From 43d5f668fd427df7de38838527a4035ee2b9e38b Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:34:55 +0530 Subject: [PATCH 1/2] Nutrition: search past foods, set meal time, edit entries, today's protein (#559) - Log sheet: search over every food ever logged (NutritionDb.recent query) instead of only the last five. - Date and time rows on the log sheet, defaulting to now, so a meal can be logged after the fact. - Tapping a logged entry opens the sheet in edit mode (amount, meal, time, numbers; barcode entries keep portion rescaling) via NutritionDb.update, which keeps id and created_at. Remove moved into the edit sheet. - Today tab shows today's protein against the protein target. - Strings in all seven ARBs. --- lib/data/nutrition_store.dart | 24 ++- lib/l10n/app_de.arb | 12 +- lib/l10n/app_en.arb | 45 ++++++ lib/l10n/app_es.arb | 12 +- lib/l10n/app_fr.arb | 12 +- lib/l10n/app_hi.arb | 12 +- lib/l10n/app_ru.arb | 19 ++- lib/l10n/app_zh.arb | 12 +- lib/ui2/screens/log_food.dart | 209 ++++++++++++++++++++++--- lib/ui2/screens/nutrition_screen.dart | 62 +++++--- test/nutrition_ux_559_test.dart | 211 ++++++++++++++++++++++++++ 11 files changed, 585 insertions(+), 45 deletions(-) create mode 100644 test/nutrition_ux_559_test.dart diff --git a/lib/data/nutrition_store.dart b/lib/data/nutrition_store.dart index 8a59b4ad6..091539422 100644 --- a/lib/data/nutrition_store.dart +++ b/lib/data/nutrition_store.dart @@ -474,6 +474,17 @@ class NutritionDb { ); } + /// Rewrite an existing entry in place: same id, same `created_at`, so it + /// keeps its place in [recent] and a CSV export still shows when it was + /// first logged. Totals and averages are rolled up from the table on every + /// read, so an edit lands exactly as a delete-then-add would. Same + /// [FoodEntry.sanitised] guard as [put]. + static Future update(Database db, FoodEntry e) async { + final row = e.sanitised.toRow(DateTime.now().millisecondsSinceEpoch) + ..remove('created_at'); + await db.update('food_entry', row, where: 'id = ?', whereArgs: [e.id]); + } + static Future delete(Database db, String id) => db.delete('food_entry', where: 'id = ?', whereArgs: [id]); @@ -510,12 +521,21 @@ class NutritionDb { /// The last distinct things eaten, newest first. This is what makes a repeat /// entry a two-second job, and it is why there is no need for a "favourites" /// concept on top. - static Future> recent(Database db, {int limit = 12}) async { + /// + /// [query] narrows it to labels containing that text, over the WHOLE + /// history rather than the last few — the search on the log sheet. + static Future> recent( + Database db, { + int limit = 12, + String query = '', + }) async { + final q = query.trim(); final rows = await db.rawQuery( 'SELECT * FROM food_entry WHERE id IN ' '(SELECT MAX(id) FROM food_entry GROUP BY label) ' + '${q.isEmpty ? '' : 'AND label LIKE ? '}' 'ORDER BY created_at DESC LIMIT ?', - [limit], + [if (q.isNotEmpty) '%$q%', limit], ); return [for (final r in rows) FoodEntry.fromRow(r)]; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 744868aaf..cdb633508 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2607,5 +2607,15 @@ "dataBackupFolderSub": "Wähle, wo neue Sicherungen gespeichert werden. Vorhandene Sicherungen bleiben an ihrem bisherigen Ort", "dataBackupFolderUseDefault": "Standardordner verwenden", "dataBackupFolderSaved": "Sicherungsordner gespeichert.", - "dataBackupFolderRetry": "Versuche es mit Jetzt sichern erneut oder wähle einen anderen Ordner" + "dataBackupFolderRetry": "Versuche es mit Jetzt sichern erneut oder wähle einen anderen Ordner", + "logFoodEditTitle": "Eintrag bearbeiten", + "logFoodDateLabel": "Datum", + "logFoodTimeLabel": "Uhrzeit", + "logFoodSearchLabel": "Deine Lebensmittel durchsuchen", + "logFoodSearchHint": "Alles, was du erfasst hast", + "logFoodSearchNoMatch": "Nichts, was du erfasst hast, passt dazu.", + "logFoodRemoveEntry": "Eintrag entfernen", + "nutritionProteinToday": "Protein heute", + "nutritionProteinToGo": "Noch {grams} g", + "nutritionTargetReached": "Ziel erreicht" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7cdb11fde..6837d95f0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -14526,5 +14526,50 @@ "type": "int" } } + }, + "logFoodEditTitle": "Edit this entry", + "@logFoodEditTitle": { + "description": "Log food sheet: title when editing an existing entry" + }, + "logFoodDateLabel": "Date", + "@logFoodDateLabel": { + "description": "Log food sheet: row label for the day the food was eaten" + }, + "logFoodTimeLabel": "Time", + "@logFoodTimeLabel": { + "description": "Log food sheet: row label for the time the food was eaten" + }, + "logFoodSearchLabel": "Search your foods", + "@logFoodSearchLabel": { + "description": "Log food sheet: label of the search field over past foods" + }, + "logFoodSearchHint": "Anything you have logged", + "@logFoodSearchHint": { + "description": "Log food sheet: hint in the search field over past foods" + }, + "logFoodSearchNoMatch": "Nothing you have logged matches that.", + "@logFoodSearchNoMatch": { + "description": "Log food sheet: shown when the search over past foods finds nothing" + }, + "logFoodRemoveEntry": "Remove this entry", + "@logFoodRemoveEntry": { + "description": "Log food sheet: button that removes the entry being edited" + }, + "nutritionProteinToday": "Protein today", + "@nutritionProteinToday": { + "description": "Nutrition Today tab: label of the protein-versus-target card" + }, + "nutritionProteinToGo": "{grams} g to go", + "@nutritionProteinToGo": { + "description": "Nutrition Today tab: grams of protein still needed to reach the target", + "placeholders": { + "grams": { + "type": "int" + } + } + }, + "nutritionTargetReached": "Target reached", + "@nutritionTargetReached": { + "description": "Nutrition Today tab: protein target met or exceeded" } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index cb9af1212..250f44e94 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2517,5 +2517,15 @@ "dataBackupFolderSub": "Elige dónde guardar las nuevas copias. Las copias existentes permanecen donde están", "dataBackupFolderUseDefault": "Usar carpeta predeterminada", "dataBackupFolderSaved": "Carpeta de copias guardada.", - "dataBackupFolderRetry": "Reintenta con Crear copia ahora o elige otra carpeta" + "dataBackupFolderRetry": "Reintenta con Crear copia ahora o elige otra carpeta", + "logFoodEditTitle": "Editar esta entrada", + "logFoodDateLabel": "Fecha", + "logFoodTimeLabel": "Hora", + "logFoodSearchLabel": "Busca tus alimentos", + "logFoodSearchHint": "Cualquier cosa que hayas registrado", + "logFoodSearchNoMatch": "Nada de lo que has registrado coincide.", + "logFoodRemoveEntry": "Eliminar esta entrada", + "nutritionProteinToday": "Proteína de hoy", + "nutritionProteinToGo": "Faltan {grams} g", + "nutritionTargetReached": "Objetivo alcanzado" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6b2495e1e..aba52cfe3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2488,5 +2488,15 @@ "dataBackupFolderSub": "Choisissez où enregistrer les nouvelles sauvegardes. Les sauvegardes existantes restent à leur emplacement", "dataBackupFolderUseDefault": "Utiliser le dossier par défaut", "dataBackupFolderSaved": "Dossier de sauvegarde enregistré.", - "dataBackupFolderRetry": "Réessayez avec Sauvegarder maintenant ou choisissez un autre dossier" + "dataBackupFolderRetry": "Réessayez avec Sauvegarder maintenant ou choisissez un autre dossier", + "logFoodEditTitle": "Modifier cette entrée", + "logFoodDateLabel": "Date", + "logFoodTimeLabel": "Heure", + "logFoodSearchLabel": "Rechercher vos aliments", + "logFoodSearchHint": "Tout ce que vous avez noté", + "logFoodSearchNoMatch": "Rien de ce que vous avez noté ne correspond.", + "logFoodRemoveEntry": "Supprimer cette entrée", + "nutritionProteinToday": "Protéines aujourd’hui", + "nutritionProteinToGo": "Encore {grams} g", + "nutritionTargetReached": "Objectif atteint" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b852c0b80..835323828 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2488,5 +2488,15 @@ "dataBackupFolderSub": "चुनें कि नए बैकअप कहाँ सहेजे जाएँ। पुराने बैकअप अपनी जगह पर रहेंगे", "dataBackupFolderUseDefault": "डिफ़ॉल्ट फ़ोल्डर इस्तेमाल करें", "dataBackupFolderSaved": "बैकअप फ़ोल्डर सहेजा गया।", - "dataBackupFolderRetry": "अभी बैकअप लें से फिर कोशिश करें या दूसरा फ़ोल्डर चुनें" + "dataBackupFolderRetry": "अभी बैकअप लें से फिर कोशिश करें या दूसरा फ़ोल्डर चुनें", + "logFoodEditTitle": "यह एंट्री बदलें", + "logFoodDateLabel": "तारीख", + "logFoodTimeLabel": "समय", + "logFoodSearchLabel": "अपने खाने खोजें", + "logFoodSearchHint": "जो कुछ भी आपने दर्ज किया है", + "logFoodSearchNoMatch": "आपकी दर्ज की गई चीज़ों में कुछ नहीं मिला।", + "logFoodRemoveEntry": "यह एंट्री हटाएँ", + "nutritionProteinToday": "आज का प्रोटीन", + "nutritionProteinToGo": "{grams} g बाकी", + "nutritionTargetReached": "लक्ष्य पूरा" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 94e5f2eb1..2f12ede04 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -6834,5 +6834,22 @@ "devicesOuraCheckpointUnconfirmed": "Не удалось завершить синхронизацию. Попробуйте синхронизировать ещё раз.", "investigateRmssdNightly": "RMSSD, ночной (среднее по 5-минутным окнам)", "investigateRmssdStored": "RMSSD, ночной (прежняя оценка)", - "devicesOuraWriteRefused": "Не удалось отправить команду кольцу. Попробуйте синхронизировать ещё раз." + "devicesOuraWriteRefused": "Не удалось отправить команду кольцу. Попробуйте синхронизировать ещё раз.", + "logFoodEditTitle": "Изменить запись", + "logFoodDateLabel": "Дата", + "logFoodTimeLabel": "Время", + "logFoodSearchLabel": "Поиск по вашей еде", + "logFoodSearchHint": "Всё, что вы записывали", + "logFoodSearchNoMatch": "Среди ваших записей ничего не нашлось.", + "logFoodRemoveEntry": "Удалить запись", + "nutritionProteinToday": "Белок сегодня", + "nutritionProteinToGo": "Осталось {grams} г", + "@nutritionProteinToGo": { + "placeholders": { + "grams": { + "type": "int" + } + } + }, + "nutritionTargetReached": "Цель достигнута" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 46c33f878..5a1eeffbf 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2488,5 +2488,15 @@ "dataBackupFolderSub": "选择新备份的保存位置。现有备份仍保留在原位置", "dataBackupFolderUseDefault": "使用默认文件夹", "dataBackupFolderSaved": "已保存备份文件夹。", - "dataBackupFolderRetry": "点击立即备份重试,或选择其他文件夹" + "dataBackupFolderRetry": "点击立即备份重试,或选择其他文件夹", + "logFoodEditTitle": "编辑这条记录", + "logFoodDateLabel": "日期", + "logFoodTimeLabel": "时间", + "logFoodSearchLabel": "搜索你的食物", + "logFoodSearchHint": "你记录过的任何食物", + "logFoodSearchNoMatch": "你的记录里没有匹配的内容。", + "logFoodRemoveEntry": "删除这条记录", + "nutritionProteinToday": "今日蛋白质", + "nutritionProteinToGo": "还差 {grams} 克", + "nutritionTargetReached": "已达目标" } diff --git a/lib/ui2/screens/log_food.dart b/lib/ui2/screens/log_food.dart index 12435403c..fe2d1111d 100644 --- a/lib/ui2/screens/log_food.dart +++ b/lib/ui2/screens/log_food.dart @@ -31,25 +31,33 @@ import 'package:url_launcher/url_launcher.dart'; import '../../data/db.dart'; import '../../data/day_label.dart'; +import '../../data/journal_fields.dart' show formatMinuteOfDay; import '../../data/nutrition_store.dart'; import '../../data/off_lookup.dart'; import '../../l10n/app_localizations.dart'; import '../profile/profile.dart' show SetRow; import '../ui2.dart'; import 'journal_compose.dart' show OsTextField; +import 'log_workout.dart' show dayLabel; import 'scan_barcode.dart'; class LogFoodSheet extends StatefulWidget { - const LogFoodSheet({super.key, this.date, this.meal = 'snack'}); + const LogFoodSheet({super.key, this.date, this.meal = 'snack', this.entry}); final String? date; final String meal; - /// Show as a bottom sheet. Resolves true when something was logged. + /// An existing entry to EDIT instead of logging a new one. Same id on save, + /// and the sheet can remove it. + final FoodEntry? entry; + + /// Show as a bottom sheet. Resolves true when something was logged, edited + /// or removed. static Future show( BuildContext c, { String? date, String meal = 'snack', + FoodEntry? entry, }) => showModalBottomSheet( context: c, isScrollControlled: true, @@ -58,7 +66,7 @@ class LogFoodSheet extends StatefulWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(R.xxl)), ), - builder: (_) => LogFoodSheet(date: date, meal: meal), + builder: (_) => LogFoodSheet(date: date, meal: meal, entry: entry), ); @override @@ -66,8 +74,24 @@ class LogFoodSheet extends StatefulWidget { } class _LogFoodSheetState extends State { - late final String _date = widget.date ?? todayLabel(); - late String _meal = widget.meal; + FoodEntry? get _editing => widget.entry; + late String _date = _editing?.date ?? widget.date ?? todayLabel(); + late String _meal = _editing?.meal ?? widget.meal; + + /// When it was eaten, on [_date]. Defaults to now so the one-tap path is + /// unchanged; picking one is how a meal gets logged after the fact. Null + /// only for an edited entry that never had a time — it stays unrecorded + /// rather than gaining one nobody chose. + late TimeOfDay? _time = switch (widget.entry) { + null => TimeOfDay.now(), + FoodEntry(atTs: final ts?) => + TimeOfDay.fromDateTime(DateTime.fromMillisecondsSinceEpoch(ts * 1000)), + _ => null, + }; + + /// Search over everything ever logged, not just the last five. + final _query = TextEditingController(); + bool _hasHistory = false; final _label = TextEditingController(); final _kcal = TextEditingController(); @@ -82,7 +106,7 @@ class _LogFoodSheetState extends State { final _portion = TextEditingController(); List _recent = const []; - bool _detail = false; + late bool _detail = _editing != null; /// The last scan's product, when it produced one. Holds the per-100 g /// figures the portion field rescales from, and its barcode becomes the @@ -97,14 +121,42 @@ class _LogFoodSheetState extends State { @override void initState() { super.initState(); - _loadRecent(); + final e = _editing; + if (e == null) { + _loadRecent(); + _query.addListener(_loadRecent); + } else { + _label.text = e.label; + _kcal.text = _plain(e.kcal); + _protein.text = _plain(e.proteinG); + _carbs.text = _plain(e.carbsG); + _fat.text = _plain(e.fatG); + _fibre.text = _plain(e.fibreG); + _loadScanned(e); + } _portion.addListener(_rescale); } + /// A scanned entry reopens with its product attached, so changing the + /// portion rescales the numbers the same way it did when it was logged. + /// The portion is set BEFORE the product, so the listener does not + /// overwrite numbers the user may have typed over since. + Future _loadScanned(FoodEntry e) async { + final key = e.foodKey; + if (e.source != FoodSource.barcode || key == null) return; + final row = await NutritionDb.foodDef(await LocalDb.instance, key); + if (row == null || row['source'] != 'barcode' || !mounted) return; + setState(() { + _portion.text = _plain(e.quantity); + _scanned = OffProduct.fromDefRow(row); + }); + } + @override void dispose() { _portion.removeListener(_rescale); for (final t in [ + _query, _label, _kcal, _protein, @@ -119,17 +171,72 @@ class _LogFoodSheetState extends State { } Future _loadRecent() async { + final q = _query.text.trim(); final db = await LocalDb.instance; - final r = await NutritionDb.recent(db); - if (mounted) setState(() => _recent = r); + final r = await NutritionDb.recent(db, limit: q.isEmpty ? 5 : 25, query: q); + // A slower answer to an older query must not replace a newer one. + if (!mounted || q != _query.text.trim()) return; + setState(() { + _recent = r; + if (r.isNotEmpty) _hasHistory = true; + }); } Future _write(FoodEntry e) async { final db = await LocalDb.instance; - await NutritionDb.put(db, e); + if (_editing != null) { + await NutritionDb.update(db, e); + } else { + await NutritionDb.put(db, e); + } if (mounted) Navigator.of(context).pop(true); } + /// Removing is destructive and there is no undo, so the entry is named back + /// before it goes. + Future _remove(FoodEntry e) async { + final l = AppLocalizations.of(context); + final ok = await confirmRemove( + context, + title: l?.nutritionRemoveTitle(e.label) ?? 'Remove ${e.label}?', + body: l?.nutritionRemoveBody ?? + 'It leaves the day and every average that counted it. There is no ' + 'undo.', + ); + if (!ok) return; + await NutritionDb.delete(await LocalDb.instance, e.id); + if (mounted) Navigator.of(context).pop(true); + } + + /// Epoch seconds for [_time] on [_date], or null when no time is set. + int? get _atTs { + final t = _time; + final d = DateTime.tryParse(_date); + if (t == null || d == null) return null; + return DateTime(d.year, d.month, d.day, t.hour, t.minute) + .millisecondsSinceEpoch ~/ + 1000; + } + + Future _pickDate() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: DateTime.tryParse(_date) ?? now, + firstDate: DateTime(2000), + lastDate: now, + ); + if (picked != null && mounted) setState(() => _date = dayLabelOf(picked)); + } + + Future _pickTime() async { + final picked = await showTimePicker( + context: context, + initialTime: _time ?? TimeOfDay.now(), + ); + if (picked != null && mounted) setState(() => _time = picked); + } + FoodEntry _base({ required String label, FoodSource source = FoodSource.manual, @@ -138,7 +245,7 @@ class _LogFoodSheetState extends State { date: _date, meal: _meal, label: label, - atTs: DateTime.now().millisecondsSinceEpoch ~/ 1000, + atTs: _atTs, source: source, ); @@ -330,7 +437,10 @@ class _LogFoodSheetState extends State { Row( children: [ Expanded( - child: Text(l?.logFoodTitle ?? 'Log an eating occasion', + child: Text( + _editing != null + ? (l?.logFoodEditTitle ?? 'Edit this entry') + : (l?.logFoodTitle ?? 'Log an eating occasion'), style: F.t2.copyWith(color: p.ink)), ), Pressable( @@ -369,6 +479,26 @@ class _LogFoodSheetState extends State { ], ], ), + const SizedBox(height: S.x3), + Surface( + pad: const EdgeInsets.symmetric(horizontal: S.x4), + child: Column( + children: [ + SetRow(LucideIcons.calendar, C.domFood, + l?.logFoodDateLabel ?? 'Date', + value: dayLabel(DateTime.tryParse(_date) ?? DateTime.now(), + l: l), + onTap: _pickDate), + SetRow(LucideIcons.clock, C.domFood, + l?.logFoodTimeLabel ?? 'Time', + value: _time == null + ? (l?.nutritionNotRecorded ?? 'Not recorded') + : formatMinuteOfDay(_time!.hour * 60 + _time!.minute), + onTap: _pickTime), + ], + ), + ), + if (_editing == null) ...[ const SizedBox(height: S.x5), BigButton( l?.logFoodIAte(_mealLabel(c, _meal)) ?? @@ -377,14 +507,30 @@ class _LogFoodSheetState extends State { color: C.domFood, onTap: () => _write(_base(label: _mealLabel(c, _meal))), ), - if (_recent.isNotEmpty) + if (_hasHistory) Section( l?.logFoodAgain ?? 'Again', + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OsTextField( + controller: _query, + label: l?.logFoodSearchLabel ?? 'Search your foods', + hint: l?.logFoodSearchHint ?? 'Anything you have logged', + ), + const SizedBox(height: S.x3), + if (_recent.isEmpty) + Text( + l?.logFoodSearchNoMatch ?? + 'Nothing you have logged matches that.', + style: F.cap.copyWith(color: p.ink3), + ) + else Surface( pad: const EdgeInsets.symmetric(horizontal: S.x4), child: Column( children: [ - for (final r in _recent.take(5)) + for (final r in _recent) FoodRow( entry: r, trailing: LucideIcons.circlePlus, @@ -394,8 +540,7 @@ class _LogFoodSheetState extends State { date: _date, meal: _meal, label: r.label, - atTs: - DateTime.now().millisecondsSinceEpoch ~/ 1000, + atTs: _atTs, foodKey: r.foodKey, quantity: r.quantity, unit: r.unit, @@ -412,6 +557,8 @@ class _LogFoodSheetState extends State { ], ), ), + ], + ), ), const SizedBox(height: S.x5), Pressable( @@ -433,7 +580,9 @@ class _LogFoodSheetState extends State { ], ), ), + ], if (_detail) ...[ + if (_editing == null) ...[ const SizedBox(height: S.x3), Surface( pad: const EdgeInsets.symmetric(horizontal: S.x4), @@ -450,6 +599,7 @@ class _LogFoodSheetState extends State { onTap: _looking ? null : _scan, ), ), + ], if (_looking) ...[ const SizedBox(height: S.x3), StatusCard( @@ -540,9 +690,13 @@ class _LogFoodSheetState extends State { } final b = _base(label: label); final scan = _scanned; + // An edit keeps its id and everything this form does not + // show — the nutrients only the coach writes, the note — + // rather than dropping them as a side effect. + final o = _editing; _write( FoodEntry( - id: b.id, + id: o?.id ?? b.id, date: b.date, meal: b.meal, label: label, @@ -550,8 +704,15 @@ class _LogFoodSheetState extends State { // The barcode, so a repeat of this food is a local // read — and so the row can say where its numbers came // from. NOT `verified`: see FoodSource.barcode. - foodKey: scan?.barcode, - quantity: scan == null ? null : _num(_portion), + foodKey: scan?.barcode ?? o?.foodKey, + quantity: scan == null ? o?.quantity : _num(_portion), + unit: scan == null ? (o?.unit ?? 'g') : 'g', + sugarG: o?.sugarG, + satFatG: o?.satFatG, + sodiumMg: o?.sodiumMg, + ironMg: o?.ironMg, + calciumMg: o?.calciumMg, + note: o?.note ?? '', source: scan == null ? FoodSource.manual : FoodSource.barcode, @@ -565,6 +726,16 @@ class _LogFoodSheetState extends State { ); }, ), + if (_editing != null) ...[ + const SizedBox(height: S.x3), + BigButton( + l?.logFoodRemoveEntry ?? 'Remove this entry', + icon: LucideIcons.trash2, + color: C.red, + soft: true, + onTap: () => _remove(_editing!), + ), + ], ], ], ), diff --git a/lib/ui2/screens/nutrition_screen.dart b/lib/ui2/screens/nutrition_screen.dart index 4921fec0b..d475bc946 100644 --- a/lib/ui2/screens/nutrition_screen.dart +++ b/lib/ui2/screens/nutrition_screen.dart @@ -181,20 +181,42 @@ class _NutritionScreenState extends State with RevisionReload { } } - /// Removing a log is destructive and there is no undo, so the entry is named - /// back before it goes. - Future _confirmDelete(FoodEntry e) async { - final l = AppLocalizations.of(context); - final ok = await confirmRemove( - context, - title: l?.nutritionRemoveTitle(e.label) ?? 'Remove ${e.label}?', - body: l?.nutritionRemoveBody ?? - 'It leaves the day and every average that counted it. There is no ' - 'undo.', + /// Edit an entry — amount, meal, time — or remove it from the same sheet. + Future _editFood(FoodEntry e) async { + final ok = await LogFoodSheet.show(context, entry: e); + if (ok == true) await _load(); + } + + /// Today's protein against the typed target. The Goals tab compares the + /// seven-day mean; this is the number to act on before the day ends. A + /// floor says "at least", and no figure at all is said, never drawn as 0 g. + Widget? _proteinToday(BuildContext c, NutritionDay? day) { + final target = _target('protein_target'); + if (target == null || target <= 0 || day == null || !day.logged) { + return null; + } + final l = AppLocalizations.of(c); + final label = l?.nutritionProteinToday ?? 'Protein today'; + final goal = '${target.round()} g'; + final v = day.protein.value; + if (v == null) { + return GoalTrajectory(label, l?.nutritionNotRecorded ?? 'Not recorded', + goal, l?.nutritionAddNumbersFix ?? 'Add the numbers to an occasion', + 0, C.red, rateDown: false); + } + final left = (target - v).round(); + return GoalTrajectory( + label, + '${day.protein.isFloor ? (l?.nutritionAtLeastPrefix ?? 'at least ') : ''}' + '${v.round()} g', + goal, + left > 0 + ? (l?.nutritionProteinToGo(left) ?? '$left g to go') + : (l?.nutritionTargetReached ?? 'Target reached'), + (v / target).clamp(0, 1).toDouble(), + C.red, + rateDown: false, ); - if (!ok) return; - await NutritionDb.delete(await LocalDb.instance, e.id); - await _load(); } @override @@ -244,6 +266,10 @@ class _NutritionScreenState extends State with RevisionReload { ) else DayEnergyCard(day: day, burned: _burned), + if (_proteinToday(c, day) case final protein?) ...[ + const SizedBox(height: S.x4), + protein, + ], Section( l?.nutritionOccasionsSection ?? 'Occasions', Surface( @@ -257,16 +283,16 @@ class _NutritionScreenState extends State with RevisionReload { onTap: () => _logFood(meal: m), ), // The individual entries, indented under the occasion they - // belong to, each one removable. A mistyped 2,000 kcal used - // to be permanent and it silently poisoned every seven-day - // mean it fell into. + // belong to, each one editable and removable. A mistyped + // 2,000 kcal used to be permanent and it silently poisoned + // every seven-day mean it fell into. for (final e in day?.mealEntries(m) ?? const []) Padding( padding: const EdgeInsets.only(left: S.x8), child: FoodRow( entry: e, - trailing: LucideIcons.trash2, - onTap: () => _confirmDelete(e), + trailing: LucideIcons.pencil, + onTap: () => _editFood(e), ), ), ], diff --git a/test/nutrition_ux_559_test.dart b/test/nutrition_ux_559_test.dart new file mode 100644 index 000000000..3b90162af --- /dev/null +++ b/test/nutrition_ux_559_test.dart @@ -0,0 +1,211 @@ +// #559: the Nutrition tab, made easier to use — search over everything ever +// logged, a meal time that can be set after the fact, entries that can be +// edited rather than only deleted, and today's protein against its target. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/nutrition_store.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/clock_format.dart'; +import 'package:openstrap_edge/ui2/screens/journal_compose.dart' + show OsTextField; +import 'package:openstrap_edge/ui2/screens/log_food.dart'; +import 'package:openstrap_edge/ui2/screens/nutrition_screen.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +FoodEntry _e(String id, String label, + {String? date, double? kcal, double? protein, int? at}) => + FoodEntry( + id: id, + date: date ?? todayLabel(), + meal: 'lunch', + label: label, + atTs: at, + kcal: kcal, + proteinG: protein, + sodiumMg: 400, + note: 'kept', + ); + +/// Real sqflite work runs on another isolate, so fake-async pumping alone +/// never sees it finish. +Future _settle(WidgetTester t) async { + for (var i = 0; i < 30; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(const Duration(milliseconds: 50)); + } +} + +Future _pump(WidgetTester t, Widget body, {AppState? app}) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + Widget home = Scaffold(body: body); + if (app != null) { + home = ChangeNotifierProvider.value(value: app, child: home); + } + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + builder: (c, child) => MediaQuery( + data: MediaQuery.of(c).copyWith(alwaysUse24HourFormat: true), + child: child!, + ), + home: home, + )); + await _settle(t); +} + +Finder _field(String upperLabel) => find.descendant( + of: find.widgetWithText(OsTextField, upperLabel), + matching: find.byType(TextField), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Database db; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() async { + SharedPreferences.setMockInitialValues({}); + ClockFormatController.seed(ClockFormat.h24); + await LocalDb.close(); + LocalDb.dbName = 'nutrition_ux_559_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName)); + db = await LocalDb.instance; + }); + tearDown(() async { + ClockFormatController.debugReset(); + await LocalDb.close(); + }); + + test('update rewrites in place: same id, created_at kept, totals follow', + () async { + await NutritionDb.put(db, _e('f1', 'Chocolate', kcal: 100, protein: 2)); + final created = (await db.query('food_entry')).single['created_at']; + await Future.delayed(const Duration(milliseconds: 5)); + await NutritionDb.update(db, _e('f1', 'Chocolate', kcal: 500, protein: 8)); + final rows = await db.query('food_entry'); + expect(rows, hasLength(1)); + expect(rows.single['created_at'], created); + final day = rollupDay(todayLabel(), + await NutritionDb.entriesForDay(db, todayLabel()), + today: todayLabel()); + expect(day.kcal.value, 500); + expect(day.protein.value, 8); + }); + + test('recent(query) searches the whole history, not the last five', + () async { + for (var i = 0; i < 8; i++) { + await NutritionDb.put(db, _e('f$i', 'Food $i', kcal: 10)); + await Future.delayed(const Duration(milliseconds: 3)); + } + expect((await NutritionDb.recent(db, limit: 5)).map((e) => e.label), + isNot(contains('Food 0'))); + expect((await NutritionDb.recent(db, query: 'food 0')).single.label, + 'Food 0'); + }); + + testWidgets('the log sheet searches past foods', (t) async { + await t.runAsync(() async { + for (var i = 0; i < 8; i++) { + await NutritionDb.put(db, _e('f$i', 'Food $i', kcal: 10)); + await Future.delayed(const Duration(milliseconds: 3)); + } + await NutritionDb.put(db, _e('fz', 'Oat porridge', kcal: 300)); + }); + await _pump(t, const LogFoodSheet()); + expect(find.text('Food 0'), findsNothing, reason: 'only five by default'); + await t.enterText(_field('SEARCH YOUR FOODS'), 'Food 0'); + await _settle(t); + expect(find.widgetWithText(FoodRow, 'Food 0'), findsOneWidget); + expect(find.text('Oat porridge'), findsNothing); + await t.enterText(_field('SEARCH YOUR FOODS'), 'zzz'); + await _settle(t); + expect(find.text('Nothing you have logged matches that.'), findsOneWidget); + }); + + testWidgets('a meal can be logged at a chosen time', (t) async { + await _pump(t, const LogFoodSheet(meal: 'breakfast')); + await t.tap(find.text('Time')); + await t.pumpAndSettle(); + await t.tap(find.byIcon(Icons.keyboard_outlined)); + await t.pumpAndSettle(); + final inputs = find.descendant( + of: find.byType(Dialog), matching: find.byType(TextField)); + await t.enterText(inputs.at(0), '07'); + await t.enterText(inputs.at(1), '15'); + await t.tap(find.text('OK')); + await t.pumpAndSettle(); + expect(find.text('07:15'), findsOneWidget); + + await t.tap(find.text('I ate breakfast')); + await _settle(t); + final e = (await t.runAsync( + () => NutritionDb.entriesForDay(db, todayLabel())))!.single; + final at = DateTime.fromMillisecondsSinceEpoch(e.atTs! * 1000); + expect((at.hour, at.minute), (7, 15)); + expect(dayLabelOf(at), todayLabel()); + }); + + testWidgets('an entry is edited from the Today tab, not just deleted', + (t) async { + final n = DateTime.now(); + final noon = + DateTime(n.year, n.month, n.day, 12, 30).millisecondsSinceEpoch ~/ 1000; + await t.runAsync(() => NutritionDb.put( + db, _e('f1', 'Chocolate bar', kcal: 120, protein: 2, at: noon))); + final app = AppState.forTesting(); + addTearDown(app.dispose); + await _pump(t, const NutritionScreen(), app: app); + + await t.tap(find.text('Chocolate bar')); + await t.pumpAndSettle(); + expect(find.text('Edit this entry'), findsOneWidget); + await t.enterText(_field('ENERGY (KCAL)'), '480'); + await t.tap(find.descendant( + of: find.byType(LogFoodSheet), matching: find.text('Dinner'))); + await t.pump(); + await t.ensureVisible(find.text('Save')); + await t.tap(find.text('Save')); + await _settle(t); + + final rows = (await t.runAsync( + () => NutritionDb.entriesForDay(db, todayLabel())))!; + expect(rows, hasLength(1)); + final e = rows.single; + expect(e.id, 'f1'); + expect(e.kcal, 480); + expect(e.meal, 'dinner'); + expect(e.atTs, noon, reason: 'an untouched time stays what it was'); + expect((e.sodiumMg, e.note), (400, 'kept'), + reason: 'fields the form does not show survive an edit'); + expect(find.text('480'), findsOneWidget, reason: "today's total follows"); + }); + + testWidgets("today's protein is shown against the target", (t) async { + await t.runAsync(() async { + await NutritionDb.put(db, _e('a', 'Eggs', kcal: 200, protein: 30)); + await NutritionDb.put(db, _e('b', 'Yoghurt', kcal: 100, protein: 15)); + }); + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.user = {'protein_target': 120}; + await _pump(t, const NutritionScreen(), app: app); + + expect(find.text('Protein today'), findsOneWidget); + expect(find.text('45 g'), findsOneWidget); + expect(find.text('75 g to go'), findsOneWidget); + }); +} From 39ee4978881efdc272db29f8640a99b6d94dc6cc Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:52:19 +0530 Subject: [PATCH 2/2] fix(nutrition): literal food search, one write per sheet, honest protein remainder - NutritionDb.recent escapes \, % and _ with an ESCAPE clause, so a search for "50%" or "a_b" matches only labels containing that text. - The log sheet ignores a second write tap while one is in flight, so a double tap cannot insert the same meal twice. - Protein left rounds up: under target by less than 0.5 g still reads "1 g to go", not "Target reached". --- lib/data/nutrition_store.dart | 9 +++++++-- lib/ui2/screens/log_food.dart | 22 +++++++++++++++++----- lib/ui2/screens/nutrition_screen.dart | 3 ++- test/nutrition_ux_559_test.dart | 11 +++++++++++ 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/lib/data/nutrition_store.dart b/lib/data/nutrition_store.dart index 091539422..f0ca3d2ff 100644 --- a/lib/data/nutrition_store.dart +++ b/lib/data/nutrition_store.dart @@ -533,9 +533,14 @@ class NutritionDb { final rows = await db.rawQuery( 'SELECT * FROM food_entry WHERE id IN ' '(SELECT MAX(id) FROM food_entry GROUP BY label) ' - '${q.isEmpty ? '' : 'AND label LIKE ? '}' + "${q.isEmpty ? '' : r"AND label LIKE ? ESCAPE '\' "}" 'ORDER BY created_at DESC LIMIT ?', - [if (q.isNotEmpty) '%$q%', limit], + [ + // `%` and `_` are literal text in a food name, not wildcards. + if (q.isNotEmpty) + '%${q.replaceAllMapped(RegExp(r'[\\%_]'), (m) => '\\${m[0]}')}%', + limit, + ], ); return [for (final r in rows) FoodEntry.fromRow(r)]; } diff --git a/lib/ui2/screens/log_food.dart b/lib/ui2/screens/log_food.dart index fe2d1111d..7bee02560 100644 --- a/lib/ui2/screens/log_food.dart +++ b/lib/ui2/screens/log_food.dart @@ -182,12 +182,24 @@ class _LogFoodSheetState extends State { }); } + /// Set while a write is in flight and kept through the pop that follows: + /// a second tap before the sheet closes would otherwise insert the same + /// meal twice under a fresh id. + bool _writing = false; + Future _write(FoodEntry e) async { - final db = await LocalDb.instance; - if (_editing != null) { - await NutritionDb.update(db, e); - } else { - await NutritionDb.put(db, e); + if (_writing) return; + _writing = true; + try { + final db = await LocalDb.instance; + if (_editing != null) { + await NutritionDb.update(db, e); + } else { + await NutritionDb.put(db, e); + } + } catch (_) { + _writing = false; + rethrow; } if (mounted) Navigator.of(context).pop(true); } diff --git a/lib/ui2/screens/nutrition_screen.dart b/lib/ui2/screens/nutrition_screen.dart index d475bc946..447743e08 100644 --- a/lib/ui2/screens/nutrition_screen.dart +++ b/lib/ui2/screens/nutrition_screen.dart @@ -204,7 +204,8 @@ class _NutritionScreenState extends State with RevisionReload { goal, l?.nutritionAddNumbersFix ?? 'Add the numbers to an occasion', 0, C.red, rateDown: false); } - final left = (target - v).round(); + // Rounded up: 99.6 of 100 g is still 1 g to go, never "reached". + final left = (target - v).ceil(); return GoalTrajectory( label, '${day.protein.isFloor ? (l?.nutritionAtLeastPrefix ?? 'at least ') : ''}' diff --git a/test/nutrition_ux_559_test.dart b/test/nutrition_ux_559_test.dart index 3b90162af..9303c1aab 100644 --- a/test/nutrition_ux_559_test.dart +++ b/test/nutrition_ux_559_test.dart @@ -117,6 +117,17 @@ void main() { 'Food 0'); }); + test('recent(query) reads % and _ as text, not wildcards', () async { + await NutritionDb.put(db, _e('a', 'Milk 50% less fat', kcal: 10)); + await NutritionDb.put(db, _e('b', 'Milk 500 ml', kcal: 10)); + await NutritionDb.put(db, _e('c', 'a_b bar', kcal: 10)); + await NutritionDb.put(db, _e('d', 'axb bar', kcal: 10)); + expect((await NutritionDb.recent(db, query: '50%')).single.label, + 'Milk 50% less fat'); + expect((await NutritionDb.recent(db, query: 'a_b')).single.label, + 'a_b bar'); + }); + testWidgets('the log sheet searches past foods', (t) async { await t.runAsync(() async { for (var i = 0; i < 8; i++) {