From 4c6083b7680a49dd0ad69ca9a672a8b6f55d94bf Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 4 Jul 2026 05:18:32 +0200 Subject: [PATCH] Add food bank, edit-entry, and app settings screens; history redesign Work-in-progress feature set accumulated ahead of the log-meal compact layout change: a food bank browser, a shared entry-edit screen, an app-wide settings service, and a substantially reworked history screen with filtering/sorting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018UorgLvWJ4huH55tmXoUAZ --- app/lib/main.dart | 2 + app/lib/models/food_bank_record.dart | 21 +- app/lib/models/slot.dart | 7 +- app/lib/screens/edit_entry_screen.dart | 182 +++ app/lib/screens/food_bank_screen.dart | 712 ++++++++++++ app/lib/screens/history_screen.dart | 1034 ++++++++++++++++- app/lib/screens/settings_screen.dart | 27 +- app/lib/services/app_settings_service.dart | 87 ++ app/lib/services/foodbank_service.dart | 92 +- app/lib/services/github_client.dart | 2 +- app/lib/services/log_storage_service.dart | 63 +- app/lib/services/sync_service.dart | 3 +- app/test/screens/edit_entry_screen_test.dart | 268 +++++ app/test/screens/food_bank_screen_test.dart | 613 ++++++++++ .../screens/meal_builder_screen_test.dart | 123 +- .../services/app_settings_service_test.dart | 115 ++ app/test/services/foodbank_service_test.dart | 213 ++++ app/test/services/fuzzy_test.dart | 10 +- app/test/services/github_client_test.dart | 7 +- .../services/log_storage_service_test.dart | 193 ++- .../services/photo_attach_service_test.dart | 31 +- app/test/services/sync_merge_test.dart | 97 +- app/test/widget_test.dart | 5 +- 23 files changed, 3768 insertions(+), 139 deletions(-) create mode 100644 app/lib/screens/edit_entry_screen.dart create mode 100644 app/lib/screens/food_bank_screen.dart create mode 100644 app/lib/services/app_settings_service.dart create mode 100644 app/test/screens/edit_entry_screen_test.dart create mode 100644 app/test/screens/food_bank_screen_test.dart create mode 100644 app/test/services/app_settings_service_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index 6b01c7f..80cfdc5 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -5,6 +5,7 @@ library; import 'dart:io'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/background_check_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; @@ -15,6 +16,7 @@ import 'package:workmanager/workmanager.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await LogStorageService.init(); + await AppSettingsService.init(); await FoodBankService.init(); final notifications = await NotificationService.init(); await notifications.requestPermission(); diff --git a/app/lib/models/food_bank_record.dart b/app/lib/models/food_bank_record.dart index 76a7142..be43aef 100644 --- a/app/lib/models/food_bank_record.dart +++ b/app/lib/models/food_bank_record.dart @@ -23,17 +23,16 @@ class FoodBankRecord { }); /// Builds a [FoodBankRecord] from its JSON map representation. - factory FoodBankRecord.fromJson(Map json) => - FoodBankRecord( - desc: json['desc'] as String? ?? '', - kcal: (json['kcal'] as num?)?.toDouble() ?? 0, - proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0, - carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0, - fatG: (json['fat_g'] as num?)?.toDouble() ?? 0, - grams: (json['grams'] as num?)?.toDouble() ?? 0, - count: (json['count'] as num?)?.toDouble() ?? 0, - components: (json['components'] as List?)?.cast(), - ); + factory FoodBankRecord.fromJson(Map json) => FoodBankRecord( + desc: json['desc'] as String? ?? '', + kcal: (json['kcal'] as num?)?.toDouble() ?? 0, + proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0, + carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0, + fatG: (json['fat_g'] as num?)?.toDouble() ?? 0, + grams: (json['grams'] as num?)?.toDouble() ?? 0, + count: (json['count'] as num?)?.toDouble() ?? 0, + components: (json['components'] as List?)?.cast(), + ); /// The food or meal's display name, as the user typed it. final String desc; diff --git a/app/lib/models/slot.dart b/app/lib/models/slot.dart index ee82bdd..22c637b 100644 --- a/app/lib/models/slot.dart +++ b/app/lib/models/slot.dart @@ -23,8 +23,11 @@ const int gateEatingEndHour = 22; /// Mirrors `_slots.day_slots`. List daySlots() { final slots = []; - for (var hour = gateDayStartHour; hour < gateEatingEndHour; - hour += gateSlotIntervalHours) { + for ( + var hour = gateDayStartHour; + hour < gateEatingEndHour; + hour += gateSlotIntervalHours + ) { slots.add(hour); } return slots; diff --git a/app/lib/screens/edit_entry_screen.dart b/app/lib/screens/edit_entry_screen.dart new file mode 100644 index 0000000..b3edccc --- /dev/null +++ b/app/lib/screens/edit_entry_screen.dart @@ -0,0 +1,182 @@ +/// Screen for editing an existing meal-history entry in-place. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/models/food_suggestion.dart'; +import 'package:diet_guard_app/models/nutrition.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart'; +import 'package:diet_guard_app/widgets/macro_input_row.dart'; +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +/// Edit screen for an existing [FoodEntry]. +/// +/// Preserves [FoodEntry.id], [FoodEntry.time], [FoodEntry.slot], +/// [FoodEntry.imagePath], and [FoodEntry.deleted]. All nutritional fields and +/// the description are editable. +class EditEntryScreen extends StatefulWidget { + /// Creates an [EditEntryScreen] pre-filled with [entry]. + const EditEntryScreen({required this.entry, super.key}); + + /// The entry to edit. + final FoodEntry entry; + + @override + State createState() => _EditEntryScreenState(); +} + +class _EditEntryScreenState extends State { + late final TextEditingController _descController; + final MacroControllers _macros = MacroControllers(); + List _suggestions = const []; + String _source = 'manual'; + String? _status; + + @override + void initState() { + super.initState(); + final e = widget.entry; + _descController = TextEditingController(text: e.desc); + _macros.kcal.text = e.kcal.toStringAsFixed(0); + _macros.protein.text = e.proteinG.toStringAsFixed(0); + _macros.carbs.text = e.carbsG.toStringAsFixed(0); + _macros.fat.text = e.fatG.toStringAsFixed(0); + _macros.grams.text = e.grams > 0 ? e.grams.toStringAsFixed(0) : ''; + _source = e.source; + + _descController.addListener(_onDescChanged); + for (final c in [ + _macros.kcal, + _macros.protein, + _macros.carbs, + _macros.fat, + _macros.perGrams, + _macros.grams, + ]) { + c.addListener(_onMacroEdited); + } + unawaited(_onDescChanged()); + } + + @override + void dispose() { + _descController.dispose(); + _macros.dispose(); + super.dispose(); + } + + void _onMacroEdited() { + if (_source == 'food bank') { + setState(() => _source = 'manual'); + } + } + + Future _onDescChanged() async { + final matches = await FoodBankService.instance.search( + _descController.text, + ); + if (!mounted) return; + setState(() => _suggestions = matches); + } + + void _onSuggestionSelected(FoodSuggestion suggestion) { + _descController.text = suggestion.name; + _macros.kcal.text = suggestion.nutrition.kcal.toStringAsFixed(0); + _macros.protein.text = suggestion.nutrition.proteinG.toStringAsFixed(0); + _macros.carbs.text = suggestion.nutrition.carbsG.toStringAsFixed(0); + _macros.fat.text = suggestion.nutrition.fatG.toStringAsFixed(0); + _macros.perGrams.text = suggestion.nutrition.grams.toStringAsFixed(0); + _macros.grams.text = suggestion.nutrition.grams.toStringAsFixed(0); + setState(() { + _source = 'food bank'; + _suggestions = const []; + }); + } + + double _parse(TextEditingController c) => double.tryParse(c.text.trim()) ?? 0; + + Future _onSave() async { + final desc = _descController.text.trim(); + if (desc.isEmpty) { + setState(() => _status = 'Description cannot be empty.'); + return; + } + final nutrition = nutritionForPortion( + kcal: _parse(_macros.kcal), + proteinG: _parse(_macros.protein), + carbsG: _parse(_macros.carbs), + fatG: _parse(_macros.fat), + perGrams: _parse(_macros.perGrams), + ateGrams: _parse(_macros.grams), + source: _source, + ); + final e = widget.entry; + final updated = FoodEntry( + // Assign a UUID when editing a legacy entry that never had one. + // This upgrades it to a first-class sync entry without creating a + // duplicate: the Python sync deduplicates null-id entries by time+desc. + id: e.id ?? const Uuid().v4(), + time: e.time, + desc: desc, + grams: nutrition.grams, + kcal: nutrition.kcal, + proteinG: nutrition.proteinG, + carbsG: nutrition.carbsG, + fatG: nutrition.fatG, + source: _source, + slot: e.slot, + imagePath: e.imagePath, + ); + await LogStorageService.instance.updateEntry(e, updated); + final log = await LogStorageService.instance.readLog(); + await FoodBankService.instance.rebuildAndPersist(log); + if (!mounted) return; + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Edit meal')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'What did you eat?', + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: AutocompleteSuggestionList( + suggestions: _suggestions, + onSelected: _onSuggestionSelected, + ), + ), + const SizedBox(height: 12), + MacroInputRow(controllers: _macros), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _onSave, + child: const Text('Save'), + ), + ), + if (_status != null) ...[ + const SizedBox(height: 12), + Text(_status!), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/screens/food_bank_screen.dart b/app/lib/screens/food_bank_screen.dart new file mode 100644 index 0000000..4e8855b --- /dev/null +++ b/app/lib/screens/food_bank_screen.dart @@ -0,0 +1,712 @@ +/// Food bank browser: lists every entry across the log-derived and manual +/// banks with filtering, sorting, and the ability to add new manual entries. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:flutter/material.dart'; + +// --------------------------------------------------------------------------- +// Filter / sort state +// --------------------------------------------------------------------------- + +/// Sort field for the food bank list. +enum FbSortField { + /// Sort alphabetically by name. + name, + + /// Sort by calories. + kcal, + + /// Sort by protein (g). + protein, + + /// Sort by carbohydrates (g). + carbs, + + /// Sort by fat (g). + fat, + + /// Sort by usage count (most-used first by default). + count, +} + +/// Active filter criteria for the food bank list. +class FbFilter { + /// Creates a [FbFilter] with the given criteria. + FbFilter({ + this.nameQuery = '', + this.minKcal, + this.maxKcal, + this.minProtein, + this.maxProtein, + this.minCarbs, + this.maxCarbs, + this.minFat, + this.maxFat, + }); + + /// Substring match on the food name. + String nameQuery; + + /// Minimum kcal. + double? minKcal; + + /// Maximum kcal. + double? maxKcal; + + /// Minimum protein (g). + double? minProtein; + + /// Maximum protein (g). + double? maxProtein; + + /// Minimum carbs (g). + double? minCarbs; + + /// Maximum carbs (g). + double? maxCarbs; + + /// Minimum fat (g). + double? minFat; + + /// Maximum fat (g). + double? maxFat; + + /// True when any criterion is set. + bool get isActive => + nameQuery.isNotEmpty || + minKcal != null || + maxKcal != null || + minProtein != null || + maxProtein != null || + minCarbs != null || + maxCarbs != null || + minFat != null || + maxFat != null; +} + +// --------------------------------------------------------------------------- +// Pure filter / sort helper +// --------------------------------------------------------------------------- + +/// Filters and sorts [entries] by [filter] and [sortField]/[ascending]. +/// +/// Exposed as a top-level function for unit tests. +List applyFbFilter( + List entries, + FbFilter filter, + FbSortField sortField, { + required bool ascending, +}) { + var result = [...entries]; + if (filter.nameQuery.isNotEmpty) { + final q = filter.nameQuery.toLowerCase(); + result = result.where((e) => e.desc.toLowerCase().contains(q)).toList(); + } + if (filter.minKcal != null) { + result = result.where((e) => e.kcal >= filter.minKcal!).toList(); + } + if (filter.maxKcal != null) { + result = result.where((e) => e.kcal <= filter.maxKcal!).toList(); + } + if (filter.minProtein != null) { + result = result.where((e) => e.proteinG >= filter.minProtein!).toList(); + } + if (filter.maxProtein != null) { + result = result.where((e) => e.proteinG <= filter.maxProtein!).toList(); + } + if (filter.minCarbs != null) { + result = result.where((e) => e.carbsG >= filter.minCarbs!).toList(); + } + if (filter.maxCarbs != null) { + result = result.where((e) => e.carbsG <= filter.maxCarbs!).toList(); + } + if (filter.minFat != null) { + result = result.where((e) => e.fatG >= filter.minFat!).toList(); + } + if (filter.maxFat != null) { + result = result.where((e) => e.fatG <= filter.maxFat!).toList(); + } + + result.sort((a, b) { + int cmp; + switch (sortField) { + case FbSortField.name: + cmp = a.desc.compareTo(b.desc); + case FbSortField.kcal: + cmp = a.kcal.compareTo(b.kcal); + case FbSortField.protein: + cmp = a.proteinG.compareTo(b.proteinG); + case FbSortField.carbs: + cmp = a.carbsG.compareTo(b.carbsG); + case FbSortField.fat: + cmp = a.fatG.compareTo(b.fatG); + case FbSortField.count: + cmp = a.count.compareTo(b.count); + } + return ascending ? cmp : -cmp; + }); + + return result; +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +/// Lists all food bank entries (log-derived + manual) with filtering/sorting +/// and a FAB for adding new manual entries. +class FoodBankScreen extends StatefulWidget { + /// Creates a [FoodBankScreen]. + const FoodBankScreen({super.key}); + + @override + State createState() => _FoodBankScreenState(); +} + +class _FoodBankScreenState extends State { + List? _allEntries; + List _displayed = const []; + FbFilter _filter = FbFilter(); + FbSortField _sortField = FbSortField.count; + bool _sortAscending = false; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final entries = await FoodBankService.instance.mergedEntries(); + if (!mounted) return; + setState(() { + _allEntries = entries; + _displayed = applyFbFilter( + entries, + _filter, + _sortField, + ascending: _sortAscending, + ); + }); + } + + void _applyFilterSort() { + setState(() { + _displayed = applyFbFilter( + _allEntries!, + _filter, + _sortField, + ascending: _sortAscending, + ); + }); + } + + Future _openFilterSheet() async { + final all = _allEntries!; + + double maxVal(double Function(FoodBankRecord) f, double fallback) => + all.isEmpty ? fallback : all.map(f).reduce((a, b) => a > b ? a : b); + + final maxKcal = maxVal((e) => e.kcal, 2000); + final maxProtein = maxVal((e) => e.proteinG, 200); + final maxCarbs = maxVal((e) => e.carbsG, 200); + final maxFat = maxVal((e) => e.fatG, 100); + + var draft = FbFilter( + nameQuery: _filter.nameQuery, + minKcal: _filter.minKcal, + maxKcal: _filter.maxKcal, + minProtein: _filter.minProtein, + maxProtein: _filter.maxProtein, + minCarbs: _filter.minCarbs, + maxCarbs: _filter.maxCarbs, + minFat: _filter.minFat, + maxFat: _filter.maxFat, + ); + var draftSort = _sortField; + var draftAsc = _sortAscending; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheet) => _FbFilterSheet( + filter: draft, + sortField: draftSort, + ascending: draftAsc, + maxKcal: maxKcal, + maxProtein: maxProtein, + maxCarbs: maxCarbs, + maxFat: maxFat, + onFilterChanged: (f) => setSheet(() => draft = f), + onSortChanged: ({required field, required asc}) { + setSheet(() { + draftSort = field; + draftAsc = asc; + }); + }, + onApply: () { + setState(() { + _filter = draft; + _sortField = draftSort; + _sortAscending = draftAsc; + }); + _applyFilterSort(); + Navigator.of(ctx).pop(); + }, + onClear: () { + setSheet(() { + draft = FbFilter(); + draftSort = FbSortField.count; + draftAsc = false; + }); + }, + ), + ), + ); + } + + Future _openAddDialog() async { + final result = await showDialog( + context: context, + builder: (_) => const _AddEntryDialog(), + ); + if (result == null) return; + await FoodBankService.instance.addManualEntry(result); + await _load(); + } + + @override + Widget build(BuildContext context) { + final all = _allEntries; + return Scaffold( + appBar: AppBar( + title: const Text('Food Bank'), + actions: [ + if (all != null) + Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filter & sort', + onPressed: _openFilterSheet, + ), + if (_filter.isActive) + Positioned( + top: 8, + right: 8, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ], + ), + body: all == null + ? const Center(child: CircularProgressIndicator()) + : _displayed.isEmpty + ? Center( + child: Text( + all.isEmpty + ? 'Food bank is empty.\n' + 'Log meals to populate it, or add entries manually.' + : 'No entries match the current filter.', + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + itemCount: _displayed.length, + itemBuilder: (context, i) => _RecordTile(_displayed[i]), + ), + floatingActionButton: FloatingActionButton( + onPressed: _openAddDialog, + tooltip: 'Add manual entry', + child: const Icon(Icons.add), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Record tile +// --------------------------------------------------------------------------- + +class _RecordTile extends StatelessWidget { + const _RecordTile(this.record); + final FoodBankRecord record; + + @override + Widget build(BuildContext context) { + final macros = + 'P ${record.proteinG.toStringAsFixed(0)} g ' + 'C ${record.carbsG.toStringAsFixed(0)} g ' + 'F ${record.fatG.toStringAsFixed(0)} g'; + final per = record.grams > 0 + ? ' per ${record.grams.toStringAsFixed(0)} g' + : ''; + return ListTile( + title: Text(record.desc), + subtitle: Text( + '${record.kcal.toStringAsFixed(0)} kcal$per · $macros', + ), + trailing: record.count > 0 + ? Text( + '×${record.count.toStringAsFixed(0)}', + style: Theme.of(context).textTheme.bodySmall, + ) + : null, + ); + } +} + +// --------------------------------------------------------------------------- +// Filter sheet +// --------------------------------------------------------------------------- + +class _FbFilterSheet extends StatelessWidget { + const _FbFilterSheet({ + required this.filter, + required this.sortField, + required this.ascending, + required this.maxKcal, + required this.maxProtein, + required this.maxCarbs, + required this.maxFat, + required this.onFilterChanged, + required this.onSortChanged, + required this.onApply, + required this.onClear, + }); + + final FbFilter filter; + final FbSortField sortField; + final bool ascending; + final double maxKcal; + final double maxProtein; + final double maxCarbs; + final double maxFat; + final void Function(FbFilter) onFilterChanged; + final void Function({required FbSortField field, required bool asc}) + onSortChanged; + final VoidCallback onApply; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scroll) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 0), + child: Row( + children: [ + Expanded( + child: Text( + 'Filter & Sort', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton( + onPressed: onClear, + child: const Text('Clear all'), + ), + ], + ), + ), + const Divider(), + Expanded( + child: ListView( + controller: scroll, + padding: const EdgeInsets.all(16), + children: [ + TextField( + decoration: const InputDecoration( + labelText: 'Search by name', + prefixIcon: Icon(Icons.search), + isDense: true, + ), + controller: TextEditingController(text: filter.nameQuery) + ..selection = TextSelection.collapsed( + offset: filter.nameQuery.length, + ), + onChanged: (v) { + filter.nameQuery = v; + onFilterChanged(filter); + }, + ), + const SizedBox(height: 16), + if (maxKcal > 0) ...[ + Text( + 'Kcal range', + style: Theme.of(context).textTheme.labelLarge, + ), + RangeSlider( + max: maxKcal, + values: RangeValues( + filter.minKcal ?? 0, + filter.maxKcal ?? maxKcal, + ), + labels: RangeLabels( + (filter.minKcal ?? 0).toStringAsFixed(0), + (filter.maxKcal ?? maxKcal).toStringAsFixed(0), + ), + onChanged: (v) { + filter.minKcal = v.start > 0 ? v.start : null; + filter.maxKcal = v.end < maxKcal ? v.end : null; + onFilterChanged(filter); + }, + ), + const SizedBox(height: 8), + ], + if (maxProtein > 0) ...[ + Text( + 'Protein range (g)', + style: Theme.of(context).textTheme.labelLarge, + ), + RangeSlider( + max: maxProtein, + values: RangeValues( + filter.minProtein ?? 0, + filter.maxProtein ?? maxProtein, + ), + labels: RangeLabels( + (filter.minProtein ?? 0).toStringAsFixed(0), + (filter.maxProtein ?? maxProtein).toStringAsFixed(0), + ), + onChanged: (v) { + filter.minProtein = v.start > 0 ? v.start : null; + filter.maxProtein = v.end < maxProtein ? v.end : null; + onFilterChanged(filter); + }, + ), + const SizedBox(height: 8), + ], + if (maxCarbs > 0) ...[ + Text( + 'Carbs range (g)', + style: Theme.of(context).textTheme.labelLarge, + ), + RangeSlider( + max: maxCarbs, + values: RangeValues( + filter.minCarbs ?? 0, + filter.maxCarbs ?? maxCarbs, + ), + labels: RangeLabels( + (filter.minCarbs ?? 0).toStringAsFixed(0), + (filter.maxCarbs ?? maxCarbs).toStringAsFixed(0), + ), + onChanged: (v) { + filter.minCarbs = v.start > 0 ? v.start : null; + filter.maxCarbs = v.end < maxCarbs ? v.end : null; + onFilterChanged(filter); + }, + ), + const SizedBox(height: 8), + ], + if (maxFat > 0) ...[ + Text( + 'Fat range (g)', + style: Theme.of(context).textTheme.labelLarge, + ), + RangeSlider( + max: maxFat, + values: RangeValues( + filter.minFat ?? 0, + filter.maxFat ?? maxFat, + ), + labels: RangeLabels( + (filter.minFat ?? 0).toStringAsFixed(0), + (filter.maxFat ?? maxFat).toStringAsFixed(0), + ), + onChanged: (v) { + filter.minFat = v.start > 0 ? v.start : null; + filter.maxFat = v.end < maxFat ? v.end : null; + onFilterChanged(filter); + }, + ), + const SizedBox(height: 8), + ], + Text( + 'Sort by', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: DropdownButton( + isExpanded: true, + value: sortField, + items: const [ + DropdownMenuItem( + value: FbSortField.count, + child: Text('Usage count'), + ), + DropdownMenuItem( + value: FbSortField.name, + child: Text('Name'), + ), + DropdownMenuItem( + value: FbSortField.kcal, + child: Text('Kcal'), + ), + DropdownMenuItem( + value: FbSortField.protein, + child: Text('Protein'), + ), + DropdownMenuItem( + value: FbSortField.carbs, + child: Text('Carbs'), + ), + DropdownMenuItem( + value: FbSortField.fat, + child: Text('Fat'), + ), + ], + onChanged: (v) { + if (v != null) { + onSortChanged(field: v, asc: ascending); + } + }, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + ascending ? Icons.arrow_upward : Icons.arrow_downward, + ), + tooltip: ascending ? 'Ascending' : 'Descending', + onPressed: () => + onSortChanged(field: sortField, asc: !ascending), + ), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onApply, + child: const Text('Apply'), + ), + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Add entry dialog +// --------------------------------------------------------------------------- + +class _AddEntryDialog extends StatefulWidget { + const _AddEntryDialog(); + + @override + State<_AddEntryDialog> createState() => _AddEntryDialogState(); +} + +class _AddEntryDialogState extends State<_AddEntryDialog> { + final _name = TextEditingController(); + final _grams = TextEditingController(text: '100'); + final _kcal = TextEditingController(); + final _protein = TextEditingController(); + final _carbs = TextEditingController(); + final _fat = TextEditingController(); + + @override + void dispose() { + _name.dispose(); + _grams.dispose(); + _kcal.dispose(); + _protein.dispose(); + _carbs.dispose(); + _fat.dispose(); + super.dispose(); + } + + void _save() { + final name = _name.text.trim(); + if (name.isEmpty) return; + Navigator.of(context).pop( + FoodBankRecord( + desc: name, + kcal: double.tryParse(_kcal.text) ?? 0, + proteinG: double.tryParse(_protein.text) ?? 0, + carbsG: double.tryParse(_carbs.text) ?? 0, + fatG: double.tryParse(_fat.text) ?? 0, + grams: double.tryParse(_grams.text) ?? 100, + count: 0, + ), + ); + } + + Widget _field(String label, TextEditingController ctrl) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TextField( + controller: ctrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration(labelText: label, isDense: true), + ), + ); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add to food bank'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TextField( + controller: _name, + decoration: const InputDecoration( + labelText: 'Name', + isDense: true, + ), + ), + ), + _field('Reference grams', _grams), + _field('Kcal', _kcal), + _field('Protein (g)', _protein), + _field('Carbs (g)', _carbs), + _field('Fat (g)', _fat), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: _save, + child: const Text('Save to bank'), + ), + ], + ); + } +} diff --git a/app/lib/screens/history_screen.dart b/app/lib/screens/history_screen.dart index 32ae37f..d651cf3 100644 --- a/app/lib/screens/history_screen.dart +++ b/app/lib/screens/history_screen.dart @@ -1,19 +1,289 @@ -/// Read-only list of every logged meal, newest first. +/// Logged meal history with day grouping, filtering, and sorting. library; import 'dart:async'; import 'dart:io'; import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/edit_entry_screen.dart'; import 'package:diet_guard_app/screens/photo_viewer_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; -/// Shows every non-deleted logged entry across all days, so the user can -/// confirm what was actually logged (including whether a photo attached). +// --------------------------------------------------------------------------- +// Filter & sort state +// --------------------------------------------------------------------------- + +/// Sort field for the history list. +enum HistorySortField { + /// Sort by entry date/time. + date, + + /// Sort by calories. + kcal, + + /// Sort by protein (g). + protein, + + /// Sort by carbohydrates (g). + carbs, + + /// Sort by fat (g). + fat, + + /// Sort by description text. + description, +} + +/// All active filter criteria; [isActive] is true when any criterion is set. +class HistoryFilter { + /// Creates a [HistoryFilter] with the given criteria. + HistoryFilter({ + this.nameQuery = '', + this.dateRange, + this.minKcal, + this.maxKcal, + this.minProtein, + this.maxProtein, + this.minCarbs, + this.maxCarbs, + this.minFat, + this.maxFat, + this.hasPhoto, + this.source, + }); + + /// Substring match on the food description. + String nameQuery; + + /// Optional date range filter. + DateTimeRange? dateRange; + + /// Minimum kcal. + double? minKcal; + + /// Maximum kcal. + double? maxKcal; + + /// Minimum protein (g). + double? minProtein; + + /// Maximum protein (g). + double? maxProtein; + + /// Minimum carbs (g). + double? minCarbs; + + /// Maximum carbs (g). + double? maxCarbs; + + /// Minimum fat (g). + double? minFat; + + /// Maximum fat (g). + double? maxFat; + + /// null = all, true = with photo, false = without. + bool? hasPhoto; + + /// null = all, or a source string from the log. + String? source; + + /// True when any filter criterion is active. + bool get isActive => + nameQuery.isNotEmpty || + dateRange != null || + minKcal != null || + maxKcal != null || + minProtein != null || + maxProtein != null || + minCarbs != null || + maxCarbs != null || + minFat != null || + maxFat != null || + hasPhoto != null || + source != null; +} + +// --------------------------------------------------------------------------- +// List item sealed hierarchy for day-grouped rendering +// --------------------------------------------------------------------------- + +sealed class _HistoryItem {} + +final class _DayHeader extends _HistoryItem { + _DayHeader( + this.dateKey, + this.totalKcal, + this.entryCount, + this.totalProtein, + this.totalCarbs, + this.totalFat, + ); + final String dateKey; + final double totalKcal; + final int entryCount; + final double totalProtein; + final double totalCarbs; + final double totalFat; +} + +final class _EntryRow extends _HistoryItem { + _EntryRow(this.entry); + final FoodEntry entry; +} + +// --------------------------------------------------------------------------- +// Pure filter / sort / group helpers +// --------------------------------------------------------------------------- + +/// Applies [filter] and sort criteria to [entries] and returns the result. /// -/// Deliberately minimal: no editing, filtering, or pagination -- just -/// enough to answer "did this get logged, and with what photo?" +/// Exposed as a top-level function for unit tests. +List applyHistoryFilter( + List entries, + HistoryFilter filter, + HistorySortField sortField, { + required bool ascending, +}) { + var result = [...entries]; + + if (filter.nameQuery.isNotEmpty) { + final q = filter.nameQuery.toLowerCase(); + result = result.where((e) => e.desc.toLowerCase().contains(q)).toList(); + } + if (filter.dateRange != null) { + final start = filter.dateRange!.start; + final end = filter.dateRange!.end.add(const Duration(days: 1)); + result = result.where((e) { + final t = DateTime.tryParse(e.time); + return t != null && !t.isBefore(start) && t.isBefore(end); + }).toList(); + } + if (filter.minKcal != null) { + result = result.where((e) => e.kcal >= filter.minKcal!).toList(); + } + if (filter.maxKcal != null) { + result = result.where((e) => e.kcal <= filter.maxKcal!).toList(); + } + if (filter.minProtein != null) { + result = result.where((e) => e.proteinG >= filter.minProtein!).toList(); + } + if (filter.maxProtein != null) { + result = result.where((e) => e.proteinG <= filter.maxProtein!).toList(); + } + if (filter.minCarbs != null) { + result = result.where((e) => e.carbsG >= filter.minCarbs!).toList(); + } + if (filter.maxCarbs != null) { + result = result.where((e) => e.carbsG <= filter.maxCarbs!).toList(); + } + if (filter.minFat != null) { + result = result.where((e) => e.fatG >= filter.minFat!).toList(); + } + if (filter.maxFat != null) { + result = result.where((e) => e.fatG <= filter.maxFat!).toList(); + } + if (filter.hasPhoto != null) { + result = result + .where( + (e) => filter.hasPhoto! ? e.imagePath != null : e.imagePath == null, + ) + .toList(); + } + if (filter.source != null) { + result = result.where((e) => e.source == filter.source).toList(); + } + + result.sort((a, b) { + int cmp; + switch (sortField) { + case HistorySortField.date: + final at = DateTime.tryParse(a.time) ?? DateTime(0); + final bt = DateTime.tryParse(b.time) ?? DateTime(0); + cmp = at.compareTo(bt); + case HistorySortField.kcal: + cmp = a.kcal.compareTo(b.kcal); + case HistorySortField.protein: + cmp = a.proteinG.compareTo(b.proteinG); + case HistorySortField.carbs: + cmp = a.carbsG.compareTo(b.carbsG); + case HistorySortField.fat: + cmp = a.fatG.compareTo(b.fatG); + case HistorySortField.description: + cmp = a.desc.compareTo(b.desc); + } + return ascending ? cmp : -cmp; + }); + + return result; +} + +List<_HistoryItem> _buildGroupedItems(List entries) { + final byDay = >{}; + for (final e in entries) { + final day = e.time.length >= 10 ? e.time.substring(0, 10) : 'unknown'; + byDay.putIfAbsent(day, () => []).add(e); + } + final days = byDay.keys.toList()..sort((a, b) => b.compareTo(a)); + final items = <_HistoryItem>[]; + for (final day in days) { + final dayEntries = byDay[day]!; + final totalKcal = dayEntries.fold(0, (s, e) => s + e.kcal); + final totalProtein = dayEntries.fold(0, (s, e) => s + e.proteinG); + final totalCarbs = dayEntries.fold(0, (s, e) => s + e.carbsG); + final totalFat = dayEntries.fold(0, (s, e) => s + e.fatG); + items + ..add( + _DayHeader( + day, + totalKcal, + dayEntries.length, + totalProtein, + totalCarbs, + totalFat, + ), + ) + ..addAll(dayEntries.map(_EntryRow.new)); + } + return items; +} + +String _dateRangeLabel(DateTimeRange r) => + '${r.start.toString().substring(0, 10)}' + ' – ${r.end.toString().substring(0, 10)}'; + +String _formatDay(String dateKey) { + try { + final d = DateTime.parse(dateKey); + const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const mo = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${wd[d.weekday - 1]} ${d.day} ${mo[d.month - 1]} ${d.year}'; + } on Exception { + return dateKey; + } +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- + +/// Shows every non-deleted logged entry, grouped by day, with optional +/// filtering and sorting. class HistoryScreen extends StatefulWidget { /// Creates a [HistoryScreen]. const HistoryScreen({super.key}); @@ -23,7 +293,11 @@ class HistoryScreen extends StatefulWidget { } class _HistoryScreenState extends State { - List? _entries; + List? _allEntries; + List _displayed = const []; + HistoryFilter _filter = HistoryFilter(); + HistorySortField _sortField = HistorySortField.date; + bool _sortAscending = false; @override void initState() { @@ -34,34 +308,304 @@ class _HistoryScreenState extends State { Future _load() async { final entries = await LogStorageService.instance.allEntriesNewestFirst(); if (!mounted) return; - setState(() => _entries = entries); + setState(() { + _allEntries = entries; + _displayed = applyHistoryFilter( + entries, + _filter, + _sortField, + ascending: _sortAscending, + ); + }); + } + + Future _onEditEntry(FoodEntry entry) async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => EditEntryScreen(entry: entry)), + ); + await _load(); + } + + void _applyFilterSort() { + setState(() { + _displayed = applyHistoryFilter( + _allEntries!, + _filter, + _sortField, + ascending: _sortAscending, + ); + }); + } + + Future _openFilterSheet() async { + final all = _allEntries!; + final maxKcal = all.isEmpty + ? 2000.0 + : all.map((e) => e.kcal).reduce((a, b) => a > b ? a : b); + final maxProtein = all.isEmpty + ? 200.0 + : all.map((e) => e.proteinG).reduce((a, b) => a > b ? a : b); + final maxCarbs = all.isEmpty + ? 200.0 + : all.map((e) => e.carbsG).reduce((a, b) => a > b ? a : b); + final maxFat = all.isEmpty + ? 100.0 + : all.map((e) => e.fatG).reduce((a, b) => a > b ? a : b); + + var draft = HistoryFilter( + nameQuery: _filter.nameQuery, + dateRange: _filter.dateRange, + minKcal: _filter.minKcal, + maxKcal: _filter.maxKcal, + minProtein: _filter.minProtein, + maxProtein: _filter.maxProtein, + minCarbs: _filter.minCarbs, + maxCarbs: _filter.maxCarbs, + minFat: _filter.minFat, + maxFat: _filter.maxFat, + hasPhoto: _filter.hasPhoto, + source: _filter.source, + ); + var draftSortField = _sortField; + var draftSortAscending = _sortAscending; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheet) => _FilterSheet( + filter: draft, + sortField: draftSortField, + ascending: draftSortAscending, + maxKcal: maxKcal, + maxProtein: maxProtein, + maxCarbs: maxCarbs, + maxFat: maxFat, + onFilterChanged: (f) => setSheet(() => draft = f), + onSortChanged: ({required field, required asc}) { + setSheet(() { + draftSortField = field; + draftSortAscending = asc; + }); + }, + onApply: () { + setState(() { + _filter = draft; + _sortField = draftSortField; + _sortAscending = draftSortAscending; + }); + _applyFilterSort(); + Navigator.of(ctx).pop(); + }, + onClear: () { + setSheet(() { + draft = HistoryFilter(); + draftSortField = HistorySortField.date; + draftSortAscending = false; + }); + }, + ), + ), + ); } @override Widget build(BuildContext context) { - final entries = _entries; + final allEntries = _allEntries; return Scaffold( - appBar: AppBar(title: const Text('History')), - body: entries == null + appBar: AppBar( + title: const Text('History'), + actions: [ + if (allEntries != null) + Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filter & sort', + onPressed: _openFilterSheet, + ), + if (_filter.isActive) + Positioned( + top: 8, + right: 8, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ], + ), + body: allEntries == null ? const Center(child: CircularProgressIndicator()) - : entries.isEmpty - ? const Center(child: Text('Nothing logged yet.')) - : ListView.builder( - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; - return ListTile( - leading: _Thumbnail(imagePath: entry.imagePath), - title: Text(entry.desc), - subtitle: Text('${entry.time} • ${entry.source}'), - trailing: Text('${entry.kcal.toStringAsFixed(0)} kcal'), - ); - }, + : _displayed.isEmpty + ? Center( + child: Text( + allEntries.isEmpty + ? 'Nothing logged yet.' + : 'No entries match the current filter.', + ), + ) + : _GroupedList( + items: _buildGroupedItems(_displayed), + onDeleteEntry: _load, + onEditEntry: _onEditEntry, ), ); } } +// --------------------------------------------------------------------------- +// Grouped list widget +// --------------------------------------------------------------------------- + +class _GroupedList extends StatelessWidget { + const _GroupedList({ + required this.items, + required this.onDeleteEntry, + required this.onEditEntry, + }); + final List<_HistoryItem> items; + final Future Function() onDeleteEntry; + final Future Function(FoodEntry) onEditEntry; + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return switch (item) { + _DayHeader() => _DayHeaderTile(item), + _EntryRow() => _EntryTile( + item.entry, + onDelete: onDeleteEntry, + onEdit: () => onEditEntry(item.entry), + ), + }; + }, + ); + } +} + +class _DayHeaderTile extends StatelessWidget { + const _DayHeaderTile(this.header); + final _DayHeader header; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final goal = AppSettingsService.dailyKcalGoal; + final kcalColor = header.totalKcal > goal + ? colorScheme.error + : colorScheme.primary; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: colorScheme.surfaceContainerHighest, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _formatDay(header.dateKey), + style: textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + '${header.entryCount}' + ' ${header.entryCount == 1 ? 'entry' : 'entries'}', + style: textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + '${header.totalKcal.round()} / $goal kcal', + style: textTheme.bodySmall?.copyWith(color: kcalColor), + ), + const SizedBox(width: 8), + Text( + 'P ${header.totalProtein.round()}g · ' + 'C ${header.totalCarbs.round()}g · ' + 'F ${header.totalFat.round()}g', + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _EntryTile extends StatelessWidget { + const _EntryTile(this.entry, {this.onDelete, this.onEdit}); + final FoodEntry entry; + + /// Called after a confirmed delete so the parent can reload. + final Future Function()? onDelete; + + /// Called when the tile is tapped to open the edit screen. + final Future Function()? onEdit; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _Thumbnail(imagePath: entry.imagePath), + title: Text(entry.desc), + subtitle: Text('${entry.time} • ${entry.source}'), + trailing: Text('${entry.kcal.toStringAsFixed(0)} kcal'), + // Any entry can be edited (legacy null-id entries gain a UUID on save). + // Delete remains id-only to avoid ambiguous time+desc matches. + onTap: () => onEdit?.call(), + onLongPress: entry.id != null ? () => _confirmDelete(context) : null, + ); + } + + Future _confirmDelete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete entry?'), + content: Text('Remove "${entry.desc}" from history?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed == true) { + await LogStorageService.instance.deleteEntry(entry.id!); + await onDelete?.call(); + } + } +} + class _Thumbnail extends StatelessWidget { const _Thumbnail({required this.imagePath}); @@ -98,3 +642,447 @@ class _Thumbnail extends StatelessWidget { ); } } + +// --------------------------------------------------------------------------- +// Filter sheet +// --------------------------------------------------------------------------- + +class _FilterSheet extends StatelessWidget { + const _FilterSheet({ + required this.filter, + required this.sortField, + required this.ascending, + required this.maxKcal, + required this.maxProtein, + required this.maxCarbs, + required this.maxFat, + required this.onFilterChanged, + required this.onSortChanged, + required this.onApply, + required this.onClear, + }); + + final HistoryFilter filter; + final HistorySortField sortField; + final bool ascending; + final double maxKcal; + final double maxProtein; + final double maxCarbs; + final double maxFat; + final void Function(HistoryFilter) onFilterChanged; + final void Function({ + required HistorySortField field, + required bool asc, + }) + onSortChanged; + final VoidCallback onApply; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scroll) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 0), + child: Row( + children: [ + Expanded( + child: Text( + 'Filter & Sort', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton( + onPressed: onClear, + child: const Text('Clear all'), + ), + ], + ), + ), + const Divider(), + Expanded( + child: SingleChildScrollView( + controller: scroll, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Name search + TextField( + decoration: const InputDecoration( + labelText: 'Search by name', + prefixIcon: Icon(Icons.search), + isDense: true, + ), + controller: TextEditingController(text: filter.nameQuery) + ..selection = TextSelection.collapsed( + offset: filter.nameQuery.length, + ), + onChanged: (v) { + filter.nameQuery = v; + onFilterChanged(filter); + }, + ), + const SizedBox(height: 16), + + // Date range + Text( + 'Date range', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + OutlinedButton.icon( + icon: const Icon(Icons.date_range), + label: Text( + filter.dateRange == null + ? 'Any date' + : _dateRangeLabel(filter.dateRange!), + ), + onPressed: () async { + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 1)), + initialDateRange: filter.dateRange, + ); + if (picked != null) { + filter.dateRange = picked; + onFilterChanged(filter); + } + }, + ), + if (filter.dateRange != null) + TextButton( + onPressed: () { + filter.dateRange = null; + onFilterChanged(filter); + }, + child: const Text('Clear date range'), + ), + const SizedBox(height: 16), + + // Kcal range + if (maxKcal > 0) ...[ + Text( + 'Kcal range', + style: Theme.of(context).textTheme.labelLarge, + ), + _SliderEndpointLabels( + lo: '0', + hi: maxKcal.round().toString(), + ), + RangeSlider( + key: const Key('kcal-range-slider'), + max: maxKcal, + values: RangeValues( + filter.minKcal ?? 0, + filter.maxKcal ?? maxKcal, + ), + labels: RangeLabels( + (filter.minKcal ?? 0).toStringAsFixed(0), + (filter.maxKcal ?? maxKcal).toStringAsFixed(0), + ), + onChanged: (v) { + filter.minKcal = v.start > 0 ? v.start : null; + filter.maxKcal = v.end < maxKcal ? v.end : null; + onFilterChanged(filter); + }, + ), + _SliderSelectedLabel( + '${(filter.minKcal ?? 0).round()}' + ' – ${(filter.maxKcal ?? maxKcal).round()} kcal', + ), + const SizedBox(height: 8), + ], + + // Protein range + if (maxProtein > 0) ...[ + Text( + 'Protein range (g)', + style: Theme.of(context).textTheme.labelLarge, + ), + _SliderEndpointLabels( + lo: '0', + hi: '${maxProtein.round()}g', + ), + RangeSlider( + key: const Key('protein-range-slider'), + max: maxProtein, + values: RangeValues( + filter.minProtein ?? 0, + filter.maxProtein ?? maxProtein, + ), + labels: RangeLabels( + (filter.minProtein ?? 0).toStringAsFixed(0), + (filter.maxProtein ?? maxProtein).toStringAsFixed(0), + ), + onChanged: (v) { + filter.minProtein = v.start > 0 ? v.start : null; + filter.maxProtein = v.end < maxProtein ? v.end : null; + onFilterChanged(filter); + }, + ), + _SliderSelectedLabel( + '${(filter.minProtein ?? 0).round()}' + ' – ${(filter.maxProtein ?? maxProtein).round()}g', + ), + const SizedBox(height: 8), + ], + + // Carbs range + if (maxCarbs > 0) ...[ + Text( + 'Carbs range (g)', + style: Theme.of(context).textTheme.labelLarge, + ), + _SliderEndpointLabels( + lo: '0', + hi: '${maxCarbs.round()}g', + ), + RangeSlider( + key: const Key('carbs-range-slider'), + max: maxCarbs, + values: RangeValues( + filter.minCarbs ?? 0, + filter.maxCarbs ?? maxCarbs, + ), + labels: RangeLabels( + (filter.minCarbs ?? 0).toStringAsFixed(0), + (filter.maxCarbs ?? maxCarbs).toStringAsFixed(0), + ), + onChanged: (v) { + filter.minCarbs = v.start > 0 ? v.start : null; + filter.maxCarbs = v.end < maxCarbs ? v.end : null; + onFilterChanged(filter); + }, + ), + _SliderSelectedLabel( + '${(filter.minCarbs ?? 0).round()}' + ' – ${(filter.maxCarbs ?? maxCarbs).round()}g', + ), + const SizedBox(height: 8), + ], + + // Fat range + if (maxFat > 0) ...[ + Text( + 'Fat range (g)', + style: Theme.of(context).textTheme.labelLarge, + ), + _SliderEndpointLabels( + lo: '0', + hi: '${maxFat.round()}g', + ), + RangeSlider( + key: const Key('fat-range-slider'), + max: maxFat, + values: RangeValues( + filter.minFat ?? 0, + filter.maxFat ?? maxFat, + ), + labels: RangeLabels( + (filter.minFat ?? 0).toStringAsFixed(0), + (filter.maxFat ?? maxFat).toStringAsFixed(0), + ), + onChanged: (v) { + filter.minFat = v.start > 0 ? v.start : null; + filter.maxFat = v.end < maxFat ? v.end : null; + onFilterChanged(filter); + }, + ), + _SliderSelectedLabel( + '${(filter.minFat ?? 0).round()}' + ' – ${(filter.maxFat ?? maxFat).round()}g', + ), + const SizedBox(height: 8), + ], + + // Photo filter + Text( + 'Photo', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: const Text('Any'), + selected: filter.hasPhoto == null, + onSelected: (_) { + filter.hasPhoto = null; + onFilterChanged(filter); + }, + ), + FilterChip( + label: const Text('With photo'), + selected: filter.hasPhoto == true, + onSelected: (_) { + filter.hasPhoto = true; + onFilterChanged(filter); + }, + ), + FilterChip( + label: const Text('Without photo'), + selected: filter.hasPhoto == false, + onSelected: (_) { + filter.hasPhoto = false; + onFilterChanged(filter); + }, + ), + ], + ), + const SizedBox(height: 16), + + // Source filter + Text( + 'Source', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: const Text('All'), + selected: filter.source == null, + onSelected: (_) { + filter.source = null; + onFilterChanged(filter); + }, + ), + for (final src in ['manual', 'food bank', 'meal']) + FilterChip( + label: Text(src), + selected: filter.source == src, + onSelected: (_) { + filter.source = src; + onFilterChanged(filter); + }, + ), + ], + ), + const SizedBox(height: 16), + + // Sort + Text( + 'Sort by', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: DropdownButton( + isExpanded: true, + value: sortField, + items: const [ + DropdownMenuItem( + value: HistorySortField.date, + child: Text('Date'), + ), + DropdownMenuItem( + value: HistorySortField.kcal, + child: Text('Kcal'), + ), + DropdownMenuItem( + value: HistorySortField.protein, + child: Text('Protein'), + ), + DropdownMenuItem( + value: HistorySortField.carbs, + child: Text('Carbs'), + ), + DropdownMenuItem( + value: HistorySortField.fat, + child: Text('Fat'), + ), + DropdownMenuItem( + value: HistorySortField.description, + child: Text('Description'), + ), + ], + onChanged: (v) { + if (v != null) { + onSortChanged(field: v, asc: ascending); + } + }, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + ascending ? Icons.arrow_upward : Icons.arrow_downward, + ), + tooltip: ascending ? 'Ascending' : 'Descending', + onPressed: () => + onSortChanged(field: sortField, asc: !ascending), + ), + ], + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onApply, + child: const Text('Apply'), + ), + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Slider label helpers +// --------------------------------------------------------------------------- + +/// Thin row showing the min (0) and max endpoint values for a range slider. +class _SliderEndpointLabels extends StatelessWidget { + const _SliderEndpointLabels({required this.lo, required this.hi}); + final String lo; + final String hi; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + return Row( + children: [ + Text(lo, style: style), + const Spacer(), + Text(hi, style: style), + ], + ); + } +} + +/// Centred text showing the currently-selected range value (always visible). +class _SliderSelectedLabel extends StatelessWidget { + const _SliderSelectedLabel(this.label); + final String label; + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 008e32d..d8aba89 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -9,6 +9,7 @@ library; import 'dart:async'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/github_client.dart'; import 'package:diet_guard_app/services/github_device_auth.dart'; import 'package:diet_guard_app/services/sync_service.dart'; @@ -41,6 +42,7 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { + final _kcalGoalController = TextEditingController(); final _ownerController = TextEditingController(); final _repoController = TextEditingController(); final _tokenController = TextEditingController(); @@ -66,6 +68,7 @@ class _SettingsScreenState extends State { settings = const SyncSettings(owner: '', repo: '', token: ''); } if (!mounted) return; + _kcalGoalController.text = AppSettingsService.dailyKcalGoal.toString(); _ownerController.text = settings.owner; _repoController.text = settings.repo; _tokenController.text = settings.token; @@ -75,6 +78,7 @@ class _SettingsScreenState extends State { @override void dispose() { + _kcalGoalController.dispose(); _ownerController.dispose(); _repoController.dispose(); _tokenController.dispose(); @@ -227,10 +231,31 @@ class _SettingsScreenState extends State { return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( - appBar: AppBar(title: const Text('Sync settings')), + appBar: AppBar(title: const Text('Settings')), body: ListView( padding: const EdgeInsets.all(16), children: [ + Text('Nutrition', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + TextField( + controller: _kcalGoalController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Daily kcal goal', + helperText: 'Shown in the history day summary', + suffixText: 'kcal', + ), + onChanged: (v) { + final n = int.tryParse(v); + if (n != null && n > 0) { + unawaited(AppSettingsService.instance.saveDailyKcalGoal(n)); + } + }, + ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 8), Text( 'Authorize in your browser — no token to paste. Syncs to ' 'kuhyx/diet-guard-sync by default.', diff --git a/app/lib/services/app_settings_service.dart b/app/lib/services/app_settings_service.dart new file mode 100644 index 0000000..ade1c27 --- /dev/null +++ b/app/lib/services/app_settings_service.dart @@ -0,0 +1,87 @@ +/// User-adjustable app settings persisted locally as JSON. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Singleton storing lightweight settings such as the daily kcal goal. +/// +/// The static [dailyKcalGoal] getter returns the default (2200) when the +/// singleton has not been initialised — safe to read in widget tests that +/// never call [init]. +class AppSettingsService { + AppSettingsService._(this._file); + + static AppSettingsService? _instance; + + /// Returns the initialized singleton; throws if [init] was not called. + static AppSettingsService get instance => _instance!; + + final File _file; + int _dailyKcalGoal = 2200; + + /// Returns the configured daily kcal goal, or 2200 when uninitialised. + static int get dailyKcalGoal => _instance?._dailyKcalGoal ?? 2200; + + /// Initialises the singleton, pointing at the app's documents directory. + static Future init() async { + if (_instance != null) return _instance!; + final dir = await getApplicationDocumentsDirectory(); + final svc = AppSettingsService._( + File(p.join(dir.path, 'app_settings.json')), + ); + await svc._load(); + _instance = svc; + return svc; + } + + /// Resets the singleton so [init] can be called again in tests. + /// + /// When [testDir] is given, reads/writes go there instead of the real + /// documents directory. + @visibleForTesting + static void resetForTesting({Directory? testDir}) { + _instance = testDir == null + ? null + : AppSettingsService._( + File(p.join(testDir.path, 'app_settings.json')), + ); + } + + /// Initialises from [testDir], calling [_load], for use in unit tests. + /// + /// Bypasses [getApplicationDocumentsDirectory] so tests don't need platform + /// channels. + @visibleForTesting + static Future initForTesting(Directory testDir) async { + final svc = AppSettingsService._( + File(p.join(testDir.path, 'app_settings.json')), + ); + await svc._load(); + _instance = svc; + return svc; + } + + Future _load() async { + if (!_file.existsSync()) return; + try { + final data = jsonDecode(await _file.readAsString()); + if (data is Map && data['daily_kcal_goal'] is int) { + _dailyKcalGoal = data['daily_kcal_goal'] as int; + } + } on Exception { + // Ignore parse errors and keep default. + } + } + + /// Updates the in-memory value and persists [goal] to disk. + Future saveDailyKcalGoal(int goal) async { + _dailyKcalGoal = goal; + await _file.parent.create(recursive: true); + await _file.writeAsString(jsonEncode({'daily_kcal_goal': goal})); + } +} diff --git a/app/lib/services/foodbank_service.dart b/app/lib/services/foodbank_service.dart index 93ac914..4f2c5b6 100644 --- a/app/lib/services/foodbank_service.dart +++ b/app/lib/services/foodbank_service.dart @@ -111,14 +111,15 @@ class FoodBankService { /// `_foodbank.remember_food`/`remember_meal`'s upsert semantics: latest /// macros win per normalized name, `count` increments per occurrence. static Map rebuild(DayLog log) { - final entries = log.values - .expand((entries) => entries) - .where((entry) => !entry.deleted) - .toList() - ..sort((a, b) { - final byTime = a.time.compareTo(b.time); - return byTime != 0 ? byTime : (a.id ?? '').compareTo(b.id ?? ''); - }); + final entries = + log.values + .expand((entries) => entries) + .where((entry) => !entry.deleted) + .toList() + ..sort((a, b) { + final byTime = a.time.compareTo(b.time); + return byTime != 0 ? byTime : (a.id ?? '').compareTo(b.id ?? ''); + }); final bank = {}; for (final entry in entries) { final components = entry.components; @@ -199,15 +200,86 @@ class FoodBankService { return bank; } + // --------------------------------------------------------------------------- + // Manual bank (food items added directly without logging them as eaten) + // --------------------------------------------------------------------------- + + File get _manualFile => + File(p.join(_file.parent.path, 'food_bank_manual.json')); + + Future> _readManualBank() async { + final file = _manualFile; + if (!file.existsSync()) return {}; + String raw; + try { + raw = await file.readAsString(); + } on FileSystemException { + return {}; + } + Object? data; + try { + data = jsonDecode(raw); + } on FormatException { + return {}; + } + if (data is! Map) return {}; + final result = {}; + for (final entry in data.entries) { + final key = entry.key; + final value = entry.value; + if (key is String && value is Map) { + result[key] = FoodBankRecord.fromJson(value.cast()); + } + } + return result; + } + + Future _writeManualBank(Map bank) async { + final file = _manualFile; + await file.parent.create(recursive: true); + final encoded = { + for (final entry in bank.entries) entry.key: entry.value.toJson(), + }; + await file.writeAsString(jsonEncode(encoded)); + } + + /// Adds or updates [record] in the manually-curated bank without logging it + /// as eaten. A repeated call with the same normalized name overwrites the + /// previous entry. + Future addManualEntry(FoodBankRecord record) async { + final bank = await _readManualBank(); + bank[_normalize(record.desc)] = record; + await _writeManualBank(bank); + } + + /// All known food records: log-derived entries merged with manually-added + /// ones, sorted by count descending. + /// + /// Log-derived records (from [readBank]) take precedence over manual records + /// with the same normalized name. + Future> mergedEntries() async { + final logBank = await readBank(); + final manualBank = await _readManualBank(); + final merged = {...manualBank, ...logBank}; + return merged.values.toList()..sort((a, b) => b.count.compareTo(a.count)); + } + + // --------------------------------------------------------------------------- + // Search + // --------------------------------------------------------------------------- + /// Returns banked foods matching [query], best match first. /// /// An empty query returns the most-logged foods. Mirrors - /// `_foodbank.search_foods`. + /// `_foodbank.search_foods`. Searches both log-derived and manually-added + /// entries; log-derived entries win on name collision. Future> search( String query, { int limit = defaultSuggestions, }) async { - final bank = await readBank(); + final logBank = await readBank(); + final manualBank = await _readManualBank(); + final bank = {...manualBank, ...logBank}; final normalized = _normalize(query); if (normalized.isEmpty) return _rankedAll(bank, limit); diff --git a/app/lib/services/github_client.dart b/app/lib/services/github_client.dart index 769273c..a45579b 100644 --- a/app/lib/services/github_client.dart +++ b/app/lib/services/github_client.dart @@ -58,7 +58,7 @@ class GitHubClient { // formal; assign it explicitly. // ignore: prefer_initializing_formals : _token = token, - _http = httpClient ?? http.Client(); + _http = httpClient ?? http.Client(); /// The repo owner/org (e.g. `"kuhyx"`). final String owner; diff --git a/app/lib/services/log_storage_service.dart b/app/lib/services/log_storage_service.dart index fe60c8b..d93b2ad 100644 --- a/app/lib/services/log_storage_service.dart +++ b/app/lib/services/log_storage_service.dart @@ -165,15 +165,16 @@ class LogStorageService { /// "today". Future> allEntriesNewestFirst() async { final log = await readLog(); - final entries = [ - for (final dayEntries in log.values) - ...dayEntries.where((e) => !e.deleted), - ]..sort((a, b) { - final aTime = DateTime.tryParse(a.time); - final bTime = DateTime.tryParse(b.time); - if (aTime == null || bTime == null) return 0; - return bTime.compareTo(aTime); - }); + final entries = + [ + for (final dayEntries in log.values) + ...dayEntries.where((e) => !e.deleted), + ]..sort((a, b) { + final aTime = DateTime.tryParse(a.time); + final bTime = DateTime.tryParse(b.time); + if (aTime == null || bTime == null) return 0; + return bTime.compareTo(aTime); + }); return entries; } @@ -187,13 +188,49 @@ class LogStorageService { return double.parse(total.toStringAsFixed(1)); } + /// Tombstones the entry with [id] wherever it appears in the log. + /// + /// Silently does nothing when the id is not found or the entry is already + /// deleted — covers both legacy null-id entries and double-delete races. + Future deleteEntry(String id) async { + final log = await readLog(); + for (final entries in log.values) { + for (var i = 0; i < entries.length; i++) { + if (entries[i].id == id && !entries[i].deleted) { + entries[i] = entries[i].copyWithDeleted(); + await writeLog(log); + return; + } + } + } + } + + /// Replaces the stored entry matching [original] with [updated]. + /// + /// Matches by [FoodEntry.id] when present; falls back to + /// `time + desc` for legacy entries that predate UUID support. + /// Silently does nothing when no match is found. + Future updateEntry(FoodEntry original, FoodEntry updated) async { + final log = await readLog(); + for (final entries in log.values) { + for (var i = 0; i < entries.length; i++) { + final e = entries[i]; + final matches = original.id != null + ? e.id == original.id + : e.time == original.time && e.desc == original.desc; + if (matches) { + entries[i] = updated; + await writeLog(log); + return; + } + } + } + } + /// Returns the slot hours already satisfied today, mirrors /// `_state.logged_slots_today`. Future> loggedSlotsToday() async { final entries = await todayEntries(); - return entries - .where((e) => e.slot != null) - .map((e) => e.slot!) - .toSet(); + return entries.where((e) => e.slot != null).map((e) => e.slot!).toSet(); } } diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 749d406..3dc194d 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -26,7 +26,8 @@ const _devicesDir = 'devices'; /// phone is the only other device in this design. const phoneDeviceId = 'phone'; -String _deviceLogPath(String deviceId) => '$_devicesDir/$deviceId/food_log.json'; +String _deviceLogPath(String deviceId) => + '$_devicesDir/$deviceId/food_log.json'; /// Runs one full sync tick: pull, merge, preserve photos, persist, push. /// diff --git a/app/test/screens/edit_entry_screen_test.dart b/app/test/screens/edit_entry_screen_test.dart new file mode 100644 index 0000000..004b894 --- /dev/null +++ b/app/test/screens/edit_entry_screen_test.dart @@ -0,0 +1,268 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/edit_entry_screen.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _entry = FoodEntry( + id: 'test-uuid', + time: '2026-06-22T12:00:00+02:00', + desc: 'pizza obok pracy', + grams: 400, + kcal: 1200, + proteinG: 52, + carbsG: 145, + fatG: 42, + source: 'manual', +); + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_edit_test_'); + LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + testWidgets('pre-fills all fields from the entry', (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + await tester.pumpWidget( + MaterialApp(home: EditEntryScreen(entry: _entry)), + ); + await settle(tester); + + expect(find.text('pizza obok pracy'), findsOneWidget); + expect(find.text('1200'), findsOneWidget); + expect(find.text('52'), findsOneWidget); + expect(find.text('145'), findsOneWidget); + expect(find.text('42'), findsOneWidget); + expect(find.text('400'), findsOneWidget); + }); + }); + + testWidgets('Save button is present and AppBar title is correct', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + await tester.pumpWidget( + MaterialApp(home: EditEntryScreen(entry: _entry)), + ); + await settle(tester); + expect(find.text('Edit meal'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + }); + + testWidgets('shows error when description is cleared', (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + await tester.pumpWidget( + MaterialApp(home: EditEntryScreen(entry: _entry)), + ); + await settle(tester); + + // Clear the description field. + final descField = find.ancestor( + of: find.text('pizza obok pracy'), + matching: find.byType(TextField), + ); + await tester.enterText(descField, ''); + await tester.tap(find.text('Save')); + await settle(tester); + + expect(find.text('Description cannot be empty.'), findsOneWidget); + }); + }); + + testWidgets('Save persists updated kcal to the log', (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + await tester.pumpWidget( + MaterialApp(home: EditEntryScreen(entry: _entry)), + ); + await settle(tester); + + // Change kcal. + await tester.enterText( + find.ancestor( + of: find.text('1200'), + matching: find.byType(TextField), + ), + '999', + ); + await tester.tap(find.text('Save')); + await settle(tester); + + final log = await LogStorageService.instance.readLog(); + final saved = log['2026-06-22']!.first; + expect(saved.kcal, 999); + expect(saved.desc, 'pizza obok pracy'); + expect(saved.id, 'test-uuid'); + }); + }); + + testWidgets('legacy null-id entry gains a UUID on save', (tester) async { + await tester.runAsync(() async { + const legacy = FoodEntry( + time: '2026-06-22T08:00:00+02:00', + desc: 'kabanosy', + grams: 380, + kcal: 1174, + proteinG: 53, + carbsG: 19, + fatG: 152, + source: 'food bank', + ); + await LogStorageService.instance.writeLog({ + '2026-06-22': [legacy], + }); + + await tester.pumpWidget( + MaterialApp(home: EditEntryScreen(entry: legacy)), + ); + await settle(tester); + await tester.tap(find.text('Save')); + await settle(tester); + + final log = await LogStorageService.instance.readLog(); + final saved = log['2026-06-22']!.first; + expect(saved.id, isNotNull); + expect(saved.id, isNotEmpty); + expect(saved.kcal, 1174); + }); + }); + + testWidgets('selecting a food bank suggestion fills all macro fields', ( + tester, + ) async { + await tester.runAsync(() async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'pizza obok pracy', + kcal: 1200, + proteinG: 52, + carbsG: 145, + fatG: 42, + grams: 400, + count: 1, + ), + ); + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + + await tester.pumpWidget( + MaterialApp(home: EditEntryScreen(entry: _entry)), + ); + await settle(tester); + + // The desc field already matches the bank entry — a suggestion appears. + expect(find.text('pizza obok pracy'), findsWidgets); + // Tap the suggestion tile (the one in the autocomplete list, not the + // TextField itself). + final suggestionTiles = find.text('pizza obok pracy'); + // At least 2 matches: TextField text + suggestion tile. + expect(suggestionTiles, findsWidgets); + await tester.tap(suggestionTiles.last); + await settle(tester); + + // After selection, suggestions are cleared and macros are filled. + expect(find.text('1200'), findsOneWidget); + }); + }); + + testWidgets( + 'editing a macro after selecting a suggestion resets source to manual', + (tester) async { + await tester.runAsync(() async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'pizza obok pracy', + kcal: 1200, + proteinG: 52, + carbsG: 145, + fatG: 42, + grams: 400, + count: 1, + ), + ); + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + + await tester.pumpWidget( + MaterialApp(home: EditEntryScreen(entry: _entry)), + ); + await settle(tester); + + // Select the suggestion to set _source = 'food bank'. + final suggestionTiles = find.text('pizza obok pracy'); + await tester.tap(suggestionTiles.last); + await settle(tester); + + // Manually edit kcal — should flip _source back to 'manual'. + await tester.enterText( + find.ancestor( + of: find.text('1200'), + matching: find.byType(TextField), + ), + '999', + ); + await settle(tester); + await tester.tap(find.text('Save')); + await settle(tester); + + final log = await LogStorageService.instance.readLog(); + // Source reverts to 'manual' after macro edit. + expect(log['2026-06-22']!.first.source, 'manual'); + }); + }, + ); + + testWidgets('tapping an entry tile navigates to EditEntryScreen', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.text('pizza obok pracy')); + await settle(tester); + + expect(find.text('Edit meal'), findsOneWidget); + expect(find.text('pizza obok pracy'), findsOneWidget); + // Macros are pre-filled. + expect(find.text('1200'), findsOneWidget); + }); + }); +} diff --git a/app/test/screens/food_bank_screen_test.dart b/app/test/screens/food_bank_screen_test.dart new file mode 100644 index 0000000..9ac840d --- /dev/null +++ b/app/test/screens/food_bank_screen_test.dart @@ -0,0 +1,613 @@ +import 'dart:io'; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/food_bank_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_screen_'); + FoodBankService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + FoodBankService.resetForTesting(); + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + // --------------------------------------------------------------------------- + // applyFbFilter — pure function tests + // --------------------------------------------------------------------------- + + group('applyFbFilter', () { + final records = [ + const FoodBankRecord( + desc: 'Apple', + kcal: 80, + proteinG: 0.5, + carbsG: 20, + fatG: 0.3, + grams: 100, + count: 5, + ), + const FoodBankRecord( + desc: 'Banana', + kcal: 90, + proteinG: 1, + carbsG: 22, + fatG: 0.4, + grams: 100, + count: 10, + ), + const FoodBankRecord( + desc: 'Chicken breast', + kcal: 165, + proteinG: 31, + carbsG: 0, + fatG: 3.6, + grams: 100, + count: 2, + ), + ]; + + test('no filter returns entries sorted by count descending', () { + final result = applyFbFilter( + records, + FbFilter(), + FbSortField.count, + ascending: false, + ); + expect(result.map((r) => r.desc), ['Banana', 'Apple', 'Chicken breast']); + }); + + test('nameQuery filters by case-insensitive substring', () { + final result = applyFbFilter( + records, + FbFilter(nameQuery: 'an'), + FbSortField.name, + ascending: true, + ); + expect(result.map((r) => r.desc), [ + 'Banana', + ]); // only 'Banana' contains 'an' + }); + + test('minKcal and maxKcal filter by kcal', () { + final result = applyFbFilter( + records, + FbFilter(minKcal: 85, maxKcal: 100), + FbSortField.kcal, + ascending: true, + ); + expect(result.map((r) => r.desc), ['Banana']); + }); + + test('minProtein filters by protein', () { + final result = applyFbFilter( + records, + FbFilter(minProtein: 10), + FbSortField.count, + ascending: false, + ); + expect(result.map((r) => r.desc), ['Chicken breast']); + }); + + test('maxCarbs filters by carbs', () { + final result = applyFbFilter( + records, + FbFilter(maxCarbs: 5), + FbSortField.count, + ascending: false, + ); + expect(result.map((r) => r.desc), ['Chicken breast']); + }); + + test('minFat and maxFat filter by fat', () { + final result = applyFbFilter( + records, + FbFilter(minFat: 0.35, maxFat: 1), + FbSortField.count, + ascending: false, + ); + expect(result.map((r) => r.desc), ['Banana']); + }); + + test('maxProtein filters by protein', () { + final result = applyFbFilter( + records, + FbFilter(maxProtein: 5), + FbSortField.count, + ascending: false, + ); + // Banana (1 g) and Apple (0.5 g) have protein ≤ 5 g; sorted count desc. + expect(result.map((r) => r.desc), ['Banana', 'Apple']); + }); + + test('minCarbs filters by carbs', () { + final result = applyFbFilter( + records, + FbFilter(minCarbs: 10), + FbSortField.count, + ascending: false, + ); + // Banana (22 g) and Apple (20 g) have carbs ≥ 10 g; sorted count desc. + expect(result.map((r) => r.desc), ['Banana', 'Apple']); + }); + + test('sort ascending by name', () { + final result = applyFbFilter( + records, + FbFilter(), + FbSortField.name, + ascending: true, + ); + expect(result.map((r) => r.desc), ['Apple', 'Banana', 'Chicken breast']); + }); + + test('sort descending by kcal', () { + final result = applyFbFilter( + records, + FbFilter(), + FbSortField.kcal, + ascending: false, + ); + expect(result.first.desc, 'Chicken breast'); + }); + + test('sort ascending by protein', () { + final result = applyFbFilter( + records, + FbFilter(), + FbSortField.protein, + ascending: true, + ); + expect(result.first.desc, 'Apple'); + }); + + test('sort by carbs ascending', () { + final result = applyFbFilter( + records, + FbFilter(), + FbSortField.carbs, + ascending: true, + ); + expect(result.first.desc, 'Chicken breast'); // 0g + }); + + test('sort by fat descending', () { + final result = applyFbFilter( + records, + FbFilter(), + FbSortField.fat, + ascending: false, + ); + expect(result.first.desc, 'Chicken breast'); // 3.6g + }); + + test('FbFilter.isActive is false when nothing is set', () { + expect(FbFilter().isActive, isFalse); + }); + + test('FbFilter.isActive is true when nameQuery is set', () { + expect(FbFilter(nameQuery: 'x').isActive, isTrue); + }); + + test('FbFilter.isActive is true when minKcal is set', () { + expect(FbFilter(minKcal: 50).isActive, isTrue); + }); + + test('FbFilter.isActive is true when maxKcal is set', () { + expect(FbFilter(maxKcal: 500).isActive, isTrue); + }); + + test('FbFilter.isActive is true when minProtein is set', () { + expect(FbFilter(minProtein: 5).isActive, isTrue); + }); + + test('FbFilter.isActive is true when maxProtein is set', () { + expect(FbFilter(maxProtein: 50).isActive, isTrue); + }); + + test('FbFilter.isActive is true when minCarbs is set', () { + expect(FbFilter(minCarbs: 5).isActive, isTrue); + }); + + test('FbFilter.isActive is true when maxCarbs is set', () { + expect(FbFilter(maxCarbs: 50).isActive, isTrue); + }); + + test('FbFilter.isActive is true when minFat is set', () { + expect(FbFilter(minFat: 1).isActive, isTrue); + }); + + test('FbFilter.isActive is true when maxFat is set', () { + expect(FbFilter(maxFat: 10).isActive, isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // Widget tests + // --------------------------------------------------------------------------- + + testWidgets('shows empty-bank message when no entries exist', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + expect(find.textContaining('Food bank is empty'), findsOneWidget); + }); + }); + + testWidgets('lists entries from the merged bank', (tester) async { + await tester.runAsync(() async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Manual oat', + kcal: 370, + proteinG: 13, + carbsG: 66, + fatG: 7, + grams: 100, + count: 0, + ), + ); + + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + expect(find.text('Manual oat'), findsOneWidget); + }); + }); + + testWidgets('FAB opens add-entry dialog and saving adds to bank', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + await tester.tap(find.byType(FloatingActionButton)); + await settle(tester); + + expect(find.text('Add to food bank'), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextField, 'Name'), + 'Test food', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Kcal'), + '200', + ); + + await tester.tap(find.text('Save to bank')); + await settle(tester); + + // After saving, the screen reloads and shows the new entry. + expect(find.text('Test food'), findsOneWidget); + }); + }); + + testWidgets('dialog cancel does not save anything', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + await tester.tap(find.byType(FloatingActionButton)); + await settle(tester); + + await tester.tap(find.text('Cancel')); + await settle(tester); + + expect(find.textContaining('Food bank is empty'), findsOneWidget); + }); + }); + + testWidgets('dialog save with empty name does nothing', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + await tester.tap(find.byType(FloatingActionButton)); + await settle(tester); + + // Tap save without entering a name. + await tester.tap(find.text('Save to bank')); + await settle(tester); + + // Dialog stays open; no entry saved. + expect(find.text('Add to food bank'), findsOneWidget); + }); + }); + + testWidgets('filter icon appears when entries exist', (tester) async { + await tester.runAsync(() async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Oat', + kcal: 370, + proteinG: 13, + carbsG: 66, + fatG: 7, + grams: 100, + count: 0, + ), + ); + + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + expect( + find.widgetWithIcon(IconButton, Icons.filter_list), + findsOneWidget, + ); + }); + }); + + testWidgets('filter sheet opens and Apply filters results', (tester) async { + await tester.runAsync(() async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Oat', + kcal: 370, + proteinG: 13, + carbsG: 66, + fatG: 7, + grams: 100, + count: 0, + ), + ); + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Egg', + kcal: 155, + proteinG: 13, + carbsG: 1, + fatG: 11, + grams: 100, + count: 0, + ), + ); + + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + expect(find.text('Filter & Sort'), findsOneWidget); + + // Type in the only TextField in the sheet (the name search field). + await tester.enterText(find.byType(TextField).first, 'Oat'); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + // Sheet is closed; only the matching entry is visible. + expect(find.text('Filter & Sort'), findsNothing); + expect(find.text('Oat'), findsOneWidget); + expect(find.text('Egg'), findsNothing); + }); + }); + + testWidgets('filter sheet Clear all resets draft then Apply shows all', ( + tester, + ) async { + await tester.runAsync(() async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Walnut', + kcal: 654, + proteinG: 15, + carbsG: 14, + fatG: 65, + grams: 100, + count: 0, + ), + ); + + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + await tester.tap(find.text('Clear all')); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + expect(find.text('Walnut'), findsOneWidget); + }); + }); + + testWidgets('record tile shows usage count for log-derived entries', ( + tester, + ) async { + await tester.runAsync(() async { + await FoodBankService.instance.rebuildAndPersist({ + '2026-06-22': [ + const FoodEntry( + id: '1', + time: '2026-06-22T08:00:00+02:00', + desc: 'rice', + grams: 100, + kcal: 130, + proteinG: 3, + carbsG: 28, + fatG: 0.3, + source: 'manual', + ), + const FoodEntry( + id: '2', + time: '2026-06-22T12:00:00+02:00', + desc: 'rice', + grams: 100, + kcal: 130, + proteinG: 3, + carbsG: 28, + fatG: 0.3, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + // The rice entry was logged twice — the tile trailing shows ×2. + expect(find.textContaining('×2'), findsOneWidget); + }); + }); + + testWidgets('filter sheet sort dropdown changes sort field', (tester) async { + await tester.runAsync(() async { + // Zero macros: no RangeSliders appear, sort section is immediately visible. + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'ZeroItem', + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + grams: 100, + count: 0, + ), + ); + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + expect(find.text('Filter & Sort'), findsOneWidget); + + // With no sliders rendered, 'Sort by' and its dropdown are immediately + // visible — open the sort-field dropdown (shows 'Usage count' by default). + await tester.tap(find.text('Usage count')); + await settle(tester); + + // Tap 'Name' in the dropdown overlay. + await tester.tap(find.text('Name').last); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + expect(find.text('Filter & Sort'), findsNothing); + expect(find.text('ZeroItem'), findsOneWidget); + }); + }); + + testWidgets('filter sheet RangeSlider onChanged callbacks fire', ( + tester, + ) async { + await tester.runAsync(() async { + // Non-zero macros: all four RangeSliders appear in the filter sheet. + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'SliderFood', + kcal: 200, + proteinG: 10, + carbsG: 25, + fatG: 8, + grams: 100, + count: 0, + ), + ); + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + // Use getRect() + dragFrom() to bypass the _maybeViewOf ancestor-search + // failure that tester.drag(finder, …) triggers inside modal overlays. + + // Kcal slider covers lines 468-471. + await tester.dragFrom( + tester.getRect(find.byType(RangeSlider).at(0)).center, + const Offset(-30, 0), + ); + await settle(tester); + + // Protein slider covers lines 491-495. + await tester.dragFrom( + tester.getRect(find.byType(RangeSlider).at(1)).center, + const Offset(-30, 0), + ); + await settle(tester); + + // Carbs slider covers lines 515-518. + await tester.dragFrom( + tester.getRect(find.byType(RangeSlider).at(2)).center, + const Offset(-30, 0), + ); + await settle(tester); + + // Fat slider covers lines 538-541. + await tester.dragFrom( + tester.getRect(find.byType(RangeSlider).at(3)).center, + const Offset(-30, 0), + ); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + expect(find.text('Filter & Sort'), findsNothing); + }); + }); + + testWidgets('filter sheet sort direction toggle fires onSortChanged', ( + tester, + ) async { + await tester.runAsync(() async { + // Zero macros: no RangeSliders appear, sort section is immediately visible. + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'ZeroItem2', + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + grams: 100, + count: 0, + ), + ); + await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + expect(find.text('Filter & Sort'), findsOneWidget); + + // Default sort is count-descending; the direction icon is arrow_downward. + await tester.tap(find.byIcon(Icons.arrow_downward)); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + expect(find.text('ZeroItem2'), findsOneWidget); + }); + }); +} diff --git a/app/test/screens/meal_builder_screen_test.dart b/app/test/screens/meal_builder_screen_test.dart index bea4d46..7ac6656 100644 --- a/app/test/screens/meal_builder_screen_test.dart +++ b/app/test/screens/meal_builder_screen_test.dart @@ -75,11 +75,11 @@ void main() { ); await settle(tester); - // Field order: [0] meal name, [1] item name, [2] kcal, - // [3] per (g), [4] protein, [5] carbs, [6] fat, [7] ate (g). + // Field order: [0] meal name, [1] item name, [2] per (g), + // [3] kcal, [4] protein, [5] carbs, [6] fat, [7] ate (g). await tester.enterText(find.byType(TextField).at(1), 'rice'); - await tester.enterText(find.byType(TextField).at(2), '200'); - await tester.enterText(find.byType(TextField).at(3), '100'); + await tester.enterText(find.byType(TextField).at(2), '100'); + await tester.enterText(find.byType(TextField).at(3), '200'); await tester.enterText(find.byType(TextField).at(4), '4'); await tester.enterText(find.byType(TextField).at(5), '44'); await tester.enterText(find.byType(TextField).at(6), '1'); @@ -92,7 +92,7 @@ void main() { expect(find.textContaining('300 kcal'), findsOneWidget); await tester.enterText(find.byType(TextField).at(1), 'chicken'); - await tester.enterText(find.byType(TextField).at(2), '165'); + await tester.enterText(find.byType(TextField).at(3), '165'); await tester.enterText(find.byType(TextField).at(4), '31'); await tester.enterText(find.byType(TextField).at(5), '0'); await tester.enterText(find.byType(TextField).at(6), '4'); @@ -103,8 +103,7 @@ void main() { await tester.tap(logMealButton); await settle(tester); - final entry = - (await LogStorageService.instance.todayEntries()).single; + final entry = (await LogStorageService.instance.todayEntries()).single; expect(entry.source, 'meal'); expect(entry.kcal, 465); // 300 (scaled rice) + 165 (chicken) expect(entry.components, hasLength(2)); @@ -135,7 +134,7 @@ void main() { await settle(tester); await tester.enterText(find.byType(TextField).at(1), 'soup'); - await tester.enterText(find.byType(TextField).at(2), '120'); + await tester.enterText(find.byType(TextField).at(3), '120'); await settle(tester); await tester.tap(addItemButton); await settle(tester); @@ -150,11 +149,115 @@ void main() { await tester.tap(logMealButton); await settle(tester); - final entry = - (await LogStorageService.instance.todayEntries()).single; + final entry = (await LogStorageService.instance.todayEntries()).single; expect(entry.imagePath, isNotNull); expect(entry.imagePath, startsWith('${tempDir.path}/images/')); }); }, ); + + testWidgets( + 'logged meal uses provided name when name field is non-empty (line 73)', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen())); + await settle(tester); + + // Enter a meal name in field at(0), item in at(1), kcal in at(3). + await tester.enterText(find.byType(TextField).at(0), 'Sunday dinner'); + await tester.enterText(find.byType(TextField).at(1), 'pasta'); + await tester.enterText(find.byType(TextField).at(3), '400'); + await settle(tester); + await tester.tap(addItemButton); + await settle(tester); + + await tester.ensureVisible(logMealButton); + await tester.tap(logMealButton); + await settle(tester); + + final entry = (await LogStorageService.instance.todayEntries()).single; + expect(entry.desc, equals('Sunday dinner')); + }); + }, + ); + + testWidgets( + 'Add item with empty description shows error status (line 46)', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen())); + await settle(tester); + + // Tap "Add item" without entering any description. + await tester.tap(addItemButton); + await settle(tester); + + expect( + find.text('Type the item first, then add it.'), + findsOneWidget, + ); + }); + }, + ); + + testWidgets( + 'logging a meal with empty name field defaults name to "meal" (line 73)', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen())); + await settle(tester); + + // Add one item (name field intentionally left empty). + await tester.enterText(find.byType(TextField).at(1), 'rice'); + await tester.enterText(find.byType(TextField).at(3), '200'); + await settle(tester); + await tester.tap(addItemButton); + await settle(tester); + + // Leave meal name field empty, then log — name defaults to 'meal'. + await tester.ensureVisible(logMealButton); + await tester.tap(logMealButton); + await settle(tester); + + final entry = (await LogStorageService.instance.todayEntries()).single; + expect(entry.desc, equals('meal')); + }); + }, + ); + + testWidgets( + 'photo attach sheet "Take a photo" choice covers camera onTap (lines 42-43)', + (tester) async { + await tester.runAsync(() async { + final fakePhoto = File('${tempDir.path}/fake_camera.jpg') + ..createSync() + ..writeAsBytesSync([0xFF, 0xD8, 0xFF]); + ImagePickerPlatform.instance = _FakeImagePickerPlatform( + XFile(fakePhoto.path), + ); + await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen())); + await settle(tester); + + await tester.enterText(find.byType(TextField).at(1), 'salad'); + await tester.enterText(find.byType(TextField).at(3), '100'); + await settle(tester); + await tester.tap(addItemButton); + await settle(tester); + + await tester.ensureVisible(find.text('Attach photo')); + await tester.tap(find.text('Attach photo')); + await settle(tester); + // Tap "Take a photo" → triggers camera onTap (lines 42-43). + await tester.tap(find.text('Take a photo')); + await settle(tester); + + await tester.ensureVisible(logMealButton); + await tester.tap(logMealButton); + await settle(tester); + + final entry = (await LogStorageService.instance.todayEntries()).single; + expect(entry.imagePath, isNotNull); + }); + }, + ); } diff --git a/app/test/services/app_settings_service_test.dart b/app/test/services/app_settings_service_test.dart new file mode 100644 index 0000000..e6d9e71 --- /dev/null +++ b/app/test/services/app_settings_service_test.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'diet_guard_settings_test_', + ); + AppSettingsService.resetForTesting(); + }); + + tearDown(() async { + AppSettingsService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + group('dailyKcalGoal static getter', () { + test('returns 2200 when singleton is uninitialised', () { + // Singleton is null after resetForTesting() — exercises the ?? 2200 branch. + expect(AppSettingsService.dailyKcalGoal, 2200); + }); + }); + + group('resetForTesting', () { + test('with testDir creates a working instance', () { + AppSettingsService.resetForTesting(testDir: tempDir); + expect(AppSettingsService.instance, isNotNull); + }); + + test('without testDir nulls the singleton', () { + AppSettingsService.resetForTesting(testDir: tempDir); + AppSettingsService.resetForTesting(); + // instance getter throws when null — verify via dailyKcalGoal fallback. + expect(AppSettingsService.dailyKcalGoal, 2200); + }); + }); + + group('init early-return', () { + test('returns existing instance without re-initialising', () async { + AppSettingsService.resetForTesting(testDir: tempDir); + final first = AppSettingsService.instance; + // init() sees _instance != null and returns early (no platform channel). + final second = await AppSettingsService.init(); + expect(identical(first, second), isTrue); + }); + }); + + group('saveDailyKcalGoal', () { + test('updates in-memory value and persists to file', () async { + AppSettingsService.resetForTesting(testDir: tempDir); + await AppSettingsService.instance.saveDailyKcalGoal(1800); + + expect(AppSettingsService.dailyKcalGoal, 1800); + + final raw = await File( + '${tempDir.path}/app_settings.json', + ).readAsString(); + final data = jsonDecode(raw) as Map; + expect(data['daily_kcal_goal'], 1800); + }); + }); + + group('initForTesting (_load paths)', () { + test('loads daily_kcal_goal from an existing file', () async { + await File( + '${tempDir.path}/app_settings.json', + ).writeAsString(jsonEncode({'daily_kcal_goal': 1600})); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.dailyKcalGoal, 1600); + }); + + test('keeps default 2200 when file does not exist', () async { + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.dailyKcalGoal, 2200); + }); + + test('keeps default 2200 on unparseable JSON', () async { + await File( + '${tempDir.path}/app_settings.json', + ).writeAsString('not json at all'); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.dailyKcalGoal, 2200); + }); + + test('keeps default 2200 when JSON root is not a Map', () async { + await File( + '${tempDir.path}/app_settings.json', + ).writeAsString(jsonEncode([1, 2, 3])); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.dailyKcalGoal, 2200); + }); + + test('keeps default 2200 when daily_kcal_goal is not an int', () async { + await File( + '${tempDir.path}/app_settings.json', + ).writeAsString(jsonEncode({'daily_kcal_goal': 'two thousand'})); + + await AppSettingsService.initForTesting(tempDir); + + expect(AppSettingsService.dailyKcalGoal, 2200); + }); + }); +} diff --git a/app/test/services/foodbank_service_test.dart b/app/test/services/foodbank_service_test.dart index bb04f0c..1f6790c 100644 --- a/app/test/services/foodbank_service_test.dart +++ b/app/test/services/foodbank_service_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/models/food_bank_record.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/models/meal_component.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; @@ -178,4 +179,216 @@ void main() { expect(results.first.name, 'common'); }); }); + + // --------------------------------------------------------------------------- + // Manual bank — addManualEntry / mergedEntries + // --------------------------------------------------------------------------- + + group('FoodBankService manual bank', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_manual_'); + FoodBankService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + FoodBankService.resetForTesting(); + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + test('addManualEntry persists and mergedEntries returns it', () async { + const record = FoodBankRecord( + desc: 'Manual oat', + kcal: 370, + proteinG: 13, + carbsG: 66, + fatG: 7, + grams: 100, + count: 0, + ); + + await FoodBankService.instance.addManualEntry(record); + final merged = await FoodBankService.instance.mergedEntries(); + + expect(merged.any((r) => r.desc == 'Manual oat'), isTrue); + }); + + test( + 'mergedEntries: log-derived entry wins over manual on collision', + () async { + // Seed log with 'oat' (count=1, kcal=100). + final log = { + '2026-06-22': [ + _entry(id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'oat'), + ], + }; + await FoodBankService.instance.rebuildAndPersist(log); + + // Add manual entry with same normalized key but different kcal. + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'oat', + kcal: 999, + proteinG: 0, + carbsG: 0, + fatG: 0, + grams: 100, + count: 0, + ), + ); + + final merged = await FoodBankService.instance.mergedEntries(); + final oat = merged.firstWhere((r) => r.desc == 'oat'); + // Log-derived entry (kcal=100) should win over manual (kcal=999). + expect(oat.kcal, 100); + }, + ); + + test( + 'mergedEntries includes both log-derived and manual entries', + () async { + final log = { + '2026-06-22': [ + _entry( + id: '1', + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + ), + ], + }; + await FoodBankService.instance.rebuildAndPersist(log); + + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Quinoa', + kcal: 370, + proteinG: 14, + carbsG: 64, + fatG: 6, + grams: 100, + count: 0, + ), + ); + + final merged = await FoodBankService.instance.mergedEntries(); + final descs = merged.map((r) => r.desc).toSet(); + expect(descs, containsAll(['toast', 'Quinoa'])); + }, + ); + + test('addManualEntry upserts by normalized key', () async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Oat', + kcal: 370, + proteinG: 13, + carbsG: 66, + fatG: 7, + grams: 100, + count: 0, + ), + ); + + // Upsert same food with updated kcal. + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'oat', + kcal: 400, + proteinG: 14, + carbsG: 68, + fatG: 8, + grams: 100, + count: 0, + ), + ); + + final merged = await FoodBankService.instance.mergedEntries(); + final oats = merged.where((r) => r.desc.toLowerCase() == 'oat').toList(); + expect(oats.length, 1); + expect(oats.single.kcal, 400); + }); + + test('search includes manual entries', () async { + await FoodBankService.instance.addManualEntry( + const FoodBankRecord( + desc: 'Rare ingredient', + kcal: 50, + proteinG: 1, + carbsG: 10, + fatG: 0.5, + grams: 100, + count: 0, + ), + ); + + final results = await FoodBankService.instance.search('Rare'); + expect(results.any((r) => r.name == 'Rare ingredient'), isTrue); + }); + }); + + // --------------------------------------------------------------------------- + // IO error paths — FileSystemException and FormatException handlers + // --------------------------------------------------------------------------- + + group('FoodBankService IO error paths', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_err_'); + FoodBankService.resetForTesting(testDir: tempDir); + LogStorageService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + FoodBankService.resetForTesting(); + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + test('readBank returns empty on invalid JSON (FormatException)', () async { + await File( + '${tempDir.path}/food_bank.json', + ).writeAsString('not valid json {{{'); + expect(await FoodBankService.instance.readBank(), isEmpty); + }); + + test( + 'readBank returns empty when file is unreadable (FileSystemException)', + () async { + final bankPath = '${tempDir.path}/food_bank.json'; + await File(bankPath).writeAsString('{}'); + await Process.run('chmod', ['000', bankPath]); + + expect(await FoodBankService.instance.readBank(), isEmpty); + + await Process.run('chmod', ['644', bankPath]); + }, + ); + + test( + 'mergedEntries handles invalid JSON in manual bank (FormatException)', + () async { + await File( + '${tempDir.path}/food_bank_manual.json', + ).writeAsString('not valid json {{{'); + expect(await FoodBankService.instance.mergedEntries(), isEmpty); + }, + ); + + test( + 'mergedEntries handles unreadable manual bank (FileSystemException)', + () async { + final manualPath = '${tempDir.path}/food_bank_manual.json'; + await File(manualPath).writeAsString('{}'); + await Process.run('chmod', ['000', manualPath]); + + expect(await FoodBankService.instance.mergedEntries(), isEmpty); + + await Process.run('chmod', ['644', manualPath]); + }, + ); + }); } diff --git a/app/test/services/fuzzy_test.dart b/app/test/services/fuzzy_test.dart index 1771907..0d3903d 100644 --- a/app/test/services/fuzzy_test.dart +++ b/app/test/services/fuzzy_test.dart @@ -13,10 +13,12 @@ void main() { expect(substring, greaterThan(typo)); }); - test('scores an empty query against a name as the fallback token score', - () { - expect(matchScore('', 'banana'), greaterThanOrEqualTo(0)); - }); + test( + 'scores an empty query against a name as the fallback token score', + () { + expect(matchScore('', 'banana'), greaterThanOrEqualTo(0)); + }, + ); test('scores a clear mismatch low', () { expect(matchScore('xyz', 'banana'), lessThan(0.6)); diff --git a/app/test/services/github_client_test.dart b/app/test/services/github_client_test.dart index 0f3b9b4..76c204c 100644 --- a/app/test/services/github_client_test.dart +++ b/app/test/services/github_client_test.dart @@ -41,7 +41,12 @@ void main() { (_) async => http.Response( jsonEncode([ {'type': 'dir', 'name': 'pc', 'path': 'devices/pc', 'sha': 's1'}, - {'type': 'dir', 'name': 'phone', 'path': 'devices/phone', 'sha': 's2'}, + { + 'type': 'dir', + 'name': 'phone', + 'path': 'devices/phone', + 'sha': 's2', + }, ]), 200, ), diff --git a/app/test/services/log_storage_service_test.dart b/app/test/services/log_storage_service_test.dart index b9233bb..b5531c2 100644 --- a/app/test/services/log_storage_service_test.dart +++ b/app/test/services/log_storage_service_test.dart @@ -132,7 +132,9 @@ void main() { fatG: 3, source: 'manual', ); - await LogStorageService.instance.writeLog({yesterdayKey: [yesterday]}); + await LogStorageService.instance.writeLog({ + yesterdayKey: [yesterday], + }); await LogStorageService.instance.logMeal('today', _manual); expect(await LogStorageService.instance.undoLastToday(), isNotNull); @@ -141,15 +143,20 @@ void main() { expect(log[yesterdayKey]!.single.deleted, isFalse); }); - test('skips an already-tombstoned entry and undoes the one before it', - () async { - final first = await LogStorageService.instance.logMeal('first', _manual); - await LogStorageService.instance.logMeal('second', _manual); - await LogStorageService.instance.undoLastToday(); - final undoneAgain = await LogStorageService.instance.undoLastToday(); - expect(undoneAgain!.id, first.id); - expect(await LogStorageService.instance.undoLastToday(), isNull); - }); + test( + 'skips an already-tombstoned entry and undoes the one before it', + () async { + final first = await LogStorageService.instance.logMeal( + 'first', + _manual, + ); + await LogStorageService.instance.logMeal('second', _manual); + await LogStorageService.instance.undoLastToday(); + final undoneAgain = await LogStorageService.instance.undoLastToday(); + expect(undoneAgain!.id, first.id); + expect(await LogStorageService.instance.undoLastToday(), isNull); + }, + ); }); group('todayTotalKcal', () { @@ -211,21 +218,167 @@ void main() { deleted: true, ); - test('sorts entries across days newest-first and drops tombstones', - () async { - await LogStorageService.instance.writeLog({ - '2026-06-01': [oldest], - '2026-06-15': [tombstoned], - '2026-06-22': [newest], - }); + test( + 'sorts entries across days newest-first and drops tombstones', + () async { + await LogStorageService.instance.writeLog({ + '2026-06-01': [oldest], + '2026-06-15': [tombstoned], + '2026-06-22': [newest], + }); - final result = await LogStorageService.instance.allEntriesNewestFirst(); + final result = await LogStorageService.instance.allEntriesNewestFirst(); - expect(result.map((e) => e.id), ['newest', 'oldest']); - }); + expect(result.map((e) => e.id), ['newest', 'oldest']); + }, + ); test('returns empty for an empty log', () async { expect(await LogStorageService.instance.allEntriesNewestFirst(), isEmpty); }); }); + + group('deleteEntry', () { + const entry = FoodEntry( + id: 'del-1', + time: '2026-06-22T12:00:00+02:00', + desc: 'to delete', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 30, + fatG: 5, + source: 'manual', + ); + + test('tombstones the matching entry', () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [entry], + }); + await LogStorageService.instance.deleteEntry('del-1'); + final log = await LogStorageService.instance.readLog(); + expect(log['2026-06-22']!.first.deleted, isTrue); + }); + + test('silently ignores an unknown id', () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [entry], + }); + await LogStorageService.instance.deleteEntry('no-such-id'); + final log = await LogStorageService.instance.readLog(); + expect(log['2026-06-22']!.first.deleted, isFalse); + }); + + test('does not re-tombstone an already-deleted entry', () async { + const deleted = FoodEntry( + id: 'del-1', + time: '2026-06-22T12:00:00+02:00', + desc: 'to delete', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 30, + fatG: 5, + source: 'manual', + deleted: true, + ); + await LogStorageService.instance.writeLog({ + '2026-06-22': [deleted], + }); + await LogStorageService.instance.deleteEntry('del-1'); + // Still deleted, no error thrown. + final log = await LogStorageService.instance.readLog(); + expect(log['2026-06-22']!.first.deleted, isTrue); + }); + }); + + group('updateEntry', () { + const original = FoodEntry( + id: 'upd-1', + time: '2026-06-22T12:00:00+02:00', + desc: 'original desc', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 30, + fatG: 5, + source: 'manual', + ); + + const updated = FoodEntry( + id: 'upd-1', + time: '2026-06-22T12:00:00+02:00', + desc: 'edited desc', + grams: 200, + kcal: 600, + proteinG: 20, + carbsG: 60, + fatG: 10, + source: 'manual', + ); + + test('replaces the entry by id', () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [original], + }); + await LogStorageService.instance.updateEntry(original, updated); + final log = await LogStorageService.instance.readLog(); + final e = log['2026-06-22']!.first; + expect(e.desc, 'edited desc'); + expect(e.kcal, 600); + expect(e.proteinG, 20); + }); + + test('replaces legacy null-id entry by time+desc', () async { + const legacy = FoodEntry( + time: '2026-06-22T12:00:00+02:00', + desc: 'legacy entry', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 30, + fatG: 5, + source: 'food bank', + ); + const legacyUpdated = FoodEntry( + id: 'new-uuid', + time: '2026-06-22T12:00:00+02:00', + desc: 'legacy entry', + grams: 150, + kcal: 450, + proteinG: 15, + carbsG: 45, + fatG: 8, + source: 'food bank', + ); + await LogStorageService.instance.writeLog({ + '2026-06-22': [legacy], + }); + await LogStorageService.instance.updateEntry(legacy, legacyUpdated); + final log = await LogStorageService.instance.readLog(); + final e = log['2026-06-22']!.first; + expect(e.id, 'new-uuid'); + expect(e.kcal, 450); + }); + + test('silently does nothing when no match is found', () async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [original], + }); + const ghost = FoodEntry( + id: 'ghost', + time: '2026-06-22T12:00:00+02:00', + desc: 'ghost', + grams: 0, + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + source: 'manual', + ); + await LogStorageService.instance.updateEntry(ghost, updated); + final log = await LogStorageService.instance.readLog(); + expect(log['2026-06-22']!.first.desc, 'original desc'); + }); + }); } diff --git a/app/test/services/photo_attach_service_test.dart b/app/test/services/photo_attach_service_test.dart index fe66025..6415b67 100644 --- a/app/test/services/photo_attach_service_test.dart +++ b/app/test/services/photo_attach_service_test.dart @@ -35,22 +35,25 @@ void main() { await tempDir.delete(recursive: true); }); - test('copies the picked file into /images with a new name', () async { - final source = File('${tempDir.path}/source.jpg') - ..writeAsBytesSync([1, 2, 3, 4]); - ImagePickerPlatform.instance = _FakeImagePickerPlatform( - XFile(source.path), - ); + test( + 'copies the picked file into /images with a new name', + () async { + final source = File('${tempDir.path}/source.jpg') + ..writeAsBytesSync([1, 2, 3, 4]); + ImagePickerPlatform.instance = _FakeImagePickerPlatform( + XFile(source.path), + ); - final result = await PhotoAttachService.instance.pickAndStore( - ImageSource.gallery, - ); + final result = await PhotoAttachService.instance.pickAndStore( + ImageSource.gallery, + ); - expect(result, isNotNull); - expect(result, startsWith('${tempDir.path}/images/')); - expect(result, endsWith('.jpg')); - expect(File(result!).readAsBytesSync(), [1, 2, 3, 4]); - }); + expect(result, isNotNull); + expect(result, startsWith('${tempDir.path}/images/')); + expect(result, endsWith('.jpg')); + expect(File(result!).readAsBytesSync(), [1, 2, 3, 4]); + }, + ); test('returns null when the picker is cancelled', () async { ImagePickerPlatform.instance = _FakeImagePickerPlatform(null); diff --git a/app/test/services/sync_merge_test.dart b/app/test/services/sync_merge_test.dart index 0029212..0a80d45 100644 --- a/app/test/services/sync_merge_test.dart +++ b/app/test/services/sync_merge_test.dart @@ -39,9 +39,14 @@ void main() { test('same id in both logs is not duplicated', () { final shared = _entry(id: 'shared'); - final merged = mergeLogs({ - '2026-06-22': [shared], - }, {'2026-06-22': [shared]}); + final merged = mergeLogs( + { + '2026-06-22': [shared], + }, + { + '2026-06-22': [shared], + }, + ); expect(merged['2026-06-22'], hasLength(1)); }); @@ -56,9 +61,14 @@ void main() { time: '2026-06-20T08:00:00', desc: 'toast', ); - final merged = mergeLogs({ - '2026-06-20': [legacyA], - }, {'2026-06-20': [legacyB]}); + final merged = mergeLogs( + { + '2026-06-20': [legacyA], + }, + { + '2026-06-20': [legacyB], + }, + ); expect(merged['2026-06-20'], hasLength(1)); }); @@ -69,9 +79,14 @@ void main() { desc: 'toast', ); final withId = _entry(id: 'x', time: '2026-06-20T09:00:00', desc: 'eggs'); - final merged = mergeLogs({ - '2026-06-20': [legacy], - }, {'2026-06-20': [withId]}); + final merged = mergeLogs( + { + '2026-06-20': [legacy], + }, + { + '2026-06-20': [withId], + }, + ); expect(merged['2026-06-20'], hasLength(2)); }); }); @@ -81,12 +96,22 @@ void main() { final normal = _entry(id: 'x'); final tombstoned = _entry(id: 'x', deleted: true); - final forward = mergeLogs({ - '2026-06-22': [normal], - }, {'2026-06-22': [tombstoned]}); - final backward = mergeLogs({ - '2026-06-22': [tombstoned], - }, {'2026-06-22': [normal]}); + final forward = mergeLogs( + { + '2026-06-22': [normal], + }, + { + '2026-06-22': [tombstoned], + }, + ); + final backward = mergeLogs( + { + '2026-06-22': [tombstoned], + }, + { + '2026-06-22': [normal], + }, + ); expect(forward['2026-06-22']!.single.deleted, isTrue); expect(backward['2026-06-22']!.single.deleted, isTrue); @@ -94,9 +119,14 @@ void main() { test('two tombstoned copies stay tombstoned', () { final tombstoned = _entry(id: 'x', deleted: true); - final merged = mergeLogs({ - '2026-06-22': [tombstoned], - }, {'2026-06-22': [_entry(id: 'x', deleted: true)]}); + final merged = mergeLogs( + { + '2026-06-22': [tombstoned], + }, + { + '2026-06-22': [_entry(id: 'x', deleted: true)], + }, + ); expect(merged['2026-06-22']!.single.deleted, isTrue); }); }); @@ -106,7 +136,9 @@ void main() { "entry is filed under its own time's date, not the arrival bucket", () { final misfiled = _entry(id: 'x', time: '2026-06-21T23:00:00'); - final merged = mergeLogs({'2026-06-22': [misfiled]}, {}); + final merged = mergeLogs({ + '2026-06-22': [misfiled], + }, {}); expect(merged.keys, ['2026-06-21']); expect(merged['2026-06-21']!.single.id, 'x'); }, @@ -119,7 +151,9 @@ void main() { // Dart's substring throws past the string length, unlike Python's // forgiving slice -- this only matters for malformed/legacy data. final short = _entry(id: 'x', time: '2026'); - final merged = mergeLogs({'2026-06-22': [short]}, {}); + final merged = mergeLogs({ + '2026-06-22': [short], + }, {}); expect(merged.keys, ['2026']); }, ); @@ -127,9 +161,14 @@ void main() { test("a day's entries are sorted oldest-first", () { final late = _entry(id: 'late', time: '2026-06-22T20:00:00'); final early = _entry(id: 'early', time: '2026-06-22T08:00:00'); - final merged = mergeLogs({ - '2026-06-22': [late], - }, {'2026-06-22': [early]}); + final merged = mergeLogs( + { + '2026-06-22': [late], + }, + { + '2026-06-22': [early], + }, + ); expect(merged['2026-06-22']!.map((e) => e.id).toList(), [ 'early', 'late', @@ -139,7 +178,9 @@ void main() { group('algebraic properties', () { test('merge is commutative', () { - final a = {'2026-06-22': [_entry(id: 'a')]}; + final a = { + '2026-06-22': [_entry(id: 'a')], + }; final b = { '2026-06-22': [_entry(id: 'b', time: '2026-06-22T09:00:00')], }; @@ -152,13 +193,17 @@ void main() { }); test('merge is idempotent', () { - final canonical = {'2026-06-22': [_entry(id: 'a')]}; + final canonical = { + '2026-06-22': [_entry(id: 'a')], + }; final merged = mergeLogs(canonical, canonical); expect(merged['2026-06-22']!.map((e) => e.id).toList(), ['a']); }); test('merging with an empty log is a no-op', () { - final log = {'2026-06-22': [_entry(id: 'a')]}; + final log = { + '2026-06-22': [_entry(id: 'a')], + }; expect(mergeLogs(log, {}).keys, log.keys); expect(mergeLogs({}, log).keys, log.keys); }); diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 9e2f2a8..dcd11a8 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -20,8 +20,9 @@ void main() { await tempDir.delete(recursive: true); }); - testWidgets('app launches straight into the meal-logging screen', - (tester) async { + testWidgets('app launches straight into the meal-logging screen', ( + tester, + ) async { // LogMealScreen's initState does real dart:io file I/O; pumpAndSettle() // alone never lets that resolve (see log_meal_screen_test.dart). await tester.runAsync(() async {