Construire Stocky : prédire ce qui va manquer dans votre cuisine — sur l'appareil lui-même
Comment Stocky transforme un scan de code-barres et un peu d'historique d'utilisation en une liste de courses qui apparaît avant que vous ne tombiez à court — avec tous les calculs, et toutes les données, qui restent sur votre téléphone.
La plupart des applications de garde-manger ne sont que des listes de tâches glorifiées. Vous ajoutez du lait, vous cochez le lait, vous rajoutez du lait. L’application n’apprend jamais rien. Stocky est parti d’une autre question : et si l’application remarquait à quelle vitesse vous consommez réellement les choses, et reconstruisait votre liste de courses avant que vous ne soyez à sec ?
Cette seule idée — une petite prédiction honnête — a façonné l’essentiel de l’ingénierie. Voici comment ça fonctionne, et pourquoi tout tourne en local.
Un scan est un événement horodaté, pas une ligne
Le modèle naïf est une table d’articles avec une colonne quantity que vous incrémentez et décrémentez. C’est simple, et ça oublie tout à l’instant même où vous la modifiez.
Stocky stocke plutôt des événements. Chaque scan, chaque « j’en ai utilisé un peu », chaque réapprovisionnement est un enregistrement en ajout seul :
@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
)
La quantité actuelle n’est qu’une réduction (fold) sur les événements. Cela peut sembler du gaspillage, mais c’est toute l’astuce : parce que je conserve l’historique, je peux poser des questions auxquelles un simple total courant ne peut pas répondre — comme « combien de millilitres de ceci le foyer consomme-t-il par jour ? »
La prédiction est délibérément ennuyeuse
Il n’y a pas de réseau de neurones ici. La consommation d’huile d’olive d’un foyer n’est pas un problème qui nécessite un transformer — il lui faut une moyenne qui ne réagit pas de façon excessive à un gros week-end.
Pour chaque produit, je calcule un taux de consommation sur une fenêtre glissante, en pondérant un peu plus les événements récents :
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. Lorsque cette valeur passe sous un seuil, l’article atterrit discrètement sur votre liste de courses. Pas encore de taux ? Pas de prédiction — Stocky ne dit rien plutôt que de deviner. Une application calme préfère se taire que se tromper.
Usage partiel, parce que les cuisines sont analogiques
La fonctionnalité qui a demandé le plus de bricolage n’était pas les maths — c’était de vous laisser dire « j’en ai bu environ un tiers ». Les gens ne décrémentent pas en nombres entiers. Une brique de jus se consomme 200 ml à la fois, et si l’application vous force à la garder « pleine » ou à la marquer « finie », la prédiction ne vaut rien.
Alors la feuille de consommation parle en unités réelles et se souvient de la taille de l’emballage du produit, si bien qu’« un verre » ou « la moitié » correspond à quelque chose que la fonction de taux peut utiliser. C’est un tout petit bout d’interface qui rend discrètement tout le modèle digne de confiance.
Pourquoi rien de tout cela ne quitte le téléphone
Chaque chiffre ci-dessus est dérivé de vos événements, et vos événements décrivent votre vie avec un niveau de détail inconfortable — quand vous mangez, combien, à quelle fréquence vous faites vos courses. C’est précisément la donnée que je ne veux pas détenir.
Il n’y a donc ni compte, ni serveur qui stocke votre garde-manger. Tout réside dans une base de données Room locale. Les recherches de produits interrogent Open Food Facts (et un ensemble communautaire de produits turcs), mais cette requête ne contient qu’un code-barres — jamais votre historique. La prédiction tourne sur l’appareil parce que c’est là que la donnée se trouve déjà, et là où elle doit rester.
Le local-first n’est pas ici une case à cocher marketing. C’est la raison même pour laquelle la version honnête de cette application a pu exister : je ne peux ni perdre, ni vendre, ni laisser fuiter une base de données que je n’ai jamais collectée.
Ce que je ferais différemment
Si je reconstruisais le modèle de taux aujourd’hui, j’ajouterais un terme saisonnier léger — certaines choses grimpent en été — et un a priori par catégorie, pour qu’un article tout neuf emprunte un taux de départ raisonnable au lieu de rester muet pendant une semaine. Les deux sont modestes. Aucun n’a besoin du cloud.
Stocky est disponible sur Google Play. Si vous l’essayez, la liste de courses qui apparaît d’elle-même est la partie dont je suis le plus fier — et celle dont vous oublierez, je l’espère, qu’elle est même ingénieuse.
// À lire aussi
D’autres notes du journal
Construire Granyn : un suivi de budget sans connexion bancaire, en trois tables
Comment Granyn suit les dépenses dans plusieurs devises et détecte les factures récurrentes sans lier le moindre compte bancaire — le schéma Room derrière tout ça, et les compromis qu'il impose.
Android local-first en 2026 : SQLite, Room et garder les données de l'utilisateur sur l'appareil
Un guide 2026 pour construire des applications Android local-first avec Room et SQLite — conception du schéma, migrations, WAL, exports, et quand (et quand ne pas) ajouter la synchronisation.
OCR respectueux de la vie privée sur Android : comment Subly lit les factures sans le cloud
Un guide pratique 2026 de l'OCR sur l'appareil sous Android avec ML Kit Text Recognition — la configuration, le code et les astuces d'extraction de champs qui gardent les données utilisateur hors de vos serveurs.