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
Chiffrer une base Room en 2026 : SQLCipher, le Keystore, et migrer sans perdre de données
Un guide pratique pour chiffrer au repos une base Room/SQLite sur Android avec SQLCipher — gestion des clés via le Keystore, migration unique, et le vrai coût en performance.
Sauvegarder des données local-first sur Android en 2026 : export SAF, Auto Backup, et un flux de restauration fiable
Comment les applications Android local-first sauvegardent les données utilisateur sans serveur : export/import SAF, Auto Backup for App Data, et une restauration qui vérifie avant d'écraser.
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.