Как я делал Stocky: предсказать, что заканчивается на вашей кухне — прямо на устройстве
Как Stocky превращает скан штрихкода и немного истории использования в список покупок, который появляется прежде, чем у вас что-то закончится — и вся математика, и все данные остаются на вашем телефоне.
Большинство приложений для кладовой — это просто приукрашенные чек-листы. Вы добавляете молоко, отмечаете молоко, снова добавляете молоко. Приложение никогда ничему не учится. Stocky начинался с другого вопроса: что, если приложение заметит, как быстро вы на самом деле всё расходуете, и пересоберёт ваш список покупок ещё до того, как у вас что-то закончится?
Эта единственная идея — маленькое, честное предсказание — определила бóльшую часть инженерных решений. Вот как это работает и почему всё выполняется локально.
Скан — это событие с меткой времени, а не строка
Наивная модель — это таблица позиций со столбцом quantity, который вы увеличиваете и уменьшаете. Она проста и забывает всё в тот же миг, когда вы её редактируете.
Вместо этого Stocky хранит события. Каждый скан, каждое «немного израсходовал», каждое пополнение — это запись, добавляемая только в конец:
@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
)
Текущее количество — это просто свёртка (fold) по событиям. Звучит расточительно, но в этом весь фокус: поскольку я храню историю, я могу задавать вопросы, на которые текущий итог ответить не в силах — например, «сколько миллилитров этого расходует семья в день?»
Предсказание намеренно скучное
Здесь нет никакой нейросети. Потребление оливкового масла в семье — не та задача, которой нужен трансформер, ей нужно среднее, которое не будет чрезмерно реагировать на один бурный уик-энд.
Для каждого продукта я вычисляю скорость потребления по скользящему окну, придавая недавним событиям чуть больший вес:
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. Когда это значение опускается ниже порога, позиция тихо оказывается в вашем списке покупок. Скорости ещё нет? Значит, нет и предсказания — Stocky лучше промолчит, чем станет гадать. Спокойное приложение предпочтёт молчать, чем ошибаться.
Частичное использование, потому что кухни аналоговые
Функцией, потребовавшей больше всего возни, была не математика, а возможность сказать «я выпил примерно треть этого». Люди не убавляют количество целыми числами. Пакет сока расходуется по 200 мл за раз, и если приложение вынуждает вас либо держать его «полным», либо помечать «закончился», предсказание превращается в мусор.
Поэтому окно расхода говорит в реальных единицах и помнит размер упаковки продукта, так что «стакан» или «половина» отображаются в нечто, что может использовать функция скорости. Это крошечный кусочек интерфейса, который тихо делает всю модель заслуживающей доверия.
Почему ничто из этого не покидает телефон
Каждое число выше выводится из ваших событий, а ваши события описывают вашу жизнь с неуютной подробностью — когда вы едите, сколько, как часто ходите за покупками. Это ровно те данные, которые я не хочу держать у себя.
Поэтому нет ни аккаунта, ни сервера, который хранил бы вашу кладовую. Всё живёт в локальной базе данных Room. Поиск продуктов обращается к Open Food Facts (и к сообществу турецких продуктов), но этот запрос содержит только штрихкод — и никогда вашу историю. Предсказание выполняется на устройстве, потому что данные уже там, и там им и место.
Local-first здесь — не маркетинговая галочка. Это сама причина, по которой честная версия этого приложения вообще стала возможной: я не могу потерять, продать или слить базу данных, которую я никогда не собирал.
Что бы я сделал иначе
Если бы я пересобирал модель скорости сегодня, я бы добавил мягкий сезонный член — что-то взлетает летом — и приор по категориям, чтобы совершенно новая позиция заимствовала разумную стартовую скорость вместо того, чтобы молчать целую неделю. Оба изменения невелики. Ни одному из них не нужно облако.
Stocky доступен в Google Play. Если вы попробуете, список покупок, который появляется сам собой, — это та часть, которой я горжусь больше всего, и та часть, о хитроумности которой вы, надеюсь, даже забудете.
// По теме
Ещё из журнала
Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах
Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.
Local-first на Android в 2026 году: SQLite, Room и хранение пользовательских данных на устройстве
Руководство 2026 года по созданию local-first приложений для Android на Room и SQLite — проектирование схемы, миграции, WAL, экспорт и когда (и когда не стоит) добавлять синхронизацию.
OCR с приоритетом приватности на Android: как Subly читает счета без облака
Практическое руководство 2026 года по OCR на устройстве под Android с ML Kit Text Recognition — настройка, код и приёмы извлечения полей, которые держат данные пользователей вне ваших серверов.