From 7cb09a5b85de6ee153f8a1ab6c1150522b5dbb5f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 19:43:08 +0200 Subject: [PATCH] Split food_bank_screen; share the filter widgets with history food_bank_screen.dart (712) carried a near-clone of the filter sheet batch 2 had just factored out of history: the same name-search field, the same slider captions, and four more copies of the same min/max macro range block. Splitting it in isolation would have baked that duplication in permanently, so the shared widgets moved to a neutral `lib/widgets/filters/` first and both screens now import them. food_bank_screen.dart 712 -> 238 lib/widgets/food_bank/ fb_filter.dart 156 FbFilter, FbSortField, applyFbFilter fb_filter_sheet.dart 244 the sheet itself fb_add_entry_dialog.dart 103 the manual-entry dialog lib/widgets/history/history_filter_fields.dart -> lib/widgets/filters/filter_fields.dart FilterRangeRow gained `showEndpointLabels`, defaulting to true. History showed endpoint captions and a selected-range readout; the food-bank sheet never did, and switching them on there would have been a UI change smuggled in under a refactor. The food-bank call sites pass false, so both screens render exactly what they rendered before. FbSortField and HistorySortField stay separate on purpose -- they are not the same enum (`name` vs `date`/`description`), so the sort dropdown is not shared. The test file mirrors the source split, three files under test/screens/ food_bank/ plus fb_test_support.dart for the setUp/tearDown/settle prologue, same shape as batch 2's history_test_support.dart. Verified: 555 flutter tests (unchanged count -- no test lost, and food bank's own RangeSlider/sort-toggle tests still pass against the shared widget), flutter analyze clean over lib/ and test/, live ~/.local/share/diet_guard byte-identical. Over-cap files: 40 -> 38. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC --- app/lib/screens/food_bank_screen.dart | 510 +-------------- .../filter_fields.dart} | 20 +- .../food_bank/fb_add_entry_dialog.dart | 103 +++ app/lib/widgets/food_bank/fb_filter.dart | 156 +++++ .../widgets/food_bank/fb_filter_sheet.dart | 244 +++++++ .../widgets/history/history_filter_sheet.dart | 2 +- app/pubspec.yaml | 2 +- .../screens/food_bank/fb_filter_test.dart | 218 +++++++ .../screens/food_bank/fb_screen_test.dart | 206 ++++++ app/test/screens/food_bank/fb_sheet_test.dart | 184 ++++++ .../screens/food_bank/fb_test_support.dart | 42 ++ app/test/screens/food_bank_screen_test.dart | 614 ------------------ 12 files changed, 1189 insertions(+), 1112 deletions(-) rename app/lib/widgets/{history/history_filter_fields.dart => filters/filter_fields.dart} (84%) create mode 100644 app/lib/widgets/food_bank/fb_add_entry_dialog.dart create mode 100644 app/lib/widgets/food_bank/fb_filter.dart create mode 100644 app/lib/widgets/food_bank/fb_filter_sheet.dart create mode 100644 app/test/screens/food_bank/fb_filter_test.dart create mode 100644 app/test/screens/food_bank/fb_screen_test.dart create mode 100644 app/test/screens/food_bank/fb_sheet_test.dart create mode 100644 app/test/screens/food_bank/fb_test_support.dart delete mode 100644 app/test/screens/food_bank_screen_test.dart diff --git a/app/lib/screens/food_bank_screen.dart b/app/lib/screens/food_bank_screen.dart index 4e8855b..a184f60 100644 --- a/app/lib/screens/food_bank_screen.dart +++ b/app/lib/screens/food_bank_screen.dart @@ -1,164 +1,22 @@ /// Food bank browser: lists every entry across the log-derived and manual /// banks with filtering, sorting, and the ability to add new manual entries. +/// +/// The filter model, the filter sheet and the add-entry dialog live under +/// `lib/widgets/food_bank/` (split for the repo's 250-line cap); this file is +/// the screen that wires them together. 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:diet_guard_app/widgets/food_bank/fb_add_entry_dialog.dart'; +import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart'; +import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.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. +/// Browses every banked food -- log-derived and manually curated -- with +/// filtering, sorting, and an add-entry dialog. class FoodBankScreen extends StatefulWidget { /// Creates a [FoodBankScreen]. const FoodBankScreen({super.key}); @@ -235,7 +93,7 @@ class _FoodBankScreenState extends State { isScrollControlled: true, useSafeArea: true, builder: (ctx) => StatefulBuilder( - builder: (ctx, setSheet) => _FbFilterSheet( + builder: (ctx, setSheet) => FbFilterSheet( filter: draft, sortField: draftSort, ascending: draftAsc, @@ -274,7 +132,7 @@ class _FoodBankScreenState extends State { Future _openAddDialog() async { final result = await showDialog( context: context, - builder: (_) => const _AddEntryDialog(), + builder: (_) => const AddEntryDialog(), ); if (result == null) return; await FoodBankService.instance.addManualEntry(result); @@ -328,7 +186,7 @@ class _FoodBankScreenState extends State { ) : ListView.builder( itemCount: _displayed.length, - itemBuilder: (context, i) => _RecordTile(_displayed[i]), + itemBuilder: (context, i) => RecordTile(_displayed[i]), ), floatingActionButton: FloatingActionButton( onPressed: _openAddDialog, @@ -343,8 +201,12 @@ class _FoodBankScreenState extends State { // Record tile // --------------------------------------------------------------------------- -class _RecordTile extends StatelessWidget { - const _RecordTile(this.record); +/// One banked food: its name, macros, and how often it has been logged. +class RecordTile extends StatelessWidget { + /// Creates a [RecordTile] for [record]. + const RecordTile(this.record, {super.key}); + + /// The banked record this tile renders. final FoodBankRecord record; @override @@ -374,339 +236,3 @@ class _RecordTile extends StatelessWidget { // --------------------------------------------------------------------------- // 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/widgets/history/history_filter_fields.dart b/app/lib/widgets/filters/filter_fields.dart similarity index 84% rename from app/lib/widgets/history/history_filter_fields.dart rename to app/lib/widgets/filters/filter_fields.dart index 7d27a63..f99e376 100644 --- a/app/lib/widgets/history/history_filter_fields.dart +++ b/app/lib/widgets/filters/filter_fields.dart @@ -1,6 +1,9 @@ -/// Small input widgets used by the history filter sheet. +/// Filter-sheet input widgets shared by the history and food-bank screens. /// -/// Split from `history_filter_sheet.dart` for the repo's 250-line cap. +/// Both screens grew the same name-search field, the same slider captions and +/// four copies each of the same min/max macro range block. They live here, in a +/// neutral `widgets/filters/` rather than under either screen, so neither +/// screen owns the other's controls. library; import 'package:flutter/material.dart'; @@ -116,6 +119,7 @@ class FilterRangeRow extends StatelessWidget { required this.max, required this.onChanged, this.unit = '', + this.showEndpointLabels = true, super.key, }); @@ -140,6 +144,12 @@ class FilterRangeRow extends StatelessWidget { /// Suffix appended to the endpoint and selection labels, e.g. `g`. final String unit; + /// Whether to print the endpoint captions and the selected-range readout. + /// + /// The history sheet shows both; the food-bank sheet never did, and turning + /// them on there would be a UI change smuggled in under a refactor. + final bool showEndpointLabels; + @override Widget build(BuildContext context) { if (maxValue <= 0) return const SizedBox.shrink(); @@ -149,7 +159,8 @@ class FilterRangeRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(label, style: Theme.of(context).textTheme.labelLarge), - SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'), + if (showEndpointLabels) + SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'), RangeSlider( key: sliderKey, max: maxValue, @@ -160,7 +171,8 @@ class FilterRangeRow extends StatelessWidget { v.end < maxValue ? v.end : null, ), ), - SliderSelectedLabel('${lo.round()} – ${hi.round()}$unit'), + if (showEndpointLabels) + SliderSelectedLabel('${lo.round()} – ${hi.round()}$unit'), const SizedBox(height: 8), ], ); diff --git a/app/lib/widgets/food_bank/fb_add_entry_dialog.dart b/app/lib/widgets/food_bank/fb_add_entry_dialog.dart new file mode 100644 index 0000000..55fb8c5 --- /dev/null +++ b/app/lib/widgets/food_bank/fb_add_entry_dialog.dart @@ -0,0 +1,103 @@ +/// The "add a manual food bank entry" dialog. +/// +/// Split out of `food_bank_screen.dart` for the repo's 250-line cap. +library; + +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:flutter/material.dart'; + +/// Prompts for a new manually-curated food bank entry. +/// +/// Pops with the new [FoodBankRecord], or null when cancelled. +class AddEntryDialog extends StatefulWidget { + /// Creates an [AddEntryDialog] with empty fields. + const AddEntryDialog({super.key}); + + @override + State createState() => AddEntryDialogState(); +} + +/// State for [AddEntryDialog], owning its text controllers. +class AddEntryDialogState extends State { + 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/widgets/food_bank/fb_filter.dart b/app/lib/widgets/food_bank/fb_filter.dart new file mode 100644 index 0000000..63f0519 --- /dev/null +++ b/app/lib/widgets/food_bank/fb_filter.dart @@ -0,0 +1,156 @@ +/// Filter and sort model for the food bank browser. +/// +/// Split out of `food_bank_screen.dart` for the repo's 250-line cap. Pure data +/// -- no widgets -- so the sheet, the list and the screen can each depend on it +/// without depending on one another. +library; + +import 'package:diet_guard_app/models/food_bank_record.dart'; + +/// 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. diff --git a/app/lib/widgets/food_bank/fb_filter_sheet.dart b/app/lib/widgets/food_bank/fb_filter_sheet.dart new file mode 100644 index 0000000..a57ae41 --- /dev/null +++ b/app/lib/widgets/food_bank/fb_filter_sheet.dart @@ -0,0 +1,244 @@ +/// The bottom sheet that edits an [FbFilter]. +/// +/// Split out of `food_bank_screen.dart` for the repo's 250-line cap. The macro +/// range rows come from `widgets/filters/`, shared with the history sheet. +library; + +import 'package:diet_guard_app/widgets/filters/filter_fields.dart'; +import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart'; +import 'package:flutter/material.dart'; + +/// The draggable bottom sheet that edits the food bank's filter and sort order. +/// +/// Stateless on purpose: the live [FbFilter] belongs to the screen, and every +/// edit goes back out through [onFilterChanged] rather than being held here. +class FbFilterSheet extends StatelessWidget { + /// Creates an [FbFilterSheet] over the screen's current filter/sort state. + 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, + super.key, + }); + + /// The filter criteria currently being edited. + final FbFilter filter; + + /// The field the list is sorted by. + final FbSortField sortField; + + /// Whether the sort runs ascending. + final bool ascending; + + /// Upper bound of the calories slider, from the banked records. + final double maxKcal; + + /// Upper bound of the protein slider, from the banked records. + final double maxProtein; + + /// Upper bound of the carbohydrate slider, from the banked records. + final double maxCarbs; + + /// Upper bound of the fat slider, from the banked records. + final double maxFat; + + /// Called with a new filter on every edit. + final void Function(FbFilter) onFilterChanged; + + /// Called when the sort field or direction changes. + final void Function({required FbSortField field, required bool asc}) + onSortChanged; + + /// Called when the user applies the filter and dismisses the sheet. + final VoidCallback onApply; + + /// Called when the user clears every criterion. + 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), + FilterRangeRow( + label: 'Kcal range', + sliderKey: const Key('fb-kcal-range-slider'), + maxValue: maxKcal, + min: filter.minKcal, + max: filter.maxKcal, + showEndpointLabels: false, + onChanged: (lo, hi) { + filter.minKcal = lo; + filter.maxKcal = hi; + onFilterChanged(filter); + }, + ), + FilterRangeRow( + label: 'Protein range (g)', + sliderKey: const Key('fb-protein-range-slider'), + maxValue: maxProtein, + min: filter.minProtein, + max: filter.maxProtein, + unit: 'g', + showEndpointLabels: false, + onChanged: (lo, hi) { + filter.minProtein = lo; + filter.maxProtein = hi; + onFilterChanged(filter); + }, + ), + FilterRangeRow( + label: 'Carbs range (g)', + sliderKey: const Key('fb-carbs-range-slider'), + maxValue: maxCarbs, + min: filter.minCarbs, + max: filter.maxCarbs, + unit: 'g', + showEndpointLabels: false, + onChanged: (lo, hi) { + filter.minCarbs = lo; + filter.maxCarbs = hi; + onFilterChanged(filter); + }, + ), + FilterRangeRow( + label: 'Fat range (g)', + sliderKey: const Key('fb-fat-range-slider'), + maxValue: maxFat, + min: filter.minFat, + max: filter.maxFat, + unit: 'g', + showEndpointLabels: false, + onChanged: (lo, hi) { + filter.minFat = lo; + filter.maxFat = hi; + onFilterChanged(filter); + }, + ), + 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 +// --------------------------------------------------------------------------- diff --git a/app/lib/widgets/history/history_filter_sheet.dart b/app/lib/widgets/history/history_filter_sheet.dart index 635c7e2..b7e795f 100644 --- a/app/lib/widgets/history/history_filter_sheet.dart +++ b/app/lib/widgets/history/history_filter_sheet.dart @@ -3,8 +3,8 @@ /// Split out of `history_screen.dart` to hold the repo's 250-line cap. library; +import 'package:diet_guard_app/widgets/filters/filter_fields.dart'; import 'package:diet_guard_app/widgets/history/history_filter.dart'; -import 'package:diet_guard_app/widgets/history/history_filter_fields.dart'; import 'package:diet_guard_app/widgets/history/history_filter_sort.dart'; import 'package:diet_guard_app/widgets/history/history_grouping.dart'; import 'package:flutter/material.dart'; diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 729bfab..ebfce0e 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.17+1 +version: 1.0.18+1 environment: sdk: ^3.12.2 diff --git a/app/test/screens/food_bank/fb_filter_test.dart b/app/test/screens/food_bank/fb_filter_test.dart new file mode 100644 index 0000000..9b067f3 --- /dev/null +++ b/app/test/screens/food_bank/fb_filter_test.dart @@ -0,0 +1,218 @@ +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fb_test_support.dart'; + +void main() { + useTempFoodBankStores(); + 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 + // --------------------------------------------------------------------------- +} diff --git a/app/test/screens/food_bank/fb_screen_test.dart b/app/test/screens/food_bank/fb_screen_test.dart new file mode 100644 index 0000000..c890031 --- /dev/null +++ b/app/test/screens/food_bank/fb_screen_test.dart @@ -0,0 +1,206 @@ +import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/screens/food_bank_screen.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fb_test_support.dart'; + +void main() { + useTempFoodBankStores(); + 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); + }); + }); +} diff --git a/app/test/screens/food_bank/fb_sheet_test.dart b/app/test/screens/food_bank/fb_sheet_test.dart new file mode 100644 index 0000000..619a536 --- /dev/null +++ b/app/test/screens/food_bank/fb_sheet_test.dart @@ -0,0 +1,184 @@ +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:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fb_test_support.dart'; + +void main() { + useTempFoodBankStores(); + 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/food_bank/fb_test_support.dart b/app/test/screens/food_bank/fb_test_support.dart new file mode 100644 index 0000000..b7349bc --- /dev/null +++ b/app/test/screens/food_bank/fb_test_support.dart @@ -0,0 +1,42 @@ +/// Shared setup for the food bank screen's split test files. +/// +/// `food_bank_screen_test.dart` was one 614-line file; splitting it for the +/// repo's 250-line cap left every part needing the same temp-dir isolation and +/// the same settle helper, so they live here rather than being copy-pasted +/// (which is how two copies drift and one silently stops isolating). +library; + +import 'dart:io'; + +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Point the food bank and log services at a fresh temp dir per test, and tear +/// it down afterwards. Call once at the top of a file's `main()`. +void useTempFoodBankStores() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_screen_'); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + }); + + tearDown(() async { + FoodBankService.resetForTesting(); + LogStorageService.resetForTesting(); + await tempDir.delete(recursive: true); + }); +} + +/// Pump until the screen has finished its untracked initState load. +/// +/// FoodBankScreen loads via a fire-and-forget Future in initState that +/// Flutter's frame scheduler does not track, so every test runs inside +/// runAsync() with a short real delay before settling. +Future settle(WidgetTester tester) async { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); +} diff --git a/app/test/screens/food_bank_screen_test.dart b/app/test/screens/food_bank_screen_test.dart deleted file mode 100644 index 44e3d6d..0000000 --- a/app/test/screens/food_bank_screen_test.dart +++ /dev/null @@ -1,614 +0,0 @@ -import 'dart:io'; - -import 'package:diet_guard_app/services/document_store_io.dart'; -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(store: FileDocumentStore(tempDir)); - LogStorageService.resetForTesting(store: FileDocumentStore(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); - }); - }); -}