Why I built another calorie tracker
Every mainstream calorie tracker follows roughly the same playbook: create an account before you're allowed to log a banana, sync your eating habits to a server, and monetize that data one way or another — subscriptions, ads, or both. I wanted something different: an app that opens straight into today's diary, stores everything locally, and sends exactly one thing over the network — a barcode I chose to scan.
That app is FairTrack, and version 1.0.1 just shipped as the first public release. It's built with Kotlin and Jetpack Compose, and it's source-available on GitHub. In this post I want to walk through some of the more interesting engineering decisions: the offline-first data model, why there's no charting library, how the Room migrations are structured, and the privacy trade-offs that shaped the architecture.
Repo: github.com/daonware-it/FairTrack
The stack, briefly
| Concern | Choice |
|---|---|
| Language | Kotlin 2.3.20 |
| UI | Jetpack Compose (Material 3), Glance for widgets |
| Architecture | MVVM, unidirectional state via StateFlow
|
| DI | Hilt |
| Persistence | Room (versioned migrations), DataStore for preferences |
| Networking | Retrofit, OkHttp, kotlinx.serialization |
| Background work | WorkManager |
| Images | Coil |
minSdk is 24 (Android 7.0), with core library desugaring so java.time is usable across all supported versions.
Offline-first is a data-modeling problem, not a UI problem
"Offline-first" is often treated as a caching layer bolted onto an otherwise online app. I wanted it to be the default state, not a fallback. Concretely that meant:
- A curated offline catalogue of 116 fruits and vegetables, bundled with the app, so the single most common logging action — "I ate an apple" — never touches the network at all.
- Micronutrient reference data derived from the German Bundeslebensmittelschlüssel (BLS 4.0, Max Rubner-Institut, CC BY 4.0), also bundled locally, covering 14 vitamins and minerals against EU nutrient reference values (Regulation 1169/2011).
-
Barcode scanning as the only network-dependent path. CameraX + ML Kit handle the scan itself on-device; only the resolved barcode is sent to the Open Food Facts API (
ODbLlicense) to fetch product data. No analytics SDK ships in the dependency graph — that's directly checkable in the build files. The practical effect: you can install FairTrack in airplane mode, log a custom meal, hit your macro goals, and never notice the network doesn't exist.
Room migrations: destructive fallback is a trap
Room's fallbackToDestructiveMigration() is tempting during early development — schema changes stop being annoying. But for an app whose entire value proposition is "your data lives only on your device," a silent wipe on a missed migration is unacceptable. There's no server copy to restore from.
So the migration policy is:
- Every schema change ships an explicit, versioned
Migrationindata/Migrations.kt. - The Room schema is exported to
app/schemas/and checked in, so historical schemas are diffable in review. - Destructive fallback is enabled only for downgrades, not upgrades — a missing forward migration fails loudly (crash, visible bug report) instead of quietly deleting a user's weight history. A minimal example of the pattern — adding a column instead of letting Room recreate the table:
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE diary_entry ADD COLUMN portion_note TEXT DEFAULT NULL"
)
}
}
This is more upfront work per schema change, but it matches the app's actual promise: there's no cloud backup to fall back on, so local data integrity has to be treated as load-bearing.
Why there's no charting library
Statistics — weekly calorie bars, a macro donut, logging streaks — are drawn directly on a Compose Canvas. No Vico, no MPAndroidChart wrapper, nothing pulled from Maven for this.
The reasoning was mostly pragmatic rather than ideological: the chart set is small and fixed (bars, a donut, a streak grid), the styling needs to match the app's Material You theming exactly rather than approximate it through a library's theming API, and a raw Canvas implementation is a few hundred lines total versus a dependency that would otherwise dominate the APK's chart-related surface area. For a small, fixed set of visualizations, hand-rolling them ended up being both smaller and easier to theme consistently than adapting a general-purpose library.
Cloud backup is disabled on purpose
This one surprises people: Android's automatic cloud backup (android:allowBackup / auto backup to Google Drive) is explicitly turned off. The default Android behavior would otherwise back up the app's local database — including diary entries, weight history, and body measurements — to the user's Google Drive without any additional code from me.
That's exactly the kind of silent cloud sync FairTrack is meant to avoid, so it's disabled deliberately, and users get an explicit, user-initiated alternative instead: export the whole diary to a JSON file and restore it on another device manually. Opt-in, visible, and fully under the user's control — the opposite of a background sync job.
Goal calculation: Mifflin–St Jeor, with guardrails
Calorie and macro targets come from the Mifflin–St Jeor BMR equation multiplied by an activity factor, with manual overrides available for anyone who wants to set their own numbers directly. A few things layered on top of the raw formula:
- Protein and fat scale with goal and activity level; carbohydrates take whatever's left in the calorie budget, rather than all three macros being independently user-set percentages that can silently sum to something nonsensical.
- BMI feedback uses an age-dependent healthy range rather than a single fixed cutoff.
- There's a guard against underweight targets — the app won't quietly let a goal calculation land somewhere unsafe without surfacing that. None of this is a substitute for medical advice, and the app says so explicitly (nutrition data accuracy depends on crowd-sourced sources, and micronutrient coverage in particular is uneven).
CI/CD and keeping the signing key out of the repo
A few pipeline details that might be useful if you're setting up something similar:
-
ci.ymlcompiles the debug variant on every push and PR. -
codeql.ymlruns CodeQL static analysis on the Kotlin sources on a schedule and on PRs. -
benchmark.ymlruns Macrobenchmark-based cold/warm startup timings weekly on an emulator — the trend matters more than any single absolute number. -
release.ymlbuilds and signs the APK for GitHub Releases on tag push. - Third-party GitHub Actions are pinned to commit SHAs, not floating tags, and every workflow starts from
permissions: {}before opting into what it actually needs. The signed Play Store bundle is built by a separate Azure DevOps pipeline, so the Play upload key never has to exist as a secret inside the GitHub repo at all — a clean separation between "public CI for a source-available repo" and "the credential that can push to production."
One consequence of shipping both a sideloaded APK and a Play Store build: they're signed with different keys, and Android refuses to let one signature replace another. A sideloaded install and a Play install can't update each other — switching sources means exporting your data, uninstalling, and restoring on the other build.
What's next
1.0.1 is the first public release, so the immediate focus is stability and closing gaps in micronutrient coverage.
The license is source-available, not OSI open source: you may read, build, modify and run FairTrack for private, non-commercial use, and contribute back. Redistribution — including publishing it to an app store — requires visible attribution to the project. Commercial use needs written permission. Full terms are in
LICENSE.
Repo: github.com/daonware-it/FairTrack
Releases: Latest release
Happy to go deeper on any of the pieces above — the migration strategy, the Canvas chart implementation, or the WorkManager setup behind the fasting timer's persistent notification — if there's interest in the comments.
Top comments (0)