From 895a87cf8630652ca7e0ba25fde92138ff1ff05b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 19:35:19 +0200 Subject: [PATCH] Split history_screen and its test into widget siblings history_screen.dart (1052) and history_screen_test.dart (1017) were a third of the remaining over-cap work. Widget extraction only -- no route changes, since routes are user-visible and repo_invariants_test.dart pins the navigation surface. history_screen.dart 1052 -> 211 lib/widgets/history/ history_filter.dart 102 filter criteria + sort enum (pure data) history_grouping.dart 206 HistoryItem tree, apply/group/format history_list.dart 194 GroupedList, DayHeaderTile, EntryTile history_filter_sheet.dart 242 the sheet itself history_filter_fields.dart 168 search field, slider labels, range row history_filter_sort.dart 140 source chips + sort controls Names that now cross a file boundary became public (HistoryItem, DayHeader, EntryRow, buildGroupedItems, formatDay, dateRangeLabel, the tile widgets), each with the doc comment public_member_api_docs requires -- 49 of them, written rather than suppressed. The four macro range sliders (kcal/protein/carbs/fat) were four near-identical 30-line blocks differing only in label, key, unit and which HistoryFilter pair they read/write. They are now one FilterRangeRow taking min/max plus an onChanged, which is most of how the sheet got under the cap. The test file mirrors the source split one-for-one, six files under test/screens/history/. Its setUp/tearDown and the settle() helper (needed by every part, because HistoryScreen loads via an untracked initState future) moved to history_test_support.dart rather than being copy-pasted six times. Verified: 555 flutter tests (same count as before -- no test lost in the split), flutter analyze clean over all of lib/ and test/, 701 python tests at 100% branch coverage, `python -m diet_guard sync` -> 402 entries/exit 0, live ~/.local/share/diet_guard byte-identical. Over-cap files: 42 -> 40. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC --- app/lib/screens/history_screen.dart | 871 +------------- app/lib/widgets/history/history_filter.dart | 102 ++ .../history/history_filter_fields.dart | 168 +++ .../widgets/history/history_filter_sheet.dart | 242 ++++ .../widgets/history/history_filter_sort.dart | 140 +++ app/lib/widgets/history/history_grouping.dart | 206 ++++ app/lib/widgets/history/history_list.dart | 194 ++++ app/pubspec.yaml | 2 +- .../history/history_filter_criteria_test.dart | 222 ++++ .../history/history_filter_range_test.dart | 44 + .../history/history_filter_sheet_test.dart | 191 ++++ .../history/history_filter_sort_test.dart | 203 ++++ .../history/history_filter_source_test.dart | 196 ++++ .../screens/history/history_screen_test.dart | 191 ++++ .../screens/history/history_test_support.dart | 43 + app/test/screens/history_screen_test.dart | 1017 ----------------- 16 files changed, 2158 insertions(+), 1874 deletions(-) create mode 100644 app/lib/widgets/history/history_filter.dart create mode 100644 app/lib/widgets/history/history_filter_fields.dart create mode 100644 app/lib/widgets/history/history_filter_sheet.dart create mode 100644 app/lib/widgets/history/history_filter_sort.dart create mode 100644 app/lib/widgets/history/history_grouping.dart create mode 100644 app/lib/widgets/history/history_list.dart create mode 100644 app/test/screens/history/history_filter_criteria_test.dart create mode 100644 app/test/screens/history/history_filter_range_test.dart create mode 100644 app/test/screens/history/history_filter_sheet_test.dart create mode 100644 app/test/screens/history/history_filter_sort_test.dart create mode 100644 app/test/screens/history/history_filter_source_test.dart create mode 100644 app/test/screens/history/history_screen_test.dart create mode 100644 app/test/screens/history/history_test_support.dart delete mode 100644 app/test/screens/history_screen_test.dart diff --git a/app/lib/screens/history_screen.dart b/app/lib/screens/history_screen.dart index 819bc7e..38ea96c 100644 --- a/app/lib/screens/history_screen.dart +++ b/app/lib/screens/history_screen.dart @@ -1,276 +1,26 @@ /// Logged meal history with day grouping, filtering, and sorting. +/// +/// The filter model, the grouped list and the filter sheet live in +/// `lib/widgets/history/` (split for the repo's 250-line cap); this file is the +/// screen that wires them together and owns the filter state. library; import 'dart:async'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/edit_entry_screen.dart'; -import 'package:diet_guard_app/services/budget_history_service.dart'; -import 'package:diet_guard_app/services/day_status_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/widgets/history/history_filter.dart'; +import 'package:diet_guard_app/widgets/history/history_filter_sheet.dart'; +import 'package:diet_guard_app/widgets/history/history_grouping.dart'; +import 'package:diet_guard_app/widgets/history/history_list.dart'; import 'package:flutter/material.dart'; -// --------------------------------------------------------------------------- -// 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.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, 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 || - 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. +/// Logged meal history: every entry, grouped by day, filterable and sortable. /// -/// 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.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 = sumKcal(dayEntries); - 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. +/// Owns the live [HistoryFilter] and sort order; the sheet that edits them and +/// the list that renders the result are separate widgets under +/// `lib/widgets/history/`. class HistoryScreen extends StatefulWidget { /// Creates a [HistoryScreen]. /// @@ -368,7 +118,7 @@ class _HistoryScreenState extends State { isScrollControlled: true, useSafeArea: true, builder: (ctx) => StatefulBuilder( - builder: (ctx, setSheet) => _FilterSheet( + builder: (ctx, setSheet) => FilterSheet( filter: draft, sortField: draftSortField, ascending: draftSortAscending, @@ -447,8 +197,8 @@ class _HistoryScreenState extends State { : 'No entries match the current filter.', ), ) - : _GroupedList( - items: _buildGroupedItems(_displayed), + : GroupedList( + items: buildGroupedItems(_displayed), onDeleteEntry: _load, onEditEntry: _onEditEntry, ), @@ -459,594 +209,3 @@ class _HistoryScreenState extends State { // --------------------------------------------------------------------------- // 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; - // The budget that applied on *that* day, not today's -- a later budget - // change must not repaint months of history red. - final goal = BudgetHistoryService.schedule.forDay(header.dateKey); - 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) { - final canDelete = entry.id != null; - return ListTile( - leading: const Icon(Icons.restaurant), - title: Text(entry.desc), - subtitle: Text('${entry.time} • ${entry.source}'), - // Delete needs a focusable control, not just a long-press. Long-press has - // no keyboard equivalent at all, so on the desktop build (a Chrome --app - // window) deleting an entry was pointer-only: unreachable by keyboard and - // invisible to a screen reader. The trailing row keeps the kcal figure - // and adds an IconButton, which Tab reaches and Enter/Space activates. - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('${entry.kcal.toStringAsFixed(0)} kcal'), - if (canDelete) - IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: 'Delete entry', - onPressed: () => _confirmDelete(context), - ), - ], - ), - // 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(), - // Retained as a redundant pointer shortcut, never the only route. - onLongPress: canDelete ? () => _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(); - } - } -} - -// --------------------------------------------------------------------------- -// 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 - _NameSearchField( - initialQuery: filter.nameQuery, - 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), - ], - - // 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']) - 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. -/// The filter sheet's name-search field, owning a persistent controller. -/// -/// Stateful on purpose. The controller used to be built inside -/// `_FilterSheet.build` with the caret forced to the end of the text, and -/// `onChanged` rebuilds the sheet — so every keystroke discarded the controller -/// and re-slammed the caret to end-of-text. Appending worked, but Home/End and -/// arrow-key editing in the middle of the query were silently undone on each -/// character. Keeping the controller in State fixes that without changing how -/// the filter is plumbed. -class _NameSearchField extends StatefulWidget { - const _NameSearchField({required this.initialQuery, required this.onChanged}); - - /// Query text to seed the field with on first build only. - final String initialQuery; - - /// Called on each edit with the new query. - final ValueChanged onChanged; - - @override - State<_NameSearchField> createState() => _NameSearchFieldState(); -} - -class _NameSearchFieldState extends State<_NameSearchField> { - late final TextEditingController _controller = TextEditingController( - text: widget.initialQuery, - ); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return TextField( - controller: _controller, - decoration: const InputDecoration( - labelText: 'Search by name', - prefixIcon: Icon(Icons.search), - isDense: true, - ), - onChanged: widget.onChanged, - ); - } -} - -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/widgets/history/history_filter.dart b/app/lib/widgets/history/history_filter.dart new file mode 100644 index 0000000..a938aee --- /dev/null +++ b/app/lib/widgets/history/history_filter.dart @@ -0,0 +1,102 @@ +/// Filter, sort and grouping model for the history screen. +/// +/// Split out of `history_screen.dart` to hold the repo's 250-line cap. This is +/// the pure-data half -- no widgets -- so the sheet, the list and the screen +/// can each depend on it without depending on one another. +library; + +import 'package:flutter/material.dart'; + +// --------------------------------------------------------------------------- +// 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.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, 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 || + source != null; +} + +// --------------------------------------------------------------------------- +// List item sealed hierarchy for day-grouped rendering +// --------------------------------------------------------------------------- diff --git a/app/lib/widgets/history/history_filter_fields.dart b/app/lib/widgets/history/history_filter_fields.dart new file mode 100644 index 0000000..7d27a63 --- /dev/null +++ b/app/lib/widgets/history/history_filter_fields.dart @@ -0,0 +1,168 @@ +/// Small input widgets used by the history filter sheet. +/// +/// Split from `history_filter_sheet.dart` for the repo's 250-line cap. +library; + +import 'package:flutter/material.dart'; + +/// The free-text "search by name" field in the filter sheet. +/// +/// Stateful only to own its [TextEditingController]; the query itself lives in +/// the screen's [HistoryFilter]. +class NameSearchField extends StatefulWidget { + /// Creates a [NameSearchField] seeded with [initialQuery]. + const NameSearchField({ + required this.initialQuery, + required this.onChanged, + super.key, + }); + + /// Query text to seed the field with on first build only. + final String initialQuery; + + /// Called on each edit with the new query. + final ValueChanged onChanged; + + @override + State createState() => NameSearchFieldState(); +} + +/// State for [NameSearchField], holding its text controller. +class NameSearchFieldState extends State { + late final TextEditingController _controller = TextEditingController( + text: widget.initialQuery, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + decoration: const InputDecoration( + labelText: 'Search by name', + prefixIcon: Icon(Icons.search), + isDense: true, + ), + onChanged: widget.onChanged, + ); + } +} + +/// The min/max captions printed under a range slider's two ends. +class SliderEndpointLabels extends StatelessWidget { + /// Creates endpoint labels reading [lo] on the left and [hi] on the right. + const SliderEndpointLabels({required this.lo, required this.hi, super.key}); + + /// Caption for the slider's lower bound. + final String lo; + + /// Caption for the slider's upper bound. + 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 { + /// Creates a centred [label] for the current slider selection. + const SliderSelectedLabel(this.label, {super.key}); + + /// The formatted selection text, e.g. `120 – 640 kcal`. + 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, + ), + ), + ); + } +} + +/// One labelled min/max [RangeSlider] row in the filter sheet. +/// +/// The four macro ranges (kcal, protein, carbs, fat) were four near-identical +/// 30-line blocks differing only in label, key, unit suffix and which pair of +/// [HistoryFilter] fields they read and write. Reading and writing go through +/// [min]/[max] and [onChanged] so this widget never needs to know which pair. +class FilterRangeRow extends StatelessWidget { + /// Creates a range row bounded by [maxValue]. + const FilterRangeRow({ + required this.label, + required this.sliderKey, + required this.maxValue, + required this.min, + required this.max, + required this.onChanged, + this.unit = '', + super.key, + }); + + /// Section heading, e.g. `Protein range (g)`. + final String label; + + /// Widget key on the slider itself, so tests can drive one row. + final Key sliderKey; + + /// Upper bound, derived from the logged data. A value of 0 hides the row. + final double maxValue; + + /// Current lower bound, or null when unset (meaning 0). + final double? min; + + /// Current upper bound, or null when unset (meaning [maxValue]). + final double? max; + + /// Called with the new bounds; null means "no bound", not zero. + final void Function(double? min, double? max) onChanged; + + /// Suffix appended to the endpoint and selection labels, e.g. `g`. + final String unit; + + @override + Widget build(BuildContext context) { + if (maxValue <= 0) return const SizedBox.shrink(); + final lo = min ?? 0; + final hi = max ?? maxValue; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(label, style: Theme.of(context).textTheme.labelLarge), + SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'), + RangeSlider( + key: sliderKey, + max: maxValue, + values: RangeValues(lo, hi), + labels: RangeLabels(lo.toStringAsFixed(0), hi.toStringAsFixed(0)), + onChanged: (v) => onChanged( + v.start > 0 ? v.start : null, + v.end < maxValue ? v.end : null, + ), + ), + SliderSelectedLabel('${lo.round()} – ${hi.round()}$unit'), + const SizedBox(height: 8), + ], + ); + } +} diff --git a/app/lib/widgets/history/history_filter_sheet.dart b/app/lib/widgets/history/history_filter_sheet.dart new file mode 100644 index 0000000..635c7e2 --- /dev/null +++ b/app/lib/widgets/history/history_filter_sheet.dart @@ -0,0 +1,242 @@ +/// The bottom sheet that edits a [HistoryFilter]. +/// +/// Split out of `history_screen.dart` to hold the repo's 250-line cap. +library; + +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'; + +/// The draggable bottom sheet that edits the history filter and sort order. +/// +/// Stateless on purpose: the live [HistoryFilter] belongs to the screen, and +/// every edit goes back out through [onFilterChanged] rather than being held +/// here, so "apply" and "clear" cannot disagree with what is displayed. +class FilterSheet extends StatelessWidget { + /// Creates a [FilterSheet] over the screen's current filter and sort state. + 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, + super.key, + }); + + /// The filter criteria currently being edited. + final HistoryFilter filter; + + /// The field the list is sorted by. + final HistorySortField sortField; + + /// Whether the sort runs ascending. + final bool ascending; + + /// Upper bound of the calories slider, from the logged data. + final double maxKcal; + + /// Upper bound of the protein slider, from the logged data. + final double maxProtein; + + /// Upper bound of the carbohydrate slider, from the logged data. + final double maxCarbs; + + /// Upper bound of the fat slider, from the logged data. + final double maxFat; + + /// Called with a new filter on every edit. + final void Function(HistoryFilter) onFilterChanged; + + /// Called when the sort field or direction changes. + final void Function({required HistorySortField 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.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 + NameSearchField( + initialQuery: filter.nameQuery, + 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), + + FilterRangeRow( + label: 'Kcal range', + sliderKey: const Key('kcal-range-slider'), + maxValue: maxKcal, + min: filter.minKcal, + max: filter.maxKcal, + onChanged: (lo, hi) { + filter.minKcal = lo; + filter.maxKcal = hi; + onFilterChanged(filter); + }, + ), + FilterRangeRow( + label: 'Protein range (g)', + sliderKey: const Key('protein-range-slider'), + maxValue: maxProtein, + min: filter.minProtein, + max: filter.maxProtein, + unit: 'g', + onChanged: (lo, hi) { + filter.minProtein = lo; + filter.maxProtein = hi; + onFilterChanged(filter); + }, + ), + FilterRangeRow( + label: 'Carbs range (g)', + sliderKey: const Key('carbs-range-slider'), + maxValue: maxCarbs, + min: filter.minCarbs, + max: filter.maxCarbs, + unit: 'g', + onChanged: (lo, hi) { + filter.minCarbs = lo; + filter.maxCarbs = hi; + onFilterChanged(filter); + }, + ), + FilterRangeRow( + label: 'Fat range (g)', + sliderKey: const Key('fat-range-slider'), + maxValue: maxFat, + min: filter.minFat, + max: filter.maxFat, + unit: 'g', + onChanged: (lo, hi) { + filter.minFat = lo; + filter.maxFat = hi; + onFilterChanged(filter); + }, + ), + + FilterSourceChips( + selected: filter.source, + onSelected: (src) { + filter.source = src; + onFilterChanged(filter); + }, + ), + const SizedBox(height: 16), + FilterSortControls( + sortField: sortField, + ascending: ascending, + onSortChanged: onSortChanged, + ), + ], + ), + ), + ), + 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. +/// The filter sheet's name-search field, owning a persistent controller. +/// +/// Stateful on purpose. The controller used to be built inside +/// `FilterSheet.build` with the caret forced to the end of the text, and +/// `onChanged` rebuilds the sheet — so every keystroke discarded the controller +/// and re-slammed the caret to end-of-text. Appending worked, but Home/End and +/// arrow-key editing in the middle of the query were silently undone on each +/// character. Keeping the controller in State fixes that without changing how +/// the filter is plumbed. diff --git a/app/lib/widgets/history/history_filter_sort.dart b/app/lib/widgets/history/history_filter_sort.dart new file mode 100644 index 0000000..cbee279 --- /dev/null +++ b/app/lib/widgets/history/history_filter_sort.dart @@ -0,0 +1,140 @@ +/// The source-chip and sort controls at the foot of the history filter sheet. +/// +/// Split from `history_filter_sheet.dart` for the repo's 250-line cap. Both are +/// stateless and report upward: the live filter and sort order belong to the +/// screen, so nothing here holds state that could disagree with what is shown. +library; + +import 'package:diet_guard_app/widgets/history/history_filter.dart'; +import 'package:flutter/material.dart'; + +/// The known `source` values an entry can carry, in display order. +/// +/// Kept next to the chips that render them rather than in the filter model: +/// this is a UI affordance (which sources are worth offering as one tap), not +/// a constraint on what `source` may contain. +const _sources = ['manual', 'food bank']; + +/// Single-select chips filtering by entry source, with an "All" reset. +class FilterSourceChips extends StatelessWidget { + /// Creates source chips with [selected] currently active. + const FilterSourceChips({ + required this.selected, + required this.onSelected, + super.key, + }); + + /// The active source, or null for "All". + final String? selected; + + /// Called with the chosen source, or null when "All" is tapped. + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Source', style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: [ + FilterChip( + label: const Text('All'), + selected: selected == null, + onSelected: (_) => onSelected(null), + ), + for (final src in _sources) + FilterChip( + label: Text(src), + selected: selected == src, + onSelected: (_) => onSelected(src), + ), + ], + ), + ], + ); + } +} + +/// The sort-field dropdown plus its ascending/descending toggle. +class FilterSortControls extends StatelessWidget { + /// Creates sort controls reflecting [sortField] and [ascending]. + const FilterSortControls({ + required this.sortField, + required this.ascending, + required this.onSortChanged, + super.key, + }); + + /// The field the list is currently sorted by. + final HistorySortField sortField; + + /// Whether the sort currently runs ascending. + final bool ascending; + + /// Called when either the field or the direction changes. + final void Function({required HistorySortField field, required bool asc}) + onSortChanged; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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), + ), + ], + ), + ], + ); + } +} diff --git a/app/lib/widgets/history/history_grouping.dart b/app/lib/widgets/history/history_grouping.dart new file mode 100644 index 0000000..7d8220b --- /dev/null +++ b/app/lib/widgets/history/history_grouping.dart @@ -0,0 +1,206 @@ +/// Grouping, filtering and day-label helpers for the history list. +/// +/// Split from `history_filter.dart` for the repo's 250-line cap: that file is +/// the filter *criteria*, this one is what those criteria do to a list. +library; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/services/day_status_service.dart'; +import 'package:diet_guard_app/widgets/history/history_filter.dart'; +import 'package:flutter/material.dart'; + +/// One row in the flattened history list: either a [DayHeader] or an +/// [EntryRow]. Sealed so the list's `switch` stays exhaustive. +sealed class HistoryItem {} + +/// A day's summary row, shown above that day's entries. +final class DayHeader extends HistoryItem { + /// Creates a [DayHeader] with a day's pre-computed totals. + DayHeader( + this.dateKey, + this.totalKcal, + this.entryCount, + this.totalProtein, + this.totalCarbs, + this.totalFat, + ); + + /// The `YYYY-MM-DD` key this header groups. + final String dateKey; + + /// Calories logged across the whole day. + final double totalKcal; + + /// How many entries the day holds. + final int entryCount; + + /// Protein grams logged across the whole day. + final double totalProtein; + + /// Carbohydrate grams logged across the whole day. + final double totalCarbs; + + /// Fat grams logged across the whole day. + final double totalFat; +} + +/// A single logged meal within a day's group. +final class EntryRow extends HistoryItem { + /// Creates an [EntryRow] wrapping [entry]. + EntryRow(this.entry); + + /// The entry this row renders. + final FoodEntry entry; +} + +// --------------------------------------------------------------------------- +// Pure filter / sort / group helpers +// --------------------------------------------------------------------------- + +/// Applies [filter] and sort criteria to [entries] and returns the result. +/// +/// 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.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; +} + +/// Flatten [entries] into day headers followed by that day's rows. +/// +/// Each day's totals are summed once here rather than per-rebuild in the list. +List 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 = []; + for (final day in days) { + final dayEntries = byDay[day]!; + final totalKcal = sumKcal(dayEntries); + 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; +} + +/// Render a picked date range as `YYYY-MM-DD – YYYY-MM-DD` for the filter chip. +String dateRangeLabel(DateTimeRange r) => + '${r.start.toString().substring(0, 10)}' + ' – ${r.end.toString().substring(0, 10)}'; + +/// Render a `YYYY-MM-DD` key as a short `Wed 12 Aug`-style label. +/// +/// Falls back to the raw key when it cannot be parsed, so a malformed date +/// shows something rather than throwing inside a list build. +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. diff --git a/app/lib/widgets/history/history_list.dart b/app/lib/widgets/history/history_list.dart new file mode 100644 index 0000000..0accee6 --- /dev/null +++ b/app/lib/widgets/history/history_list.dart @@ -0,0 +1,194 @@ +/// The grouped day/entry list rendered by the history screen. +/// +/// Split out of `history_screen.dart` to hold the repo's 250-line cap. +library; + +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/widgets/history/history_grouping.dart'; +import 'package:flutter/material.dart'; + +/// The history list: a flat [ListView] over pre-grouped day headers and rows. +/// +/// Grouping happens in [buildGroupedItems] rather than here, so the list stays +/// a dumb renderer of whatever [HistoryItem] sequence it is handed. +class GroupedList extends StatelessWidget { + /// Creates a [GroupedList] over already-grouped [items]. + const GroupedList({ + required this.items, + required this.onDeleteEntry, + required this.onEditEntry, + super.key, + }); + + /// Day headers and entry rows, in display order. + final List items; + + /// Called after a confirmed delete so the parent can reload. + final Future Function() onDeleteEntry; + + /// Called with the entry to edit when a row is tapped. + 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), + ), + }; + }, + ); + } +} + +/// The sticky-looking header that starts each day's group of entries. +class DayHeaderTile extends StatelessWidget { + /// Creates a [DayHeaderTile] for [header]. + const DayHeaderTile(this.header, {super.key}); + + /// The day this header summarises (date key, totals, budget for that day). + final DayHeader header; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // The budget that applied on *that* day, not today's -- a later budget + // change must not repaint months of history red. + final goal = BudgetHistoryService.schedule.forDay(header.dateKey); + 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, + ), + ), + ], + ), + ], + ), + ); + } +} + +/// One logged meal: description, macros, and swipe/tap actions. +class EntryTile extends StatelessWidget { + /// Creates an [EntryTile] for [entry]. + const EntryTile(this.entry, {super.key, this.onDelete, this.onEdit}); + + /// The entry this tile renders. + 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) { + final canDelete = entry.id != null; + return ListTile( + leading: const Icon(Icons.restaurant), + title: Text(entry.desc), + subtitle: Text('${entry.time} • ${entry.source}'), + // Delete needs a focusable control, not just a long-press. Long-press has + // no keyboard equivalent at all, so on the desktop build (a Chrome --app + // window) deleting an entry was pointer-only: unreachable by keyboard and + // invisible to a screen reader. The trailing row keeps the kcal figure + // and adds an IconButton, which Tab reaches and Enter/Space activates. + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('${entry.kcal.toStringAsFixed(0)} kcal'), + if (canDelete) + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete entry', + onPressed: () => _confirmDelete(context), + ), + ], + ), + // 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(), + // Retained as a redundant pointer shortcut, never the only route. + onLongPress: canDelete ? () => _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(); + } + } +} + +// --------------------------------------------------------------------------- +// Filter sheet +// --------------------------------------------------------------------------- diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ca28145..729bfab 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.16+1 +version: 1.0.17+1 environment: sdk: ^3.12.2 diff --git a/app/test/screens/history/history_filter_criteria_test.dart b/app/test/screens/history/history_filter_criteria_test.dart new file mode 100644 index 0000000..68213d6 --- /dev/null +++ b/app/test/screens/history/history_filter_criteria_test.dart @@ -0,0 +1,222 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/widgets/history/history_filter.dart'; +import 'package:diet_guard_app/widgets/history/history_grouping.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'history_test_support.dart'; + +void main() { + useTempHistoryStores(); + group('applyHistoryFilter', () { + final entries = [ + const FoodEntry( + id: 'a', + time: '2026-06-20T08:00:00+02:00', + desc: 'Apple', + grams: 100, + kcal: 80, + proteinG: 0.5, + carbsG: 20, + fatG: 0.3, + source: 'manual', + ), + const FoodEntry( + id: 'b', + time: '2026-06-21T12:00:00+02:00', + desc: 'Banana smoothie', + grams: 250, + kcal: 200, + proteinG: 3, + carbsG: 40, + fatG: 1, + source: 'food bank', + ), + const FoodEntry( + id: 'c', + time: '2026-06-22T20:00:00+02:00', + desc: 'Chicken breast', + grams: 150, + kcal: 230, + proteinG: 45, + carbsG: 0, + fatG: 5, + source: 'meal', + ), + ]; + + test('no filter returns all entries sorted by date descending', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(), + HistorySortField.date, + ascending: false, + ); + expect(result.map((e) => e.id), ['c', 'b', 'a']); + }); + + test('nameQuery filters by case-insensitive substring', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(nameQuery: 'an'), + HistorySortField.date, + ascending: false, + ); + expect(result.map((e) => e.id), ['b']); // 'Banana smoothie' + }); + + test('minKcal and maxKcal filter by kcal range', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(minKcal: 100, maxKcal: 210), + HistorySortField.date, + ascending: false, + ); + expect(result.map((e) => e.id), ['b']); // 200 kcal + }); + + test('minProtein filters by protein', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(minProtein: 10), + HistorySortField.date, + ascending: false, + ); + expect(result.map((e) => e.id), ['c']); // 45g protein + }); + + test('maxCarbs filters by carbs', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(maxCarbs: 5), + HistorySortField.date, + ascending: false, + ); + expect(result.map((e) => e.id), ['c']); // 0 carbs + }); + + test('minFat and maxFat filter by fat', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(minFat: 0.4, maxFat: 2), + HistorySortField.date, + ascending: false, + ); + expect(result.map((e) => e.id), ['b']); // fat=1 + }); + + test('source filter keeps only matching source', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(source: 'meal'), + HistorySortField.date, + ascending: false, + ); + expect(result.map((e) => e.id), ['c']); + }); + + test('dateRange filter includes only entries within range', () { + final result = applyHistoryFilter( + entries, + HistoryFilter( + dateRange: DateTimeRange( + start: DateTime(2026, 6, 21), + end: DateTime(2026, 6, 21), + ), + ), + HistorySortField.date, + ascending: false, + ); + expect(result.map((e) => e.id), ['b']); + }); + + test('sort ascending by kcal', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(), + HistorySortField.kcal, + ascending: true, + ); + expect(result.map((e) => e.id), ['a', 'b', 'c']); + }); + + test('sort descending by protein', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(), + HistorySortField.protein, + ascending: false, + ); + expect(result.first.id, 'c'); // 45g + }); + + test('sort by description ascending', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(), + HistorySortField.description, + ascending: true, + ); + // Apple, Banana smoothie, Chicken breast + expect(result.map((e) => e.id), ['a', 'b', 'c']); + }); + + test('sort by fat', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(), + HistorySortField.fat, + ascending: true, + ); + expect(result.first.id, 'a'); // fat=0.3 + }); + + test('sort by carbs descending', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(), + HistorySortField.carbs, + ascending: false, + ); + expect(result.first.id, 'b'); // 40g carbs + }); + + test('HistoryFilter.isActive is false when nothing is set', () { + expect(HistoryFilter().isActive, isFalse); + }); + + test('HistoryFilter.isActive is true when nameQuery is set', () { + expect(HistoryFilter(nameQuery: 'x').isActive, isTrue); + }); + + test('HistoryFilter.isActive is true when source is set', () { + expect(HistoryFilter(source: 'manual').isActive, isTrue); + }); + + test('maxProtein filters by protein', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(maxProtein: 5), + HistorySortField.date, + ascending: false, + ); + // Apple (0.5 g) and Banana (3 g) have protein ≤ 5 g. + expect(result.map((e) => e.id), ['b', 'a']); + }); + + test('minCarbs filters by carbs', () { + final result = applyHistoryFilter( + entries, + HistoryFilter(minCarbs: 15), + HistorySortField.date, + ascending: false, + ); + // Banana (40 g) and Apple (20 g) have carbs ≥ 15 g. + expect(result.map((e) => e.id), ['b', 'a']); + }); + }); + + // --------------------------------------------------------------------------- + // Widget-level — day grouping and filter badge + // --------------------------------------------------------------------------- +} diff --git a/app/test/screens/history/history_filter_range_test.dart b/app/test/screens/history/history_filter_range_test.dart new file mode 100644 index 0000000..6ff4a45 --- /dev/null +++ b/app/test/screens/history/history_filter_range_test.dart @@ -0,0 +1,44 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'history_test_support.dart'; + +void main() { + useTempHistoryStores(); + testWidgets( + '_formatDay falls back to raw key for an unparsable date (line 245)', + (tester) async { + await tester.runAsync(() async { + // The day key is e.time.substring(0, 10). Writing an entry whose + // `time` field can't be parsed by DateTime.parse exercises the + // `on Exception` fallback in _formatDay (line 245), which returns the + // raw key unchanged. 'NOT-A-DATE' is exactly 10 chars so substring + // doesn't truncate it. + await LogStorageService.instance.writeLog({ + 'NOT-A-DATE': [ + const FoodEntry( + id: 'bad1', + time: 'NOT-A-DATE', + desc: 'bad date entry', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + // The raw key is shown as the day header when formatting fails. + expect(find.text('NOT-A-DATE'), findsOneWidget); + }); + }, + ); +} diff --git a/app/test/screens/history/history_filter_sheet_test.dart b/app/test/screens/history/history_filter_sheet_test.dart new file mode 100644 index 0000000..b1d8ad5 --- /dev/null +++ b/app/test/screens/history/history_filter_sheet_test.dart @@ -0,0 +1,191 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'history_test_support.dart'; + +void main() { + useTempHistoryStores(); + testWidgets('filter sheet Clear all resets draft then Apply shows all', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [ + const FoodEntry( + id: 'a', + time: '2026-06-22T08:00:00+02:00', + desc: 'toast', + grams: 100, + kcal: 200, + proteinG: 7, + carbsG: 35, + fatG: 3, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + 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('toast'), findsOneWidget); + }); + }); + + testWidgets('filter sheet sort direction toggle fires onSortChanged', ( + tester, + ) async { + await tester.runAsync(() async { + // Zero macros: no RangeSliders render, so the sort section is + // immediately visible. + await LogStorageService.instance.writeLog({ + '2026-06-20': [ + const FoodEntry( + id: 'sd1', + time: '2026-06-20T09:00:00+02:00', + desc: 'porridge', + grams: 200, + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + expect(find.text('Filter & Sort'), findsOneWidget); + + // The sort section is ~8px below the fold even with zero macros — scroll + // it into view before tapping the direction button. + await tester.drag(find.byType(ListView).last, const Offset(0, -120)); + await settle(tester); + + // Default sort is date-descending; 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('Filter & Sort'), findsNothing); + expect(find.textContaining('porridge'), findsOneWidget); + }); + }); + + testWidgets('filter sheet sort field dropdown changes sort field', ( + tester, + ) async { + await tester.runAsync(() async { + // Zero macros: no RangeSliders render, so the sort section is + // immediately visible. + await LogStorageService.instance.writeLog({ + '2026-06-21': [ + const FoodEntry( + id: 'sf1', + time: '2026-06-21T12:00:00+02:00', + desc: 'chicken', + grams: 150, + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + // Scroll just enough to make the sort section visible. + await tester.drag(find.byType(ListView).last, const Offset(0, -120)); + await settle(tester); + + // Open the sort dropdown (shows 'Date' by default). + await tester.tap(find.text('Date')); + await settle(tester); + + // Select 'Kcal' from the dropdown overlay. + await tester.tap(find.text('Kcal').last); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + expect(find.text('Filter & Sort'), findsNothing); + expect(find.textContaining('chicken'), findsOneWidget); + }); + }); + + testWidgets('filter sheet source chip filters by source', (tester) async { + await tester.runAsync(() async { + // Zero macros: no sliders, source chips appear right after date button. + await LogStorageService.instance.writeLog({ + '2026-06-23': [ + const FoodEntry( + id: 'src1', + time: '2026-06-23T08:00:00+02:00', + desc: 'manual meal', + grams: 100, + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + source: 'manual', + ), + const FoodEntry( + id: 'src2', + time: '2026-06-23T12:00:00+02:00', + desc: 'bank meal', + grams: 100, + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + source: 'food bank', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + expect(find.text('Filter & Sort'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilterChip, 'manual')); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + expect(find.text('Filter & Sort'), findsNothing); + expect(find.textContaining('manual meal'), findsOneWidget); + expect(find.textContaining('bank meal'), findsNothing); + }); + }); +} diff --git a/app/test/screens/history/history_filter_sort_test.dart b/app/test/screens/history/history_filter_sort_test.dart new file mode 100644 index 0000000..676fe00 --- /dev/null +++ b/app/test/screens/history/history_filter_sort_test.dart @@ -0,0 +1,203 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'history_test_support.dart'; + +void main() { + useTempHistoryStores(); + testWidgets('shows day headers with date and total kcal', (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [ + const FoodEntry( + id: 'a', + time: '2026-06-22T08:00:00+02:00', + desc: 'breakfast', + grams: 100, + kcal: 300, + proteinG: 10, + carbsG: 40, + fatG: 5, + source: 'manual', + ), + const FoodEntry( + id: 'b', + time: '2026-06-22T12:00:00+02:00', + desc: 'lunch', + grams: 200, + kcal: 500, + proteinG: 20, + carbsG: 60, + fatG: 10, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + // Day header shows 800 kcal total (300 + 500) vs the 2200 goal. + expect(find.textContaining('800 / 2200 kcal'), findsOneWidget); + // Both entries appear as list tiles. + expect(find.text('breakfast'), findsOneWidget); + expect(find.text('lunch'), findsOneWidget); + }); + }); + + testWidgets('filter icon badge appears when filter is active', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [ + const FoodEntry( + id: 'x', + time: '2026-06-22T08:00:00+02:00', + desc: 'oat', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 15, + fatG: 2, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + // No filter active — the dot Container is absent. + // We look for a small Container widget in the Stack above the + // filter icon. + expect( + find.byWidgetPredicate((w) { + if (w is Container) { + final d = w.decoration; + if (d is BoxDecoration) { + return d.shape == BoxShape.circle && d.color != null; + } + } + return false; + }), + findsNothing, + ); + }); + }); + + testWidgets('shows "no entries match" when filter eliminates all results', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [ + const FoodEntry( + id: 'x', + time: '2026-06-22T08:00:00+02:00', + desc: 'oat', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 15, + fatG: 2, + source: 'manual', + ), + ], + }); + + // Build a custom wrapper that injects a filter through the state. + // Easiest: extend HistoryScreen is not possible (private state), so we + // test via the pure `applyHistoryFilter` function instead, which is + // already covered above. This test verifies the "no match" empty-state + // message path through the widget. + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + // Verify the normal path renders the entry. + expect(find.text('oat'), findsOneWidget); + }); + }); + + testWidgets('filter sheet opens and renders search field', (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [ + const FoodEntry( + id: 'a', + time: '2026-06-22T08:00:00+02:00', + desc: 'oat', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 15, + fatG: 2, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + expect(find.text('Filter & Sort'), findsOneWidget); + }); + }); + + testWidgets('filter sheet Apply filters results and closes sheet', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [ + const FoodEntry( + id: 'a', + time: '2026-06-22T08:00:00+02:00', + desc: 'oat porridge', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 15, + fatG: 2, + source: 'manual', + ), + const FoodEntry( + id: 'b', + time: '2026-06-22T12:00:00+02:00', + desc: 'chicken breast', + grams: 150, + kcal: 250, + proteinG: 40, + carbsG: 0, + fatG: 5, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + // Type in the search field (first TextField in the sheet). + 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 porridge'), findsOneWidget); + expect(find.text('chicken breast'), findsNothing); + }); + }); +} diff --git a/app/test/screens/history/history_filter_source_test.dart b/app/test/screens/history/history_filter_source_test.dart new file mode 100644 index 0000000..8618c53 --- /dev/null +++ b/app/test/screens/history/history_filter_source_test.dart @@ -0,0 +1,196 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'history_test_support.dart'; + +void main() { + useTempHistoryStores(); + testWidgets('filter sheet source All chip fires onSelected callback', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-25': [ + const FoodEntry( + id: 'sa1', + time: '2026-06-25T08:00:00+02:00', + desc: 'all source entry', + grams: 100, + kcal: 0, + proteinG: 0, + carbsG: 0, + fatG: 0, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + // Tap 'manual' to set a source filter, then 'All' to reset it. + // Tapping 'All' when source is not null covers lines 798-800. + await tester.tap(find.widgetWithText(FilterChip, 'manual')); + await settle(tester); + + await tester.tap(find.widgetWithText(FilterChip, 'All')); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + expect(find.text('Filter & Sort'), findsNothing); + expect(find.textContaining('all source entry'), 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 LogStorageService.instance.writeLog({ + '2026-06-26': [ + const FoodEntry( + id: 'rs1', + time: '2026-06-26T08:00:00+02:00', + desc: 'slider test entry', + grams: 100, + kcal: 300, + proteinG: 20, + carbsG: 40, + fatG: 10, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + // tester.drag(finder, offset) fails for RangeSliders inside a modal + // overlay because its internal _maybeViewOf ancestor search cannot find + // a View ancestor through the overlay's render subtree. Use + // getRect()+dragFrom() instead (resolves via renderObjectOf, no + // _maybeViewOf call). + // + // The filter sheet uses SingleChildScrollView+Column, so all four + // sliders are always in the widget tree. ensureVisible() scrolls each + // one into the viewport before getRect() is called. + + // Kcal slider. + await tester.ensureVisible(find.byKey(const Key('kcal-range-slider'))); + await settle(tester); + await tester.dragFrom( + tester.getRect(find.byKey(const Key('kcal-range-slider'))).center, + const Offset(-30, 0), + ); + await settle(tester); + + // Protein slider. + await tester.ensureVisible( + find.byKey(const Key('protein-range-slider')), + ); + await settle(tester); + await tester.dragFrom( + tester.getRect(find.byKey(const Key('protein-range-slider'))).center, + const Offset(-30, 0), + ); + await settle(tester); + + // Carbs slider. + await tester.ensureVisible(find.byKey(const Key('carbs-range-slider'))); + await settle(tester); + await tester.dragFrom( + tester.getRect(find.byKey(const Key('carbs-range-slider'))).center, + const Offset(-30, 0), + ); + await settle(tester); + + // Fat slider. + await tester.ensureVisible(find.byKey(const Key('fat-range-slider'))); + await settle(tester); + await tester.dragFrom( + tester.getRect(find.byKey(const Key('fat-range-slider'))).center, + const Offset(-30, 0), + ); + await settle(tester); + + await tester.tap(find.text('Apply')); + await settle(tester); + + expect(find.text('Filter & Sort'), findsNothing); + }); + }); + + testWidgets( + 'date range picker selection shows _dateRangeLabel and Clear button ' + '(lines 232-234, 639-642)', + (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-26': [ + const FoodEntry( + id: 'dr1', + time: '2026-06-26T08:00:00+02:00', + desc: 'range test', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 5, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + await tester.tap(find.byIcon(Icons.filter_list)); + await settle(tester); + + // Open date range picker. + await tester.tap(find.widgetWithText(OutlinedButton, 'Any date')); + await settle(tester); + + // The picker opens on the current month (no `currentDate` override + // and a null `initialDateRange`) and its `lastDate` is capped at + // tomorrow, so days "10"+ aren't always selectable this early in a + // month. Days "1" and "2" of the displayed month are always within + // [firstDate, lastDate] regardless of which day it is when the test + // runs. + final now = DateTime.now(); + final expectedStart = DateTime(now.year, now.month); + await tester.tap(find.text('1')); + await settle(tester); + await tester.tap(find.text('2')); + await settle(tester); + await tester.tap(find.text('Save')); + await settle(tester); + + // After a successful selection the filter button label shows the + // formatted range via _dateRangeLabel (lines 232-234). Use a date- + // specific prefix so the kcal slider's "0 – N kcal" label is excluded. + expect( + find.textContaining(expectedStart.toString().substring(0, 10)), + findsOneWidget, + ); + + // "Clear date range" is now visible — tap it to exercise lines 639-642. + await tester.tap(find.text('Clear date range')); + await settle(tester); + expect(find.text('Any date'), findsOneWidget); + }); + }, + ); +} diff --git a/app/test/screens/history/history_screen_test.dart b/app/test/screens/history/history_screen_test.dart new file mode 100644 index 0000000..119d6ee --- /dev/null +++ b/app/test/screens/history/history_screen_test.dart @@ -0,0 +1,191 @@ +import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/history_screen.dart'; +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/budget_schedule.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'history_test_support.dart'; + +void main() { + useTempHistoryStores(); + testWidgets('a day header shows the budget that applied on that day', ( + tester, + ) async { + await tester.runAsync(() async { + // 2200 until 2026-06-15, 2000 from then on. + await BudgetHistoryService.instance.applyMerged(const [ + BudgetEntry( + effectiveFrom: kEpochDay, + kcal: 2200, + editedAt: '2026-01-01T00:00:00.000Z', + ), + BudgetEntry( + effectiveFrom: '2026-06-15', + kcal: 2000, + editedAt: '2026-06-15T00:00:00.000Z', + ), + ]); + await LogStorageService.instance.writeLog({ + '2026-06-01': [ + const FoodEntry( + id: 'before', + time: '2026-06-01T08:00:00+02:00', + desc: 'before the cut', + grams: 100, + kcal: 2100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ), + ], + '2026-06-20': [ + const FoodEntry( + id: 'after', + time: '2026-06-20T08:00:00+02:00', + desc: 'after the cut', + grams: 100, + kcal: 2100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + // Same 2100 kcal on both days, judged against different budgets -- + // lowering the budget must not repaint the earlier day. + expect(find.text('2100 / 2200 kcal'), findsOneWidget); + expect(find.text('2100 / 2000 kcal'), findsOneWidget); + }); + }); + + testWidgets('shows a message when nothing has been logged', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + expect(find.text('Nothing logged yet.'), findsOneWidget); + }); + }); + + testWidgets('lists logged entries newest first, excluding tombstones', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-01': [ + const FoodEntry( + id: 'old', + time: '2026-06-01T08:00:00+02:00', + desc: 'old breakfast', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ), + ], + '2026-06-22': [ + const FoodEntry( + id: 'new', + time: '2026-06-22T20:00:00+02:00', + desc: 'new dinner', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + source: 'manual', + ), + const FoodEntry( + id: 'gone', + time: '2026-06-22T12:00:00+02:00', + desc: 'undone lunch', + grams: 100, + kcal: 300, + proteinG: 1, + carbsG: 1, + fatG: 1, + source: 'manual', + deleted: true, + ), + ], + }); + + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + + expect(find.text('new dinner'), findsOneWidget); + expect(find.text('old breakfast'), findsOneWidget); + expect(find.text('undone lunch'), findsNothing); + + final tiles = tester.widgetList(find.byType(ListTile)).toList(); + expect((tiles[0].title! as Text).data, 'new dinner'); + expect((tiles[1].title! as Text).data, 'old breakfast'); + }); + }); + + testWidgets( + 'initialDateRange pre-filters to just that day, matching a Calendar ' + 'screen day tap', + (tester) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-01': [ + const FoodEntry( + id: 'other-day', + time: '2026-06-01T08:00:00+02:00', + desc: 'other day breakfast', + grams: 100, + kcal: 100, + proteinG: 5, + carbsG: 10, + fatG: 2, + source: 'manual', + ), + ], + '2026-06-22': [ + const FoodEntry( + id: 'target-day', + time: '2026-06-22T20:00:00+02:00', + desc: 'target day dinner', + grams: 100, + kcal: 200, + proteinG: 10, + carbsG: 20, + fatG: 4, + source: 'manual', + ), + ], + }); + + await tester.pumpWidget( + MaterialApp( + home: HistoryScreen( + initialDateRange: DateTimeRange( + start: DateTime(2026, 6, 22), + end: DateTime(2026, 6, 22), + ), + ), + ), + ); + await settle(tester); + + expect(find.text('target day dinner'), findsOneWidget); + expect(find.text('other day breakfast'), findsNothing); + }); + }, + ); + + // --------------------------------------------------------------------------- + // applyHistoryFilter — pure function tests (no widget required) + // --------------------------------------------------------------------------- +} diff --git a/app/test/screens/history/history_test_support.dart b/app/test/screens/history/history_test_support.dart new file mode 100644 index 0000000..49e81d5 --- /dev/null +++ b/app/test/screens/history/history_test_support.dart @@ -0,0 +1,43 @@ +/// Shared setup for the history screen's split test files. +/// +/// `history_screen_test.dart` was one 1017-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 six +/// times (which is how two copies drift and one silently stops isolating). +library; + +import 'dart:io'; + +import 'package:diet_guard_app/services/budget_history_service.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Point the log and budget-history services at a fresh temp dir per test, and +/// tear it down afterwards. Call once at the top of a file's `main()`. +void useTempHistoryStores() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_history_'); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + BudgetHistoryService.resetForTesting(); + await tempDir.delete(recursive: true); + }); +} + +/// Pump until the screen has finished its untracked initState load. +/// +/// HistoryScreen loads via a fire-and-forget Future in initState that Flutter's +/// frame scheduler does not track -- see log_meal_screen_test.dart for the same +/// issue. Every test therefore 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/history_screen_test.dart b/app/test/screens/history_screen_test.dart deleted file mode 100644 index 88c85d2..0000000 --- a/app/test/screens/history_screen_test.dart +++ /dev/null @@ -1,1017 +0,0 @@ -import 'dart:io'; - -import 'package:diet_guard_app/services/document_store_io.dart'; -import 'package:diet_guard_app/models/food_entry.dart'; -import 'package:diet_guard_app/screens/history_screen.dart'; -import 'package:diet_guard_app/services/budget_history_service.dart'; -import 'package:diet_guard_app/services/budget_schedule.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_history_'); - LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); - }); - - tearDown(() async { - LogStorageService.resetForTesting(); - BudgetHistoryService.resetForTesting(); - await tempDir.delete(recursive: true); - }); - - // HistoryScreen loads via a fire-and-forget Future in initState that - // Flutter's frame scheduler does not track -- see log_meal_screen_test.dart - // for the same issue. Every test therefore 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(); - } - - testWidgets('a day header shows the budget that applied on that day', ( - tester, - ) async { - await tester.runAsync(() async { - // 2200 until 2026-06-15, 2000 from then on. - await BudgetHistoryService.instance.applyMerged(const [ - BudgetEntry( - effectiveFrom: kEpochDay, - kcal: 2200, - editedAt: '2026-01-01T00:00:00.000Z', - ), - BudgetEntry( - effectiveFrom: '2026-06-15', - kcal: 2000, - editedAt: '2026-06-15T00:00:00.000Z', - ), - ]); - await LogStorageService.instance.writeLog({ - '2026-06-01': [ - const FoodEntry( - id: 'before', - time: '2026-06-01T08:00:00+02:00', - desc: 'before the cut', - grams: 100, - kcal: 2100, - proteinG: 5, - carbsG: 10, - fatG: 2, - source: 'manual', - ), - ], - '2026-06-20': [ - const FoodEntry( - id: 'after', - time: '2026-06-20T08:00:00+02:00', - desc: 'after the cut', - grams: 100, - kcal: 2100, - proteinG: 5, - carbsG: 10, - fatG: 2, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - // Same 2100 kcal on both days, judged against different budgets -- - // lowering the budget must not repaint the earlier day. - expect(find.text('2100 / 2200 kcal'), findsOneWidget); - expect(find.text('2100 / 2000 kcal'), findsOneWidget); - }); - }); - - testWidgets('shows a message when nothing has been logged', (tester) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - expect(find.text('Nothing logged yet.'), findsOneWidget); - }); - }); - - testWidgets('lists logged entries newest first, excluding tombstones', ( - tester, - ) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-01': [ - const FoodEntry( - id: 'old', - time: '2026-06-01T08:00:00+02:00', - desc: 'old breakfast', - grams: 100, - kcal: 100, - proteinG: 5, - carbsG: 10, - fatG: 2, - source: 'manual', - ), - ], - '2026-06-22': [ - const FoodEntry( - id: 'new', - time: '2026-06-22T20:00:00+02:00', - desc: 'new dinner', - grams: 100, - kcal: 200, - proteinG: 10, - carbsG: 20, - fatG: 4, - source: 'manual', - ), - const FoodEntry( - id: 'gone', - time: '2026-06-22T12:00:00+02:00', - desc: 'undone lunch', - grams: 100, - kcal: 300, - proteinG: 1, - carbsG: 1, - fatG: 1, - source: 'manual', - deleted: true, - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - expect(find.text('new dinner'), findsOneWidget); - expect(find.text('old breakfast'), findsOneWidget); - expect(find.text('undone lunch'), findsNothing); - - final tiles = tester.widgetList(find.byType(ListTile)).toList(); - expect((tiles[0].title! as Text).data, 'new dinner'); - expect((tiles[1].title! as Text).data, 'old breakfast'); - }); - }); - - testWidgets( - 'initialDateRange pre-filters to just that day, matching a Calendar ' - 'screen day tap', - (tester) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-01': [ - const FoodEntry( - id: 'other-day', - time: '2026-06-01T08:00:00+02:00', - desc: 'other day breakfast', - grams: 100, - kcal: 100, - proteinG: 5, - carbsG: 10, - fatG: 2, - source: 'manual', - ), - ], - '2026-06-22': [ - const FoodEntry( - id: 'target-day', - time: '2026-06-22T20:00:00+02:00', - desc: 'target day dinner', - grams: 100, - kcal: 200, - proteinG: 10, - carbsG: 20, - fatG: 4, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget( - MaterialApp( - home: HistoryScreen( - initialDateRange: DateTimeRange( - start: DateTime(2026, 6, 22), - end: DateTime(2026, 6, 22), - ), - ), - ), - ); - await settle(tester); - - expect(find.text('target day dinner'), findsOneWidget); - expect(find.text('other day breakfast'), findsNothing); - }); - }, - ); - - // --------------------------------------------------------------------------- - // applyHistoryFilter — pure function tests (no widget required) - // --------------------------------------------------------------------------- - - group('applyHistoryFilter', () { - final entries = [ - const FoodEntry( - id: 'a', - time: '2026-06-20T08:00:00+02:00', - desc: 'Apple', - grams: 100, - kcal: 80, - proteinG: 0.5, - carbsG: 20, - fatG: 0.3, - source: 'manual', - ), - const FoodEntry( - id: 'b', - time: '2026-06-21T12:00:00+02:00', - desc: 'Banana smoothie', - grams: 250, - kcal: 200, - proteinG: 3, - carbsG: 40, - fatG: 1, - source: 'food bank', - ), - const FoodEntry( - id: 'c', - time: '2026-06-22T20:00:00+02:00', - desc: 'Chicken breast', - grams: 150, - kcal: 230, - proteinG: 45, - carbsG: 0, - fatG: 5, - source: 'meal', - ), - ]; - - test('no filter returns all entries sorted by date descending', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(), - HistorySortField.date, - ascending: false, - ); - expect(result.map((e) => e.id), ['c', 'b', 'a']); - }); - - test('nameQuery filters by case-insensitive substring', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(nameQuery: 'an'), - HistorySortField.date, - ascending: false, - ); - expect(result.map((e) => e.id), ['b']); // 'Banana smoothie' - }); - - test('minKcal and maxKcal filter by kcal range', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(minKcal: 100, maxKcal: 210), - HistorySortField.date, - ascending: false, - ); - expect(result.map((e) => e.id), ['b']); // 200 kcal - }); - - test('minProtein filters by protein', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(minProtein: 10), - HistorySortField.date, - ascending: false, - ); - expect(result.map((e) => e.id), ['c']); // 45g protein - }); - - test('maxCarbs filters by carbs', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(maxCarbs: 5), - HistorySortField.date, - ascending: false, - ); - expect(result.map((e) => e.id), ['c']); // 0 carbs - }); - - test('minFat and maxFat filter by fat', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(minFat: 0.4, maxFat: 2), - HistorySortField.date, - ascending: false, - ); - expect(result.map((e) => e.id), ['b']); // fat=1 - }); - - test('source filter keeps only matching source', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(source: 'meal'), - HistorySortField.date, - ascending: false, - ); - expect(result.map((e) => e.id), ['c']); - }); - - test('dateRange filter includes only entries within range', () { - final result = applyHistoryFilter( - entries, - HistoryFilter( - dateRange: DateTimeRange( - start: DateTime(2026, 6, 21), - end: DateTime(2026, 6, 21), - ), - ), - HistorySortField.date, - ascending: false, - ); - expect(result.map((e) => e.id), ['b']); - }); - - test('sort ascending by kcal', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(), - HistorySortField.kcal, - ascending: true, - ); - expect(result.map((e) => e.id), ['a', 'b', 'c']); - }); - - test('sort descending by protein', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(), - HistorySortField.protein, - ascending: false, - ); - expect(result.first.id, 'c'); // 45g - }); - - test('sort by description ascending', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(), - HistorySortField.description, - ascending: true, - ); - // Apple, Banana smoothie, Chicken breast - expect(result.map((e) => e.id), ['a', 'b', 'c']); - }); - - test('sort by fat', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(), - HistorySortField.fat, - ascending: true, - ); - expect(result.first.id, 'a'); // fat=0.3 - }); - - test('sort by carbs descending', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(), - HistorySortField.carbs, - ascending: false, - ); - expect(result.first.id, 'b'); // 40g carbs - }); - - test('HistoryFilter.isActive is false when nothing is set', () { - expect(HistoryFilter().isActive, isFalse); - }); - - test('HistoryFilter.isActive is true when nameQuery is set', () { - expect(HistoryFilter(nameQuery: 'x').isActive, isTrue); - }); - - test('HistoryFilter.isActive is true when source is set', () { - expect(HistoryFilter(source: 'manual').isActive, isTrue); - }); - - test('maxProtein filters by protein', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(maxProtein: 5), - HistorySortField.date, - ascending: false, - ); - // Apple (0.5 g) and Banana (3 g) have protein ≤ 5 g. - expect(result.map((e) => e.id), ['b', 'a']); - }); - - test('minCarbs filters by carbs', () { - final result = applyHistoryFilter( - entries, - HistoryFilter(minCarbs: 15), - HistorySortField.date, - ascending: false, - ); - // Banana (40 g) and Apple (20 g) have carbs ≥ 15 g. - expect(result.map((e) => e.id), ['b', 'a']); - }); - }); - - // --------------------------------------------------------------------------- - // Widget-level — day grouping and filter badge - // --------------------------------------------------------------------------- - - testWidgets('shows day headers with date and total kcal', (tester) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [ - const FoodEntry( - id: 'a', - time: '2026-06-22T08:00:00+02:00', - desc: 'breakfast', - grams: 100, - kcal: 300, - proteinG: 10, - carbsG: 40, - fatG: 5, - source: 'manual', - ), - const FoodEntry( - id: 'b', - time: '2026-06-22T12:00:00+02:00', - desc: 'lunch', - grams: 200, - kcal: 500, - proteinG: 20, - carbsG: 60, - fatG: 10, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - // Day header shows 800 kcal total (300 + 500) vs the 2200 goal. - expect(find.textContaining('800 / 2200 kcal'), findsOneWidget); - // Both entries appear as list tiles. - expect(find.text('breakfast'), findsOneWidget); - expect(find.text('lunch'), findsOneWidget); - }); - }); - - testWidgets('filter icon badge appears when filter is active', ( - tester, - ) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [ - const FoodEntry( - id: 'x', - time: '2026-06-22T08:00:00+02:00', - desc: 'oat', - grams: 100, - kcal: 100, - proteinG: 5, - carbsG: 15, - fatG: 2, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - // No filter active — the dot Container is absent. - // We look for a small Container widget in the Stack above the filter icon. - expect( - find.byWidgetPredicate((w) { - if (w is Container) { - final d = w.decoration; - if (d is BoxDecoration) { - return d.shape == BoxShape.circle && d.color != null; - } - } - return false; - }), - findsNothing, - ); - }); - }); - - testWidgets('shows "no entries match" when filter eliminates all results', ( - tester, - ) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [ - const FoodEntry( - id: 'x', - time: '2026-06-22T08:00:00+02:00', - desc: 'oat', - grams: 100, - kcal: 100, - proteinG: 5, - carbsG: 15, - fatG: 2, - source: 'manual', - ), - ], - }); - - // Build a custom wrapper that injects a filter through the state. - // Easiest: extend HistoryScreen is not possible (private state), so we - // test via the pure `applyHistoryFilter` function instead, which is - // already covered above. This test verifies the "no match" empty-state - // message path through the widget. - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - // Verify the normal path renders the entry. - expect(find.text('oat'), findsOneWidget); - }); - }); - - testWidgets('filter sheet opens and renders search field', (tester) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [ - const FoodEntry( - id: 'a', - time: '2026-06-22T08:00:00+02:00', - desc: 'oat', - grams: 100, - kcal: 100, - proteinG: 5, - carbsG: 15, - fatG: 2, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.filter_list)); - await settle(tester); - - expect(find.text('Filter & Sort'), findsOneWidget); - }); - }); - - testWidgets('filter sheet Apply filters results and closes sheet', ( - tester, - ) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [ - const FoodEntry( - id: 'a', - time: '2026-06-22T08:00:00+02:00', - desc: 'oat porridge', - grams: 100, - kcal: 100, - proteinG: 5, - carbsG: 15, - fatG: 2, - source: 'manual', - ), - const FoodEntry( - id: 'b', - time: '2026-06-22T12:00:00+02:00', - desc: 'chicken breast', - grams: 150, - kcal: 250, - proteinG: 40, - carbsG: 0, - fatG: 5, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.filter_list)); - await settle(tester); - - // Type in the search field (first TextField in the sheet). - 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 porridge'), findsOneWidget); - expect(find.text('chicken breast'), findsNothing); - }); - }); - - testWidgets('filter sheet Clear all resets draft then Apply shows all', ( - tester, - ) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-22': [ - const FoodEntry( - id: 'a', - time: '2026-06-22T08:00:00+02:00', - desc: 'toast', - grams: 100, - kcal: 200, - proteinG: 7, - carbsG: 35, - fatG: 3, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - 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('toast'), findsOneWidget); - }); - }); - - testWidgets('filter sheet sort direction toggle fires onSortChanged', ( - tester, - ) async { - await tester.runAsync(() async { - // Zero macros: no RangeSliders render, sort section is immediately visible. - await LogStorageService.instance.writeLog({ - '2026-06-20': [ - const FoodEntry( - id: 'sd1', - time: '2026-06-20T09:00:00+02:00', - desc: 'porridge', - grams: 200, - kcal: 0, - proteinG: 0, - carbsG: 0, - fatG: 0, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.filter_list)); - await settle(tester); - - expect(find.text('Filter & Sort'), findsOneWidget); - - // The sort section is ~8px below the fold even with zero macros — scroll - // it into view before tapping the direction button. - await tester.drag(find.byType(ListView).last, const Offset(0, -120)); - await settle(tester); - - // Default sort is date-descending; 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('Filter & Sort'), findsNothing); - expect(find.textContaining('porridge'), findsOneWidget); - }); - }); - - testWidgets('filter sheet sort field dropdown changes sort field', ( - tester, - ) async { - await tester.runAsync(() async { - // Zero macros: no RangeSliders render, sort section is immediately visible. - await LogStorageService.instance.writeLog({ - '2026-06-21': [ - const FoodEntry( - id: 'sf1', - time: '2026-06-21T12:00:00+02:00', - desc: 'chicken', - grams: 150, - kcal: 0, - proteinG: 0, - carbsG: 0, - fatG: 0, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.filter_list)); - await settle(tester); - - // Scroll just enough to make the sort section visible. - await tester.drag(find.byType(ListView).last, const Offset(0, -120)); - await settle(tester); - - // Open the sort dropdown (shows 'Date' by default). - await tester.tap(find.text('Date')); - await settle(tester); - - // Select 'Kcal' from the dropdown overlay. - await tester.tap(find.text('Kcal').last); - await settle(tester); - - await tester.tap(find.text('Apply')); - await settle(tester); - - expect(find.text('Filter & Sort'), findsNothing); - expect(find.textContaining('chicken'), findsOneWidget); - }); - }); - - testWidgets('filter sheet source chip filters by source', (tester) async { - await tester.runAsync(() async { - // Zero macros: no sliders, source chips appear right after date button. - await LogStorageService.instance.writeLog({ - '2026-06-23': [ - const FoodEntry( - id: 'src1', - time: '2026-06-23T08:00:00+02:00', - desc: 'manual meal', - grams: 100, - kcal: 0, - proteinG: 0, - carbsG: 0, - fatG: 0, - source: 'manual', - ), - const FoodEntry( - id: 'src2', - time: '2026-06-23T12:00:00+02:00', - desc: 'bank meal', - grams: 100, - kcal: 0, - proteinG: 0, - carbsG: 0, - fatG: 0, - source: 'food bank', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.filter_list)); - await settle(tester); - - expect(find.text('Filter & Sort'), findsOneWidget); - - await tester.tap(find.widgetWithText(FilterChip, 'manual')); - await settle(tester); - - await tester.tap(find.text('Apply')); - await settle(tester); - - expect(find.text('Filter & Sort'), findsNothing); - expect(find.textContaining('manual meal'), findsOneWidget); - expect(find.textContaining('bank meal'), findsNothing); - }); - }); - - testWidgets('filter sheet source All chip fires onSelected callback', ( - tester, - ) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-25': [ - const FoodEntry( - id: 'sa1', - time: '2026-06-25T08:00:00+02:00', - desc: 'all source entry', - grams: 100, - kcal: 0, - proteinG: 0, - carbsG: 0, - fatG: 0, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.filter_list)); - await settle(tester); - - // Tap 'manual' to set a source filter, then 'All' to reset it. - // Tapping 'All' when source is not null covers lines 798-800. - await tester.tap(find.widgetWithText(FilterChip, 'manual')); - await settle(tester); - - await tester.tap(find.widgetWithText(FilterChip, 'All')); - await settle(tester); - - await tester.tap(find.text('Apply')); - await settle(tester); - - expect(find.text('Filter & Sort'), findsNothing); - expect(find.textContaining('all source entry'), 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 LogStorageService.instance.writeLog({ - '2026-06-26': [ - const FoodEntry( - id: 'rs1', - time: '2026-06-26T08:00:00+02:00', - desc: 'slider test entry', - grams: 100, - kcal: 300, - proteinG: 20, - carbsG: 40, - fatG: 10, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.filter_list)); - await settle(tester); - - // tester.drag(finder, offset) fails for RangeSliders inside a modal - // overlay because its internal _maybeViewOf ancestor search cannot find - // a View ancestor through the overlay's render subtree. Use - // getRect()+dragFrom() instead (resolves via renderObjectOf, no - // _maybeViewOf call). - // - // The filter sheet uses SingleChildScrollView+Column, so all four - // sliders are always in the widget tree. ensureVisible() scrolls each - // one into the viewport before getRect() is called. - - // Kcal slider. - await tester.ensureVisible(find.byKey(const Key('kcal-range-slider'))); - await settle(tester); - await tester.dragFrom( - tester.getRect(find.byKey(const Key('kcal-range-slider'))).center, - const Offset(-30, 0), - ); - await settle(tester); - - // Protein slider. - await tester.ensureVisible( - find.byKey(const Key('protein-range-slider')), - ); - await settle(tester); - await tester.dragFrom( - tester.getRect(find.byKey(const Key('protein-range-slider'))).center, - const Offset(-30, 0), - ); - await settle(tester); - - // Carbs slider. - await tester.ensureVisible(find.byKey(const Key('carbs-range-slider'))); - await settle(tester); - await tester.dragFrom( - tester.getRect(find.byKey(const Key('carbs-range-slider'))).center, - const Offset(-30, 0), - ); - await settle(tester); - - // Fat slider. - await tester.ensureVisible(find.byKey(const Key('fat-range-slider'))); - await settle(tester); - await tester.dragFrom( - tester.getRect(find.byKey(const Key('fat-range-slider'))).center, - const Offset(-30, 0), - ); - await settle(tester); - - await tester.tap(find.text('Apply')); - await settle(tester); - - expect(find.text('Filter & Sort'), findsNothing); - }); - }); - - testWidgets( - 'date range picker selection shows _dateRangeLabel and Clear button ' - '(lines 232-234, 639-642)', - (tester) async { - await tester.runAsync(() async { - await LogStorageService.instance.writeLog({ - '2026-06-26': [ - const FoodEntry( - id: 'dr1', - time: '2026-06-26T08:00:00+02:00', - desc: 'range test', - grams: 100, - kcal: 200, - proteinG: 10, - carbsG: 20, - fatG: 5, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - await tester.tap(find.byIcon(Icons.filter_list)); - await settle(tester); - - // Open date range picker. - await tester.tap(find.widgetWithText(OutlinedButton, 'Any date')); - await settle(tester); - - // The picker opens on the current month (no `currentDate` override - // and a null `initialDateRange`) and its `lastDate` is capped at - // tomorrow, so days "10"+ aren't always selectable this early in a - // month. Days "1" and "2" of the displayed month are always within - // [firstDate, lastDate] regardless of which day it is when the test - // runs. - final now = DateTime.now(); - final expectedStart = DateTime(now.year, now.month, 1); - await tester.tap(find.text('1')); - await settle(tester); - await tester.tap(find.text('2')); - await settle(tester); - await tester.tap(find.text('Save')); - await settle(tester); - - // After a successful selection the filter button label shows the - // formatted range via _dateRangeLabel (lines 232-234). Use a date- - // specific prefix so the kcal slider's "0 – N kcal" label is excluded. - expect( - find.textContaining(expectedStart.toString().substring(0, 10)), - findsOneWidget, - ); - - // "Clear date range" is now visible — tap it to exercise lines 639-642. - await tester.tap(find.text('Clear date range')); - await settle(tester); - expect(find.text('Any date'), findsOneWidget); - }); - }, - ); - - testWidgets( - '_formatDay falls back to raw key for an unparsable date (line 245)', - (tester) async { - await tester.runAsync(() async { - // The day key is e.time.substring(0, 10). Writing an entry whose - // `time` field can't be parsed by DateTime.parse exercises the - // `on Exception` fallback in _formatDay (line 245), which returns the - // raw key unchanged. 'NOT-A-DATE' is exactly 10 chars so substring - // doesn't truncate it. - await LogStorageService.instance.writeLog({ - 'NOT-A-DATE': [ - const FoodEntry( - id: 'bad1', - time: 'NOT-A-DATE', - desc: 'bad date entry', - grams: 100, - kcal: 100, - proteinG: 5, - carbsG: 10, - fatG: 2, - source: 'manual', - ), - ], - }); - - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await settle(tester); - - // The raw key is shown as the day header when formatting fails. - expect(find.text('NOT-A-DATE'), findsOneWidget); - }); - }, - ); -}