Building Stocky: predicting what your kitchen runs out of — on the device
How Stocky turns a barcode scan and a bit of usage history into a shopping list that shows up before you run out — with all the math, and all the data, staying on your phone.
Most pantry apps are glorified checklists. You add milk, you tick milk off, you add milk again. The app never learns anything. Stocky started from a different question: what if the app noticed how fast you actually go through things, and rebuilt your shopping list before you ran dry?
That one idea — a small, honest prediction — shaped most of the engineering. Here’s how it works, and why it all runs locally.
A scan is a timestamped event, not a row
The naive model is a table of items with a quantity column you increment and decrement. It’s simple, and it forgets everything the moment you edit it.
Stocky stores events instead. Every scan, every “used some”, every restock is an append-only record:
@Entity(tableName = "pantry_events")
data class PantryEvent(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val productId: String, // barcode / Open Food Facts id
val type: EventType, // ADDED, CONSUMED, DISCARDED
val amount: Double, // in the product's base unit (ml, g, pcs)
val at: Long, // epoch millis
)
The current quantity is just a fold over events. That sounds wasteful, but it’s the whole trick: because I keep the history, I can ask questions a running total can’t answer — like “how many millilitres of this does the household get through per day?”
The prediction is deliberately boring
There is no neural network here. A household’s consumption of olive oil is not a problem that needs a transformer — it needs an average that doesn’t overreact to one big weekend.
For each product I compute a consumption rate over a trailing window, weighting recent events a little more:
fun dailyRate(events: List<PantryEvent>, now: Long): Double {
val consumed = events.filter { it.type == EventType.CONSUMED }
if (consumed.size < 2) return 0.0
val windowStart = now - 30.days.inWholeMilliseconds
val recent = consumed.filter { it.at >= windowStart }
if (recent.isEmpty()) return 0.0
val total = recent.sumOf { it.amount }
val spanDays = ((now - recent.first().at) / 1.days.inWholeMilliseconds)
.coerceAtLeast(1)
return total / spanDays
}
daysLeft = currentQuantity / dailyRate. When that dips under a threshold, the item quietly lands on your shopping list. No rate yet? No prediction — Stocky says nothing rather than guessing. A calm app would rather be silent than wrong.
Partial use, because kitchens are analog
The feature that took the most fiddling wasn’t the maths — it was letting you say “I drank about a third of this.” People don’t decrement in integers. A carton of juice gets used 200 ml at a time, and if the app forces you to either keep it “full” or mark it “gone”, the prediction is garbage.
So the consume sheet speaks in real units and remembers the product’s package size, so “a glass” or “half” maps to something the rate function can use. It’s a tiny bit of UI that quietly makes the whole model trustworthy.
Why none of this leaves the phone
Every number above is derived from your events, and your events describe your life in uncomfortable detail — when you eat, how much, how often you shop. That’s exactly the data I don’t want to hold.
So there’s no account and no server that stores your pantry. Everything lives in a local Room database. Product lookups hit Open Food Facts (and a community set of Turkish products), but that request only contains a barcode — never your history. The prediction runs on-device because that’s where the data already is, and where it should stay.
Local-first isn’t a marketing checkbox here. It’s the reason the honest version of this app was even possible: I can’t lose, sell, or leak a database I never collected.
What I’d do differently
If I rebuilt the rate model today I’d add a gentle seasonal term — some things spike in summer — and a per-category prior so a brand-new item borrows a sensible starting rate instead of staying silent for a week. Both are small. Neither needs the cloud.
Stocky is live on Google Play. If you try it, the shopping list that shows up on its own is the part I’m proudest of — and the part you’ll hopefully forget is even clever.
// Related reading
More from the journal
Building Granyn: a budget tracker with no bank login, in three tables
How Granyn tracks spending across currencies and catches recurring bills without linking a single bank account — the Room schema and the trade-offs behind it.
Local-first Android in 2026: SQLite, Room, and keeping user data on the device
A 2026 guide to building local-first Android apps with Room and SQLite — schema design, migrations, WAL, exports, and when (and when not) to add sync.
Privacy-first OCR on Android: how Subly reads bills without the cloud
A practical 2026 guide to on-device OCR on Android with ML Kit Text Recognition — the setup, the code, and the field extraction tricks that keep user data off your servers.