Calorie tracking in 2026 mostly comes down to two bad options. You type everything in by hand, which is tedious enough that most people quit within a week. Or you use one of the AI photo-scanning features that MyFitnessPal, Lose It!, and everyone else has shipped over the last couple years, which are fast and honestly pretty good, but every one of them works the same way under the hood: your meal photo goes to a server, gets processed, and comes back with an estimate. And because someone has to pay for that server every time you scan a meal, you're on a subscription.
Both halves of that bugged me. A photo of what you eat, three times a day, is a weirdly intimate dataset to be handing to a cloud server. And paying monthly for something that's really just running a model on a photo felt like it was solving a business problem, not a user problem.
So I spent the last few months building MacroSnap, a calorie tracker that runs its AI entirely on your phone. No server. No account. No meal photos leaving the device, ever. And because there's no server to pay for, no subscription either — you buy it once and it's yours. You snap a picture, the model estimates what's on the plate, you correct anything it got wrong, and it logs. That's the whole loop.
Here's what it took to actually ship that.
The stack: Flutter, Gemma, SQLite, and nothing else
The app is Flutter, mostly for boring practical reasons. One codebase for iOS and Android, solid camera and barcode plugins, and Material widgets that don't require me to hand-roll a design system before I can test whether the core idea even works.
The real bet is on flutter_gemma, the plugin that wraps Google's Gemma models for on-device inference. That's the piece that makes any of this possible — without a plugin doing the work of getting a real LLM running inside a Flutter app, "on-device food recognition" is just a slide in a pitch deck.
For storage, it's SQLite, not Firebase or CloudKit. Once you've committed to "no cloud," a lot of decisions get easier. The food database is small enough that syncing doesn't matter, and offline-first stops being a feature you build and starts being the default you'd have to work to break.
State management is Riverpod. With no backend to lean on, the provider graph is doing more work than usual — it's the thing keeping AI output, meal logs, and coaching messages in sync, since there's no server round-trip to hide the plumbing behind.
Roughly, a meal photo flows through the app like this:
Camera → GemmaService (inference) → FoodDatabaseService (USDA lookup) → MealLogProvider (state)
Nothing about that chain is exotic. What's unusual is that every link in it runs locally.
The part nobody warns you about: getting the model onto the phone
The first real problem wasn't inference. It was distribution.
You can't bundle a multi-gigabyte model inside an App Store binary — Apple's size limits won't allow it, and even if they did, nobody wants a 2.6GB download before they've opened the app once. So the model has to come down after install, on first launch.
I used background_downloader to pull the model in the background while the user goes through onboarding. By the time someone's picked their goals and set up their profile, the model is usually ready, and the whole thing feels less like "please wait" and more like it was never a wait at all.
Where it isn't ready yet, the app has to say so honestly. No spinner that hangs forever, no silent failure where a photo just doesn't do anything. If the model isn't loaded, MacroSnap falls back to manual entry with a visible progress indicator, so the app is never just broken while you wait.
One more thing that cost me a day of confusion before I figured it out: the iOS simulator can't run inference at all. If you're building anything with flutter_gemma, budget for testing on a real device from day one, because the simulator will happily let you think everything's fine right up until it isn't.
From a photo to a macro estimate you can actually trust
The core loop is four steps:
- You take a photo with image_picker.
- Gemma looks at it and returns food names, rough ingredients, and estimated quantities.
- Those get matched against a local USDA food database using FTS5 full-text search.
- Portion math converts servings into grams and totals up the macros.
The part I spent the most time on isn't any single step — it's what the model does with its own uncertainty. Gemma doesn't return a single confident number. It returns a range, and it says why.
"This is probably chicken breast, 80% confidence, somewhere between 150 and 200 grams because the portion is hard to judge from this angle" reads very differently than "This is clearly an apple, 98% confidence, about 180 grams." Both are useful. The first one is telling you where to look before you trust it.
That's the whole design principle behind the correction UI: never hide why the model might be wrong. Show the confidence, let the user fix it before it gets logged, and don't pretend the estimate is more certain than it is.
The food database: USDA, but it fits in your pocket
Under the hood, assets/foods.db is built from the USDA's SR Legacy dataset — around 7,800 foods, compressed down to roughly a megabyte after some cleanup.
Search runs on SQLite's FTS5 with the default tokenizer, which does prefix matching — type "chick" and results for chicken start appearing before you finish typing. It's not fuzzy against typos or dropped vowels; "chckn" won't match anything, since that's a different problem (trigram or spellfix indexing) I haven't built yet. There's a build pipeline behind the database itself: scripts/build_foods_db.py pulls the raw USDA CSVs and normalizes them into the schema the app expects, and a validation script runs in CI as a gate so a bad build never ships.
I went back and forth on whether to generate this on-device versus shipping it as a build artifact, and shipping won. A database baked into the repo is deterministic, testable in CI, and doesn't depend on a network call succeeding at exactly the wrong moment. Barcode scanning gets to skip the AI step entirely when it works — a UPC match against the USDA data goes straight to a known entry, no inference required.
Teaching the model what "your" portion actually looks like
Gemma's default estimate for a bowl of rice might be 200 grams. If you personally always eat closer to 150, that gap is going to annoy you every single time.
So MacroSnap remembers. Correct a portion once, and it shows up as a one-tap "Usual" chip the next time you log that food. It's a tiny recommendation system, not a trained model — no data leaves your device, no aggregate learning across users, just your own corrections quietly narrowing the gap between what the AI guesses and what you actually eat.
It's a small feature, but it's the one that made the app feel less like "AI estimate, please adjust" and more like it was actually learning something about me specifically.
The Coach, and the tone I fought hardest for
MacroSnap generates short feedback based on your daily totals against your goals. This was the section of the app where I threw out the most drafts.
The instinct with anything gamified is to cheerlead — "Great job!" on a low-calorie day, something guilt-adjacent on a high one. I didn't want either. The brand direction I wrote for myself explicitly ruled out clinical diet-app language and shame-based nudging, and it took real prompt iteration to get a local model to consistently land somewhere calm and neutral instead of one of those two defaults.
[Placeholder: a couple of real Coach output examples — one calm/neutral response and one example of a tone the prompt was specifically engineered to avoid, so readers can see the contrast.]
Privacy as architecture, not a bullet point on the landing page
The constraint that shaped every other decision in this app is simple: there's no server. No server means no user accounts, no cloud database, no analytics pipeline quietly collecting what you eat.
Everything lives in local SQLite. Backup is a local file export, not iCloud sync. HealthKit access is read-only — MacroSnap reads your weight and activity to set better calorie targets, but it never writes anything back.
This is the opposite of the model most free apps run on, where the free tier exists to fund cloud compute and the cloud compute exists to collect data. Here, your phone is the compute. If you want to check whether that's actually true, turn on Airplane Mode. The app works exactly the same, because there's nothing it's secretly waiting on.
Cost as architecture: why "no server" also means "no subscription"
The same constraint that kills the privacy problem also kills the subscription problem, and it's worth spelling out why, because it's not a marketing decision — it's math.
Cloud AI costs money every single time someone scans a meal. Multiply that by three meals a day across a user base, and a recurring fee stops being a growth-hacking choice and starts being the only way the business survives. That's the real reason most calorie apps are subscriptions: someone has to cover the per-scan bill, and it isn't going to be the company eating it forever.
Take the cloud out of the loop and that cost disappears entirely. Once the model is on your phone, a scan doesn't cost me anything — it's just your device doing math it was already capable of. There's no per-request bill I'm quietly passing on to you every month, because there's no bill at all.
Which is why MacroSnap is a one-time purchase. Pay once, own it, keep using it for as long as the app is on your phone. No renewal reminder, no price creeping up at the next billing cycle, no losing your food log history because a subscription lapsed.
I don't think this is unique to calorie tracking, either. Anywhere the AI runs on-device, the recurring-cost justification for a subscription mostly evaporates. Privacy usually gets the headlines in these posts, but for a lot of people, the economics might be the bigger win.
What I'd do differently
- flutter_gemma works, but the plugin surface is narrow. When inference fails, debugging it is mostly guesswork — there isn't much visibility into what went wrong or why.
- The minimum iOS version constraints tied to on-device inference cut out more of my potential user base than I'd budgeted for.
- The model-download-during-onboarding flow took far more polish than I expected. Getting the timing to feel invisible instead of like a loading screen was its own small project.
- Riverpod's codegen and .freezed classes are powerful, but they add real build complexity. On a smaller team, or solo, I'd think harder about whether I needed them.
- mobile_scanner handles barcodes reliably, but it's a genuinely separate UX surface with its own edge cases to maintain.
- The default model is Gemma 4 E2B, with a heavier E4B variant available as an opt-in for better accuracy. I went back and forth on which should be the default — E4B is a noticeably bigger first-run download, and I ultimately don't have real numbers yet on how much accuracy it buys you day to day. That's worth measuring properly before I write more about it.
On-device AI is starting to feel like the right default
Apple Intelligence, Gemini Nano, and the Gemma family are all pushing the same direction: capable models that fit on a phone instead of a data center. For any app dealing with photos, health data, or anything else personal, that shift makes two arguments at once — your data stays where you can see it, and nobody has to charge you monthly to cover a server bill that no longer exists.
MacroSnap is my attempt at proving both halves of that with something real instead of just arguing it. If you want to see how it turned out, [link to App Store / repo]. And if you're building something similar, I'd genuinely like to hear what you ran into — the on-device tooling is good enough now that there's no excuse not to try.