Website: https://ebeeries.github.io/PackCal/ · Download APK: latest release
PackCal answers one question quickly: how many calories are in this whole package?
Nutrition labels print energy per 100 g or per serving, but rarely the total for the pack you are holding. Type the calories per 100 g and the package weight, and PackCal shows the whole-package total instantly, plus portions, a "how much can I eat?" reverse calculator, multipack maths, side-by-side comparison, saved products and history.
totalCalories = caloriesPer100 × packageWeight / 100
527 kcal/100g × 45 g → 237.15 kcal
85 kcal/100g × 400 g → 340 kcal
610 kcal/100g × 30 g → 183 kcal
The formula works for every package size. Nothing in the app assumes a package is at least 100 g.
Coming soon.
| Calculate | Compare | Saved | Settings |
|---|---|---|---|
docs/screenshots/calculate.png |
docs/screenshots/compare.png |
docs/screenshots/saved.png |
docs/screenshots/settings.png |
- Live calculation while typing, no Calculate button. Accepts
123.5and123,5. - g / ml basis switch and kcal / kJ input toggle (1 kcal = 4.184 kJ, conversion shown inline).
- Derived portions: 1 g, 10 g, 25 g, 50 g (only when smaller than the pack), ¼, ½ and whole package.
- Portion slider from 0 to the full package, with an exact-amount field.
- How much can I eat? Enter a calorie limit and get the matching amount and percentage, capped at the package with a clear "you can eat the whole package" message.
- Multipack mode for "6 × 25 g" style products, including "eating N items".
- Package size presets grouped by product type, plus your own favourite sizes.
- Compare two products side by side with neutral difference wording.
- Saved products with categories and history of recent calculations, both stored locally.
- Optional daily calorie context ("12 % of your 1,900 kcal target"). PackCal is a calculator, not a tracker.
- Label scanner (secondary): on-device OCR with ML Kit reads "kcal per 100 g" and net weight. Values are always shown for confirmation before use.
- English and Greek, light and dark themes, TalkBack labels, large text support, edge-to-edge layout, launcher shortcut "Quick Calculate".
| Area | Choice |
|---|---|
| Language | Kotlin 2.1 |
| UI | Jetpack Compose, Material 3 |
| Architecture | MVVM, unidirectional data flow, StateFlow |
| Navigation | Navigation Compose (type-safe routes) |
| Persistence | Room (products, history, favourite sizes), DataStore Preferences (settings) |
| Async | Kotlin coroutines / Flow |
| OCR | ML Kit Text Recognition (bundled, on-device) + CameraX |
| Build | Gradle Kotlin DSL, version catalog, AGP 8.12 |
| Min / target SDK | 26 / 36 |
No backend, no accounts, no analytics, no ads. Camera permission is requested only when the user opens Scan label.
app/src/main/java/com/packcal/app
├── PackCalApplication.kt Application; owns AppContainer
├── AppContainer.kt Manual dependency container (no DI framework)
├── MainActivity.kt Single activity, splash, edge-to-edge, shortcut handling
├── domain/
│ ├── model/ ProductCalculation, SavedProduct, CalculationHistory,
│ │ PackagePreset(s), UserPreferences, units, results
│ ├── calc/ CalorieCalculator (every formula), NumberParser, InputLimits
│ ├── format/ NumberFormatter (locale-aware display rounding)
│ └── scan/ LabelParser (pure-Kotlin OCR text heuristics)
├── data/
│ ├── db/ Room entities, DAOs, PackCalDatabase
│ ├── prefs/ UserPreferencesRepository (DataStore)
│ ├── repository/ SavedProduct / History / FavoriteSize repositories
│ └── session/ CalculatorSession: in-memory hand-off between screens
└── ui/
├── theme/ Colours, typography, shapes, PackCalTheme
├── components/ Reusable pieces: NumericInputField, SegmentedToggle,
│ AnimatedNumberText, cards, dialogs, haptics, formatting
├── navigation/ PackCalApp: bottom bar + NavHost
├── calculate/ CalculateScreen, sections, CalculateViewModel, UI state
├── compare/ CompareScreen, CompareViewModel
├── saved/ SavedScreen (Products + History tabs), SavedViewModel
├── settings/ SettingsScreen, SettingsViewModel
└── scan/ ScanScreen (CameraX), ScanViewModel, TextRecognitionEngine
- Domain layer is plain Kotlin with no Android imports.
CalorieCalculatoris the only place that multiplies calories; composables never contain formulas. Everything here is covered by JVM unit tests. - Data layer wraps Room and DataStore. Reads are
Flows that fall back to empty data on I/O errors; writes returnResultso the UI can show a snackbar instead of crashing. - ViewModels hold raw input text in
SavedStateHandle(survives rotation and process death) and derive an immutableUiStatewithcombine. History is written automatically after the inputs settle for ~2 s. - Screens are stateless Compose functions driven by
UiStateand callbacks. Navigation uses oneScaffoldwith a Material 3 bottom bar; the scanner is a full-screen route. - CalculatorSession is a tiny in-memory state holder used to load a saved product, history entry or scanned label into the calculator, and to focus the calorie field when the app is opened via the Quick Calculate shortcut.
- Android Studio Ladybug or newer (any version bundling JDK 17+ works), or a JDK 17+ and the Android SDK with platform 36 and build-tools 36.
- Internet access on the first build to download Gradle 8.14 and dependencies.
- File ▸ Open… and pick the
PackCalfolder. - Let Gradle sync. Android Studio uses its bundled JDK automatically.
- Run the
appconfiguration on a device or emulator (API 26+).
Create local.properties with your SDK path if Android Studio has not already:
sdk.dir=C\:\\Users\\<you>\\AppData\\Local\\Android\\Sdk
Then:
# Windows
gradlew.bat :app:assembleDebug
# macOS / Linux
./gradlew :app:assembleDebugIf your default java is older than 17, point Gradle at a JDK 17+:
JAVA_HOME=/path/to/jdk-17 ./gradlew :app:assembleDebug./gradlew :app:testDebugUnitTestTests cover the whole-package formula (including the examples above), reverse calculation, multipack, kJ conversion, zero/negative/malformed input handling, display formatting in English and Greek locales, and the OCR label parser.
Add a signing config to app/build.gradle.kts (or sign afterwards with apksigner), then:
./gradlew :app:assembleRelease # APK
./gradlew :app:bundleRelease # AAB for PlayRelease builds have R8 minification and resource shrinking enabled.
| Artifact | Path |
|---|---|
| Debug APK | app/build/outputs/apk/debug/app-debug.apk |
| Release APK | app/build/outputs/apk/release/app-release.apk (unsigned if no signing config) |
| Release AAB | app/build/outputs/bundle/release/app-release.aab |
| Unit test report | app/build/reports/tests/testDebugUnitTest/index.html |
The launcher icon and in-app logo are PNGs generated by tools/generate_icons.py
(requires Python 3 with Pillow: pip install Pillow). It renders the PackCal artwork
(white snack wrapper with "kcal", green calculator badge, green sparks) at every density
into app/src/main/res/mipmap-*/ and the in-app logo into drawable-nodpi/packcal_logo.png.
art/icon_preview.png is the full 1024 px icon with wordmark for store listings.
To use the original illustration instead of the procedural rendering, save a square,
transparent PNG of the wrapper illustration as art/logo_source.png and run
python tools/generate_icons.py again; every asset is regenerated from it.
- The scanner uses ML Kit Text Recognition (Latin) bundled in the APK, so it works offline and needs no API key. CameraX provides the preview and capture; a gallery picker is offered as an alternative that needs no permission at all.
LabelParseris a heuristic: it looks forkcal/kJnumbers near "per 100 g/ml" markers and for net-weight style amounts, ignores nutrient lines (fat, sugars, protein…) and treats the "100 g" basis as never being the package size. kJ-only labels are converted.- OCR is never trusted blindly. The review sheet shows what was detected, lets the user edit both values and choose the unit, and only then loads them into the calculator.
- The scanner is isolated in
ui/scan. Removing the CameraX and ML Kit dependencies and theScanRouteleaves the rest of the app fully functional.
Everything stays on the device: saved products, history, favourite sizes and settings live in a local Room database and DataStore file. There is no network code, no account, no analytics or advertising SDK. The only runtime permission is the camera, requested only when Scan label is opened, and photos are processed locally and never stored.
Copyright © PackCal contributors. All rights reserved unless a licence file is added.