diff --git a/app/lib/screens/calendar_screen.dart b/app/lib/screens/calendar_screen.dart index 2d501ec..8e05ddf 100644 --- a/app/lib/screens/calendar_screen.dart +++ b/app/lib/screens/calendar_screen.dart @@ -17,6 +17,8 @@ import 'package:diet_guard_app/services/average_service.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/ui/cues.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:diet_guard_app/widgets/average_summary_row.dart'; import 'package:diet_guard_app/widgets/day_status_calendar.dart'; import 'package:diet_guard_app/widgets/streak_summary_row.dart'; @@ -153,9 +155,8 @@ class _CalendarScreenState extends State { if (_loading) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } - final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: const Text('Calendar')), + appBar: AppBar(), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( @@ -168,11 +169,10 @@ class _CalendarScreenState extends State { if (_budgetStatus != null) Padding( padding: const EdgeInsets.only(top: 4), - child: Text( - _budgetStatus!, - style: TextStyle( - color: _budgetError ? theme.colorScheme.error : null, - ), + child: StatusCue( + key: ValueKey(_budgetStatus), + kind: _budgetError ? CueKind.error : CueKind.ok, + semanticLabel: _budgetStatus!, ), ), const SizedBox(height: 12), @@ -215,7 +215,11 @@ class _BudgetRow extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('Daily budget (kcal):'), + Icon( + MacroKind.kcal.icon, + color: MacroKind.kcal.color, + semanticLabel: 'Daily budget (kcal)', + ), const SizedBox(width: 8), SizedBox( width: 90, @@ -230,7 +234,10 @@ class _BudgetRow extends StatelessWidget { const SizedBox(width: 8), FilledButton( onPressed: onEditOrSave, - child: Text(editing ? 'Save' : 'Edit'), + child: Icon( + editing ? Icons.check : Icons.edit, + semanticLabel: editing ? 'Save' : 'Edit', + ), ), ], ); diff --git a/app/lib/screens/edit_entry_screen.dart b/app/lib/screens/edit_entry_screen.dart index 7216689..d61b1e3 100644 --- a/app/lib/screens/edit_entry_screen.dart +++ b/app/lib/screens/edit_entry_screen.dart @@ -8,6 +8,7 @@ import 'package:diet_guard_app/models/food_suggestion.dart'; import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart'; import 'package:diet_guard_app/widgets/macro_input_row.dart'; import 'package:flutter/material.dart'; @@ -35,6 +36,7 @@ class _EditEntryScreenState extends State { List _suggestions = const []; String _source = 'manual'; String? _status; + int _rejections = 0; @override void initState() { @@ -80,9 +82,7 @@ class _EditEntryScreenState extends State { } Future _onDescChanged() async { - final matches = await FoodBankService.instance.search( - _descController.text, - ); + final matches = await FoodBankService.instance.search(_descController.text); if (!mounted) return; setState(() => _suggestions = matches); } @@ -104,7 +104,10 @@ class _EditEntryScreenState extends State { Future _onSave() async { final desc = _descController.text.trim(); if (desc.isEmpty) { - setState(() => _status = 'Description cannot be empty.'); + setState(() { + _status = 'Description cannot be empty.'; + _rejections++; + }); return; } final nutrition = nutritionForPortion( @@ -142,17 +145,16 @@ class _EditEntryScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Edit meal')), + appBar: AppBar(), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField( + IconField( + icon: Icons.restaurant, + semanticLabel: 'What did you eat?', controller: _descController, - decoration: const InputDecoration( - labelText: 'What did you eat?', - ), ), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 240), @@ -168,12 +170,20 @@ class _EditEntryScreenState extends State { width: double.infinity, child: ElevatedButton( onPressed: _onSave, - child: const Text('Save'), + child: const Icon(Icons.check, semanticLabel: 'Save'), ), ), if (_status != null) ...[ const SizedBox(height: 12), - Text(_status!), + // New key per rejection so a repeat error shakes again. + Align( + alignment: Alignment.centerLeft, + child: StatusCue( + key: ValueKey(_rejections), + kind: CueKind.error, + semanticLabel: _status!, + ), + ), ], ], ), diff --git a/app/lib/screens/food_bank_screen.dart b/app/lib/screens/food_bank_screen.dart index 672a0e5..80e4e7e 100644 --- a/app/lib/screens/food_bank_screen.dart +++ b/app/lib/screens/food_bank_screen.dart @@ -153,15 +153,16 @@ class _FoodBankScreenState extends State { final all = _allEntries; return Scaffold( appBar: AppBar( - title: const Text('Food Bank'), actions: [ if (all != null) Stack( alignment: Alignment.center, children: [ IconButton( - icon: const Icon(Icons.filter_list), - tooltip: 'Filter & sort', + icon: const Icon( + Icons.filter_list, + semanticLabel: 'Filter & sort', + ), onPressed: _openFilterSheet, ), if (_filter.isActive) @@ -185,19 +186,19 @@ class _FoodBankScreenState extends State { ? const Center(child: CircularProgressIndicator()) : _displayed.isEmpty ? Center( - child: Text( - all.isEmpty - ? 'Food bank is empty.\n' - 'Log meals to populate it, or add entries manually.' + child: Icon( + all.isEmpty ? Icons.restaurant_menu : Icons.filter_list_off, + size: 64, + color: Theme.of(context).colorScheme.outline, + semanticLabel: all.isEmpty + ? 'Food bank is empty.' : 'No entries match the current filter.', - textAlign: TextAlign.center, ), ) : FbTileGrid(records: _displayed, onOpen: _openDetail), floatingActionButton: FloatingActionButton( onPressed: _openAddDialog, - tooltip: 'Add manual entry', - child: const Icon(Icons.add), + child: const Icon(Icons.add, semanticLabel: 'Add manual entry'), ), ); } diff --git a/app/lib/screens/history_screen.dart b/app/lib/screens/history_screen.dart index 38ea96c..929ddc0 100644 --- a/app/lib/screens/history_screen.dart +++ b/app/lib/screens/history_screen.dart @@ -159,15 +159,16 @@ class _HistoryScreenState extends State { final allEntries = _allEntries; return Scaffold( appBar: AppBar( - title: const Text('History'), actions: [ if (allEntries != null) Stack( alignment: Alignment.center, children: [ IconButton( - icon: const Icon(Icons.filter_list), - tooltip: 'Filter & sort', + icon: const Icon( + Icons.filter_list, + semanticLabel: 'Filter & sort', + ), onPressed: _openFilterSheet, ), if (_filter.isActive) @@ -191,8 +192,11 @@ class _HistoryScreenState extends State { ? const Center(child: CircularProgressIndicator()) : _displayed.isEmpty ? Center( - child: Text( - allEntries.isEmpty + child: Icon( + allEntries.isEmpty ? Icons.restaurant : Icons.filter_list_off, + size: 64, + color: Theme.of(context).colorScheme.outline, + semanticLabel: allEntries.isEmpty ? 'Nothing logged yet.' : 'No entries match the current filter.', ), diff --git a/app/lib/widgets/autocomplete_suggestion_list.dart b/app/lib/widgets/autocomplete_suggestion_list.dart index 9df783f..f2ad8e9 100644 --- a/app/lib/widgets/autocomplete_suggestion_list.dart +++ b/app/lib/widgets/autocomplete_suggestion_list.dart @@ -2,6 +2,7 @@ library; import 'package:diet_guard_app/models/food_suggestion.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:flutter/material.dart'; /// A tappable list of [FoodSuggestion]s, each filling the form on tap. @@ -57,8 +58,12 @@ class AutocompleteSuggestionList extends StatelessWidget { return ListTile( dense: true, title: Text(suggestion.name), - subtitle: Text( - '${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal', + subtitle: Align( + alignment: Alignment.centerLeft, + child: MacroFigure( + kind: MacroKind.kcal, + value: suggestion.nutrition.kcal.toStringAsFixed(0), + ), ), onTap: () => onSelected(suggestion), ); @@ -76,17 +81,34 @@ class AutocompleteSuggestionList extends StatelessWidget { onTap: () => onSelected(suggestion), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - '${suggestion.name} · ' - '${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal', - overflow: TextOverflow.ellipsis, + child: Row( + children: [ + Flexible( + child: Text( + suggestion.name, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + MacroFigure( + kind: MacroKind.kcal, + value: suggestion.nutrition.kcal.toStringAsFixed(0), + ), + ], ), ), ), if (remaining > 0) TextButton( onPressed: () => _showMore(context), - child: Text('$remaining more'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.more_horiz, semanticLabel: 'More'), + const SizedBox(width: 4), + Text('+$remaining'), + ], + ), ), ], ); diff --git a/app/lib/widgets/average_summary_row.dart b/app/lib/widgets/average_summary_row.dart index a4f4b65..6474de9 100644 --- a/app/lib/widgets/average_summary_row.dart +++ b/app/lib/widgets/average_summary_row.dart @@ -2,23 +2,23 @@ library; import 'package:diet_guard_app/models/period_average.dart'; +import 'package:diet_guard_app/ui/cues.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:flutter/material.dart'; /// Displays this week's and this month's average kcal/day and their bands. /// -/// Mirrors `_calendar_view.py`'s `averages_text`: "Avg/day (to yesterday): -/// week NNNN kcal (band) · month NNNN kcal (band)". +/// Mirrors `_calendar_view.py`'s `averages_text`, minus the words: each period +/// is a calendar glyph, the average as a number, and the band as a tinted +/// icon (check = under, warning = slightly over, error = very over, dash = +/// no data) so colour is never the only channel. /// /// Both periods stop at yesterday, so this line does not swing every time a /// meal is logged today -- it answers "how has this week gone", not "how am I /// doing right now", which is what the Today progress card is for. class AverageSummaryRow extends StatelessWidget { /// Creates an [AverageSummaryRow]. - const AverageSummaryRow({ - required this.week, - required this.month, - super.key, - }); + const AverageSummaryRow({required this.week, required this.month, super.key}); /// The current ISO week's average, through yesterday. final PeriodAverage week; @@ -26,18 +26,72 @@ class AverageSummaryRow extends StatelessWidget { /// The current calendar month's average, through yesterday. final PeriodAverage month; - static String _part(PeriodAverage period) { - final avg = period.avgKcal; - if (avg == null) return bandLabel(period.band); - return '${avg.round()} kcal (${bandLabel(period.band)})'; - } - @override Widget build(BuildContext context) { - return Text( - 'Avg/day (to yesterday): week ${_part(week)} · month ${_part(month)}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _PeriodFigure( + period: week, + icon: Icons.calendar_view_week, + semanticLabel: 'week', + ), + const SizedBox(width: 24), + _PeriodFigure( + period: month, + icon: Icons.calendar_view_month, + semanticLabel: 'month', + ), + ], + ); + } +} + +class _PeriodFigure extends StatelessWidget { + const _PeriodFigure({ + required this.period, + required this.icon, + required this.semanticLabel, + }); + + final PeriodAverage period; + final IconData icon; + final String semanticLabel; + + (CueKind, IconData) get _band => switch (period.band) { + null => (CueKind.info, Icons.remove_circle_outline), + AverageBand.under => (CueKind.ok, Icons.check_circle), + AverageBand.slightlyOver => (CueKind.warn, Icons.warning_amber_rounded), + AverageBand.veryOver => (CueKind.error, Icons.error), + }; + + @override + Widget build(BuildContext context) { + final (kind, bandIcon) = _band; + final avg = period.avgKcal; + final style = Theme.of(context).textTheme.bodySmall; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, semanticLabel: semanticLabel), + const SizedBox(width: 4), + Icon( + bandIcon, + size: 16, + color: kind.tint(context), + semanticLabel: bandLabel(period.band), + ), + if (avg != null) ...[ + const SizedBox(width: 4), + Icon( + MacroKind.kcal.icon, + size: 14, + color: MacroKind.kcal.color, + semanticLabel: 'kcal', + ), + Text('${avg.round()}', style: style), + ], + ], ); } } diff --git a/app/lib/widgets/day_status_calendar.dart b/app/lib/widgets/day_status_calendar.dart index 97f83f2..8d06dbb 100644 --- a/app/lib/widgets/day_status_calendar.dart +++ b/app/lib/widgets/day_status_calendar.dart @@ -46,23 +46,6 @@ class DayStatusCalendar extends StatelessWidget { /// outside the displayed month is never tappable regardless. final void Function(DateTime day)? onDaySelected; - static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; - - static const _monthNames = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - String _dateKey(int year, int m, int day) => '$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; @@ -129,13 +112,18 @@ class DayStatusCalendar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( - icon: Icon(Icons.chevron_left, color: scheme.onSurfaceVariant), + icon: Icon( + Icons.chevron_left, + color: scheme.onSurfaceVariant, + semanticLabel: 'Previous month', + ), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, onPressed: onPrevMonth, ), + // Numeric month/year: a month name would be written chrome. Text( - '${_monthNames[m - 1]} $year', + '${m.toString().padLeft(2, '0')} · $year', style: TextStyle( color: scheme.onSurface, fontWeight: FontWeight.bold, @@ -143,7 +131,11 @@ class DayStatusCalendar extends StatelessWidget { ), ), IconButton( - icon: Icon(Icons.chevron_right, color: scheme.onSurfaceVariant), + icon: Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + semanticLabel: 'Next month', + ), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, onPressed: onNextMonth, @@ -153,25 +145,23 @@ class DayStatusCalendar extends StatelessWidget { const SizedBox(height: AppSpacing.xs + 2), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, - children: _weekHeaders - .map( - (h) => SizedBox( - width: 30, - child: Text( - h, - textAlign: TextAlign.center, - // Two-letter day abbreviations are UI chrome (like a - // badge/tag), deliberately kept below the 16px body - // floor rather than forced to grow past their slot. - style: TextStyle( - color: scheme.onSurfaceVariant, - fontSize: AppTextSize.label - 2, - fontWeight: FontWeight.bold, - ), + // Weekday names are gone: seven dots mark the Monday-first + // columns, the two weekend ones in the accent colour. + children: [ + for (var col = 0; col < 7; col++) + SizedBox( + width: 30, + child: Center( + child: Icon( + Icons.circle, + size: col >= 5 ? 8 : 5, + color: col >= 5 + ? scheme.primary + : scheme.onSurfaceVariant, ), ), - ) - .toList(), + ), + ], ), const SizedBox(height: AppSpacing.xs), ...List.generate(rows, (row) { @@ -218,9 +208,10 @@ class DayStatusCalendar extends StatelessWidget { // and no semantics for a screen reader. InkWell is a focus // stop and is activated by Enter/Space. final date = DateTime(year, m, day); + final semanticLabel = 'View $day/$m/$year'; return Semantics( button: true, - label: 'View $day/$m/$year', + label: semanticLabel, child: InkWell( onTap: () => onDaySelected!(date), customBorder: const CircleBorder(), diff --git a/app/lib/widgets/filters/filter_chrome.dart b/app/lib/widgets/filters/filter_chrome.dart new file mode 100644 index 0000000..469255c --- /dev/null +++ b/app/lib/widgets/filters/filter_chrome.dart @@ -0,0 +1,124 @@ +/// Icon-only chrome shared by the history and food-bank filter sheets: the +/// clear control, the apply bar and the sort chooser. Each control paints a +/// glyph and speaks its old caption only through its semantic label. +library; + +import 'package:flutter/material.dart'; + +/// Top row of a filter sheet: one clear-everything icon, right-aligned. +class FilterSheetHeader extends StatelessWidget { + /// Creates a header whose clear icon calls [onClear]. + const FilterSheetHeader({required this.onClear, super.key}); + + /// Called when the clear icon is pressed. + final VoidCallback onClear; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 8, 0), + child: Align( + alignment: Alignment.centerRight, + child: IconButton( + icon: const Icon(Icons.filter_list_off, semanticLabel: 'Clear all'), + onPressed: onClear, + ), + ), + ); +} + +/// Bottom bar of a filter sheet: one full-width check button. +class FilterApplyBar extends StatelessWidget { + /// Creates an apply bar that calls [onApply]. + const FilterApplyBar({required this.onApply, super.key}); + + /// Called when the check is pressed. + final VoidCallback onApply; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onApply, + child: const Icon(Icons.check, semanticLabel: 'Apply'), + ), + ), + ); +} + +/// One choice in a [SortChoiceRow]: a glyph, its tint and its spoken name. +class SortOption { + /// Creates an option selecting [value]. + const SortOption(this.value, this.icon, this.semanticLabel, {this.color}); + + /// The value reported when chosen. + final T value; + + /// Painted glyph. + final IconData icon; + + /// Spoken name; never painted. + final String semanticLabel; + + /// Glyph tint; the theme default when null. + final Color? color; +} + +/// Icon chips for the sort field plus an arrow for the direction. +class SortChoiceRow extends StatelessWidget { + /// Creates a chooser over [options] with [selected] active. + const SortChoiceRow({ + required this.options, + required this.selected, + required this.ascending, + required this.onChanged, + super.key, + }); + + /// Choices in display order. + final List> options; + + /// The active choice. + final T selected; + + /// Whether the sort runs ascending. + final bool ascending; + + /// Called with the new field and direction. + final void Function({required T field, required bool asc}) onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final o in options) + ChoiceChip( + label: Icon( + o.icon, + size: 18, + color: o.color, + semanticLabel: o.semanticLabel, + ), + selected: selected == o.value, + onSelected: (_) => onChanged(field: o.value, asc: ascending), + ), + ], + ), + ), + IconButton( + icon: Icon( + ascending ? Icons.arrow_upward : Icons.arrow_downward, + semanticLabel: ascending ? 'Ascending' : 'Descending', + ), + onPressed: () => onChanged(field: selected, asc: !ascending), + ), + ], + ); + } +} diff --git a/app/lib/widgets/filters/filter_fields.dart b/app/lib/widgets/filters/filter_fields.dart index f99e376..8eab8d4 100644 --- a/app/lib/widgets/filters/filter_fields.dart +++ b/app/lib/widgets/filters/filter_fields.dart @@ -6,9 +6,11 @@ /// screen owns the other's controls. library; +import 'package:diet_guard_app/ui/cues.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:flutter/material.dart'; -/// The free-text "search by name" field in the filter sheet. +/// 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]. @@ -44,13 +46,11 @@ class NameSearchFieldState extends State { @override Widget build(BuildContext context) { - return TextField( + return IconField( + icon: Icons.search, + semanticLabel: 'Search by name', controller: _controller, - decoration: const InputDecoration( - labelText: 'Search by name', - prefixIcon: Icon(Icons.search), - isDense: true, - ), + dense: true, onChanged: widget.onChanged, ); } @@ -112,7 +112,7 @@ class SliderSelectedLabel extends StatelessWidget { class FilterRangeRow extends StatelessWidget { /// Creates a range row bounded by [maxValue]. const FilterRangeRow({ - required this.label, + required this.kind, required this.sliderKey, required this.maxValue, required this.min, @@ -123,8 +123,8 @@ class FilterRangeRow extends StatelessWidget { super.key, }); - /// Section heading, e.g. `Protein range (g)`. - final String label; + /// Which nutrient this range filters; its glyph heads the row. + final MacroKind kind; /// Widget key on the slider itself, so tests can drive one row. final Key sliderKey; @@ -158,7 +158,14 @@ class FilterRangeRow extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(label, style: Theme.of(context).textTheme.labelLarge), + Align( + alignment: Alignment.centerLeft, + child: Icon( + kind.icon, + color: kind.color, + semanticLabel: kind.semanticLabel(compact: false), + ), + ), if (showEndpointLabels) SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'), RangeSlider( diff --git a/app/lib/widgets/food_bank/fb_add_entry_dialog.dart b/app/lib/widgets/food_bank/fb_add_entry_dialog.dart index 55fb8c5..777ea20 100644 --- a/app/lib/widgets/food_bank/fb_add_entry_dialog.dart +++ b/app/lib/widgets/food_bank/fb_add_entry_dialog.dart @@ -4,6 +4,8 @@ library; import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/ui/cues.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:flutter/material.dart'; /// Prompts for a new manually-curated food bank entry. @@ -53,49 +55,50 @@ class AddEntryDialogState extends State { ); } - Widget _field(String label, TextEditingController ctrl) => Padding( + Widget _field(MacroKind kind, TextEditingController ctrl) => Padding( padding: const EdgeInsets.only(bottom: 8), - child: TextField( + child: IconField( + icon: kind.icon, + iconColor: kind.color, + semanticLabel: kind.semanticLabel(compact: false), controller: ctrl, keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: InputDecoration(labelText: label, isDense: true), + dense: true, ), ); @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('Add to food bank'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.only(bottom: 8), - child: TextField( + child: IconField( + icon: Icons.restaurant, + semanticLabel: 'Name', controller: _name, - decoration: const InputDecoration( - labelText: 'Name', - isDense: true, - ), + dense: true, ), ), - _field('Reference grams', _grams), - _field('Kcal', _kcal), - _field('Protein (g)', _protein), - _field('Carbs (g)', _carbs), - _field('Fat (g)', _fat), + _field(MacroKind.perGrams, _grams), + _field(MacroKind.kcal, _kcal), + _field(MacroKind.protein, _protein), + _field(MacroKind.carbs, _carbs), + _field(MacroKind.fat, _fat), ], ), ), actions: [ - TextButton( + IconButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + icon: const Icon(Icons.close, semanticLabel: 'Cancel'), ), ElevatedButton( onPressed: _save, - child: const Text('Save to bank'), + child: const Icon(Icons.check, semanticLabel: 'Save to bank'), ), ], ); diff --git a/app/lib/widgets/food_bank/fb_filter_sheet.dart b/app/lib/widgets/food_bank/fb_filter_sheet.dart index a57ae41..6d06ee1 100644 --- a/app/lib/widgets/food_bank/fb_filter_sheet.dart +++ b/app/lib/widgets/food_bank/fb_filter_sheet.dart @@ -4,6 +4,9 @@ /// range rows come from `widgets/filters/`, shared with the history sheet. library; +import 'package:diet_guard_app/ui/cues.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; +import 'package:diet_guard_app/widgets/filters/filter_chrome.dart'; import 'package:diet_guard_app/widgets/filters/filter_fields.dart'; import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart'; import 'package:flutter/material.dart'; @@ -63,6 +66,35 @@ class FbFilterSheet extends StatelessWidget { /// Called when the user clears every criterion. final VoidCallback onClear; + static final List> _sortOptions = [ + const SortOption(FbSortField.count, Icons.repeat, 'Usage count'), + const SortOption(FbSortField.name, Icons.sort_by_alpha, 'Name'), + SortOption( + FbSortField.kcal, + MacroKind.kcal.icon, + 'Kcal', + color: MacroKind.kcal.color, + ), + SortOption( + FbSortField.protein, + MacroKind.protein.icon, + 'Protein', + color: MacroKind.protein.color, + ), + SortOption( + FbSortField.carbs, + MacroKind.carbs.icon, + 'Carbs', + color: MacroKind.carbs.color, + ), + SortOption( + FbSortField.fat, + MacroKind.fat.icon, + 'Fat', + color: MacroKind.fat.color, + ), + ]; + @override Widget build(BuildContext context) { return DraggableScrollableSheet( @@ -71,35 +103,17 @@ class FbFilterSheet extends StatelessWidget { 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'), - ), - ], - ), - ), + FilterSheetHeader(onClear: onClear), const Divider(), Expanded( child: ListView( controller: scroll, padding: const EdgeInsets.all(16), children: [ - TextField( - decoration: const InputDecoration( - labelText: 'Search by name', - prefixIcon: Icon(Icons.search), - isDense: true, - ), + IconField( + icon: Icons.search, + semanticLabel: 'Search by name', + dense: true, controller: TextEditingController(text: filter.nameQuery) ..selection = TextSelection.collapsed( offset: filter.nameQuery.length, @@ -111,7 +125,7 @@ class FbFilterSheet extends StatelessWidget { ), const SizedBox(height: 16), FilterRangeRow( - label: 'Kcal range', + kind: MacroKind.kcal, sliderKey: const Key('fb-kcal-range-slider'), maxValue: maxKcal, min: filter.minKcal, @@ -124,7 +138,7 @@ class FbFilterSheet extends StatelessWidget { }, ), FilterRangeRow( - label: 'Protein range (g)', + kind: MacroKind.protein, sliderKey: const Key('fb-protein-range-slider'), maxValue: maxProtein, min: filter.minProtein, @@ -138,7 +152,7 @@ class FbFilterSheet extends StatelessWidget { }, ), FilterRangeRow( - label: 'Carbs range (g)', + kind: MacroKind.carbs, sliderKey: const Key('fb-carbs-range-slider'), maxValue: maxCarbs, min: filter.minCarbs, @@ -152,7 +166,7 @@ class FbFilterSheet extends StatelessWidget { }, ), FilterRangeRow( - label: 'Fat range (g)', + kind: MacroKind.fat, sliderKey: const Key('fb-fat-range-slider'), maxValue: maxFat, min: filter.minFat, @@ -165,74 +179,16 @@ class FbFilterSheet extends StatelessWidget { onFilterChanged(filter); }, ), - Text( - 'Sort by', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: DropdownButton( - isExpanded: true, - value: sortField, - items: const [ - DropdownMenuItem( - value: FbSortField.count, - child: Text('Usage count'), - ), - DropdownMenuItem( - value: FbSortField.name, - child: Text('Name'), - ), - DropdownMenuItem( - value: FbSortField.kcal, - child: Text('Kcal'), - ), - DropdownMenuItem( - value: FbSortField.protein, - child: Text('Protein'), - ), - DropdownMenuItem( - value: FbSortField.carbs, - child: Text('Carbs'), - ), - DropdownMenuItem( - value: FbSortField.fat, - child: Text('Fat'), - ), - ], - onChanged: (v) { - if (v != null) { - onSortChanged(field: v, asc: ascending); - } - }, - ), - ), - const SizedBox(width: 8), - IconButton( - icon: Icon( - ascending ? Icons.arrow_upward : Icons.arrow_downward, - ), - tooltip: ascending ? 'Ascending' : 'Descending', - onPressed: () => - onSortChanged(field: sortField, asc: !ascending), - ), - ], + SortChoiceRow( + options: _sortOptions, + selected: sortField, + ascending: ascending, + onChanged: onSortChanged, ), ], ), ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: onApply, - child: const Text('Apply'), - ), - ), - ), + FilterApplyBar(onApply: onApply), ], ), ); diff --git a/app/lib/widgets/food_bank/fb_record_tile.dart b/app/lib/widgets/food_bank/fb_record_tile.dart index 07ca543..8ec163a 100644 --- a/app/lib/widgets/food_bank/fb_record_tile.dart +++ b/app/lib/widgets/food_bank/fb_record_tile.dart @@ -2,9 +2,13 @@ library; import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:flutter/material.dart'; /// One banked food: its name, macros, and how often it has been logged. +/// +/// Macros are glyph + number pairs; the reference weight carries the scale +/// glyph, the use count keeps its `×N` numeral. class RecordTile extends StatelessWidget { /// Creates a [RecordTile] for [record]. const RecordTile(this.record, {super.key}); @@ -14,19 +18,23 @@ class RecordTile extends StatelessWidget { @override Widget build(BuildContext context) { - final macros = - 'P ${record.proteinG.toStringAsFixed(0)} g ' - 'C ${record.carbsG.toStringAsFixed(0)} g ' - 'F ${record.fatG.toStringAsFixed(0)} g'; - final per = record.grams > 0 - ? ' per ${record.grams.toStringAsFixed(0)} g' - : ''; + String n(double v) => v.toStringAsFixed(0); return ListTile( title: Text(record.desc), - subtitle: Text('${record.kcal.toStringAsFixed(0)} kcal$per · $macros'), + subtitle: Wrap( + spacing: 12, + children: [ + MacroFigure(kind: MacroKind.kcal, value: n(record.kcal)), + if (record.grams > 0) + MacroFigure(kind: MacroKind.perGrams, value: '${n(record.grams)}g'), + MacroFigure(kind: MacroKind.protein, value: '${n(record.proteinG)}g'), + MacroFigure(kind: MacroKind.carbs, value: '${n(record.carbsG)}g'), + MacroFigure(kind: MacroKind.fat, value: '${n(record.fatG)}g'), + ], + ), trailing: record.count > 0 ? Text( - '×${record.count.toStringAsFixed(0)}', + '×${n(record.count)}', style: Theme.of(context).textTheme.bodySmall, ) : null, diff --git a/app/lib/widgets/food_bank/fb_tile_grid.dart b/app/lib/widgets/food_bank/fb_tile_grid.dart index 0dc34dd..ed082fe 100644 --- a/app/lib/widgets/food_bank/fb_tile_grid.dart +++ b/app/lib/widgets/food_bank/fb_tile_grid.dart @@ -4,6 +4,7 @@ library; import 'package:diet_guard_app/models/food_bank_record.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/material.dart'; @@ -59,11 +60,13 @@ class FbTile extends StatelessWidget { Row( children: [ Expanded( - child: Text( - '${record.kcal.toStringAsFixed(0)} kcal', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, + child: Align( + alignment: Alignment.centerLeft, + child: MacroFigure( + kind: MacroKind.kcal, + value: record.kcal.toStringAsFixed(0), + size: 14, + ), ), ), if (record.count > 0) diff --git a/app/lib/widgets/history/history_filter_sheet.dart b/app/lib/widgets/history/history_filter_sheet.dart index b7e795f..ea29ef8 100644 --- a/app/lib/widgets/history/history_filter_sheet.dart +++ b/app/lib/widgets/history/history_filter_sheet.dart @@ -3,6 +3,8 @@ /// Split out of `history_screen.dart` to hold the repo's 250-line cap. library; +import 'package:diet_guard_app/ui/macro_kind.dart'; +import 'package:diet_guard_app/widgets/filters/filter_chrome.dart'; import 'package:diet_guard_app/widgets/filters/filter_fields.dart'; import 'package:diet_guard_app/widgets/history/history_filter.dart'; import 'package:diet_guard_app/widgets/history/history_filter_sort.dart'; @@ -73,20 +75,7 @@ class FilterSheet extends StatelessWidget { 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')), - ], - ), - ), + FilterSheetHeader(onClear: onClear), const Divider(), Expanded( child: SingleChildScrollView( @@ -105,18 +94,21 @@ class FilterSheet extends StatelessWidget { ), 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!), + // Date range: the glyph alone means "any date"; once a range + // is set its dates (data, not chrome) appear beside it. + OutlinedButton( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.date_range, + semanticLabel: 'Date range', + ), + if (filter.dateRange != null) ...[ + const SizedBox(width: 8), + Text(dateRangeLabel(filter.dateRange!)), + ], + ], ), onPressed: () async { final picked = await showDateRangePicker( @@ -132,17 +124,20 @@ class FilterSheet extends StatelessWidget { }, ), if (filter.dateRange != null) - TextButton( + IconButton( onPressed: () { filter.dateRange = null; onFilterChanged(filter); }, - child: const Text('Clear date range'), + icon: const Icon( + Icons.event_busy, + semanticLabel: 'Clear date range', + ), ), const SizedBox(height: 16), FilterRangeRow( - label: 'Kcal range', + kind: MacroKind.kcal, sliderKey: const Key('kcal-range-slider'), maxValue: maxKcal, min: filter.minKcal, @@ -154,7 +149,7 @@ class FilterSheet extends StatelessWidget { }, ), FilterRangeRow( - label: 'Protein range (g)', + kind: MacroKind.protein, sliderKey: const Key('protein-range-slider'), maxValue: maxProtein, min: filter.minProtein, @@ -167,7 +162,7 @@ class FilterSheet extends StatelessWidget { }, ), FilterRangeRow( - label: 'Carbs range (g)', + kind: MacroKind.carbs, sliderKey: const Key('carbs-range-slider'), maxValue: maxCarbs, min: filter.minCarbs, @@ -180,7 +175,7 @@ class FilterSheet extends StatelessWidget { }, ), FilterRangeRow( - label: 'Fat range (g)', + kind: MacroKind.fat, sliderKey: const Key('fat-range-slider'), maxValue: maxFat, min: filter.minFat, @@ -210,16 +205,7 @@ class FilterSheet extends StatelessWidget { ), ), ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: onApply, - child: const Text('Apply'), - ), - ), - ), + FilterApplyBar(onApply: onApply), ], ), ); diff --git a/app/lib/widgets/history/history_filter_sort.dart b/app/lib/widgets/history/history_filter_sort.dart index cbee279..9d955ef 100644 --- a/app/lib/widgets/history/history_filter_sort.dart +++ b/app/lib/widgets/history/history_filter_sort.dart @@ -3,8 +3,12 @@ /// 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. +/// Every control is a glyph; its old caption lives only in the semantic label. library; +import 'package:diet_guard_app/ui/macro_kind.dart'; +import 'package:diet_guard_app/ui/source_icon.dart'; +import 'package:diet_guard_app/widgets/filters/filter_chrome.dart'; import 'package:diet_guard_app/widgets/history/history_filter.dart'; import 'package:flutter/material.dart'; @@ -15,7 +19,7 @@ import 'package:flutter/material.dart'; /// a constraint on what `source` may contain. const _sources = ['manual', 'food bank']; -/// Single-select chips filtering by entry source, with an "All" reset. +/// Single-select chips filtering by entry source, with an "all" reset. class FilterSourceChips extends StatelessWidget { /// Creates source chips with [selected] currently active. const FilterSourceChips({ @@ -24,41 +28,38 @@ class FilterSourceChips extends StatelessWidget { super.key, }); - /// The active source, or null for "All". + /// The active source, or null for "all". final String? selected; - /// Called with the chosen source, or null when "All" is tapped. + /// Called with the chosen source, or null when the reset chip is tapped. final ValueChanged onSelected; @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return Wrap( + spacing: 8, 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), - ), - ], + FilterChip( + label: const Icon( + Icons.all_inclusive, + size: 18, + semanticLabel: 'All', + ), + selected: selected == null, + onSelected: (_) => onSelected(null), ), + for (final src in _sources) + FilterChip( + label: SourceMark(src, size: 18), + selected: selected == src, + onSelected: (_) => onSelected(src), + ), ], ); } } -/// The sort-field dropdown plus its ascending/descending toggle. +/// The sort-field chips plus their ascending/descending toggle. class FilterSortControls extends StatelessWidget { /// Creates sort controls reflecting [sortField] and [ascending]. const FilterSortControls({ @@ -78,63 +79,44 @@ class FilterSortControls extends StatelessWidget { final void Function({required HistorySortField field, required bool asc}) onSortChanged; + static final List> _options = [ + const SortOption(HistorySortField.date, Icons.calendar_today, 'Date'), + SortOption( + HistorySortField.kcal, + MacroKind.kcal.icon, + 'Kcal', + color: MacroKind.kcal.color, + ), + SortOption( + HistorySortField.protein, + MacroKind.protein.icon, + 'Protein', + color: MacroKind.protein.color, + ), + SortOption( + HistorySortField.carbs, + MacroKind.carbs.icon, + 'Carbs', + color: MacroKind.carbs.color, + ), + SortOption( + HistorySortField.fat, + MacroKind.fat.icon, + 'Fat', + color: MacroKind.fat.color, + ), + const SortOption( + HistorySortField.description, + Icons.sort_by_alpha, + 'Description', + ), + ]; + @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), - ), - ], - ), - ], - ); - } + Widget build(BuildContext context) => SortChoiceRow( + options: _options, + selected: sortField, + ascending: ascending, + onChanged: onSortChanged, + ); } diff --git a/app/lib/widgets/history/history_grouping.dart b/app/lib/widgets/history/history_grouping.dart index 7d8220b..f450ac7 100644 --- a/app/lib/widgets/history/history_grouping.dart +++ b/app/lib/widgets/history/history_grouping.dart @@ -170,29 +170,17 @@ 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. +/// Render a `YYYY-MM-DD` key as numeric `DD.MM.YYYY` (no weekday or month +/// names: those were written words, and a date is a number). /// /// 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}'; + final day = d.day.toString().padLeft(2, '0'); + final month = d.month.toString().padLeft(2, '0'); + return '$day.$month.${d.year}'; } on Exception { return dateKey; } diff --git a/app/lib/widgets/history/history_list.dart b/app/lib/widgets/history/history_list.dart index 0accee6..be0397a 100644 --- a/app/lib/widgets/history/history_list.dart +++ b/app/lib/widgets/history/history_list.dart @@ -3,9 +3,14 @@ /// Split out of `history_screen.dart` to hold the repo's 250-line cap. library; +import 'dart:async'; + 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/ui/cues.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; +import 'package:diet_guard_app/ui/source_icon.dart'; import 'package:diet_guard_app/widgets/history/history_grouping.dart'; import 'package:flutter/material.dart'; @@ -84,28 +89,44 @@ class DayHeaderTile extends StatelessWidget { ), ), ), - Text( - '${header.entryCount}' - ' ${header.entryCount == 1 ? 'entry' : 'entries'}', - style: textTheme.bodySmall, + Icon( + Icons.restaurant, + size: 14, + color: colorScheme.onSurfaceVariant, + semanticLabel: 'entries', ), + const SizedBox(width: 3), + Text('${header.entryCount}', style: textTheme.bodySmall), ], ), const SizedBox(height: 2), Row( children: [ + Icon( + MacroKind.kcal.icon, + size: 14, + color: kcalColor, + semanticLabel: 'kcal', + ), + const SizedBox(width: 3), Text( - '${header.totalKcal.round()} / $goal kcal', + '${header.totalKcal.round()} / $goal', style: textTheme.bodySmall?.copyWith(color: kcalColor), ), + const SizedBox(width: 12), + MacroFigure( + kind: MacroKind.protein, + value: '${header.totalProtein.round()}g', + ), 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, - ), + MacroFigure( + kind: MacroKind.carbs, + value: '${header.totalCarbs.round()}g', + ), + const SizedBox(width: 8), + MacroFigure( + kind: MacroKind.fat, + value: '${header.totalFat.round()}g', ), ], ), @@ -135,7 +156,13 @@ class EntryTile extends StatelessWidget { return ListTile( leading: const Icon(Icons.restaurant), title: Text(entry.desc), - subtitle: Text('${entry.time} • ${entry.source}'), + subtitle: Row( + children: [ + Flexible(child: Text(entry.time, overflow: TextOverflow.ellipsis)), + const SizedBox(width: 8), + SourceMark(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 @@ -144,12 +171,18 @@ class EntryTile extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - Text('${entry.kcal.toStringAsFixed(0)} kcal'), + MacroFigure( + kind: MacroKind.kcal, + value: entry.kcal.toStringAsFixed(0), + size: 14, + ), if (canDelete) IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: 'Delete entry', - onPressed: () => _confirmDelete(context), + icon: const Icon( + Icons.delete_outline, + semanticLabel: 'Delete entry', + ), + onPressed: () => unawaited(_delete(context)), ), ], ), @@ -157,35 +190,33 @@ class EntryTile extends StatelessWidget { // 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, + onLongPress: canDelete ? () => unawaited(_delete(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'), - ), - ], - ), + /// Deletes at once and offers an icon-only undo instead of asking first. + /// + /// The snackbar is raised before the awaits because the reload that + /// follows rebuilds the list and may dispose this tile's context. Undo + /// writes the live copy back over the tombstone (same id), then reloads. + Future _delete(BuildContext context) async { + showCueSnack( + context, + kind: CueKind.ok, + icon: Icons.delete_outline, + semanticLabel: 'Entry deleted', + onUndo: () => unawaited(_restore()), ); - if (confirmed == true) { - await LogStorageService.instance.deleteEntry(entry.id!); - await onDelete?.call(); - } + await LogStorageService.instance.deleteEntry(entry.id!); + await onDelete?.call(); + } + + Future _restore() async { + await LogStorageService.instance.updateEntry( + entry.copyWithDeleted(), + entry, + ); + await onDelete?.call(); } } diff --git a/app/lib/widgets/streak_summary_row.dart b/app/lib/widgets/streak_summary_row.dart index 1ad6e22..ca508d4 100644 --- a/app/lib/widgets/streak_summary_row.dart +++ b/app/lib/widgets/streak_summary_row.dart @@ -2,13 +2,15 @@ library; import 'package:diet_guard_app/models/day_status.dart'; +import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/material.dart'; /// Displays the logging streak, adherence streak, and year-to-date tally. /// -/// Mirrors `_calendar_view.py`'s `streaks_text`/`ytd_text` formatting: -/// "Logging streak: N day(s) · Adherence streak: N day(s)" and -/// "This year: logged X/Y days · Z within budget". +/// Mirrors `_calendar_view.py`'s `streaks_text`/`ytd_text` data, as glyphs and +/// numerals: pencil + N (logging streak), flame + N (adherence streak), +/// calendar-check + X/Y (days logged of days elapsed), medal + Z (days within +/// budget). class StreakSummaryRow extends StatelessWidget { /// Creates a [StreakSummaryRow]. const StreakSummaryRow({ @@ -27,27 +29,80 @@ class StreakSummaryRow extends StatelessWidget { /// This year's logged/elapsed/adherent day counts. final YtdTally tally; - static String _plural(int count) => count == 1 ? 'day' : 'days'; - @override Widget build(BuildContext context) { final theme = Theme.of(context); + final status = theme.extension() ?? AppStatusColors.dark; return Column( children: [ - Text( - 'Logging streak: $loggingStreak ${_plural(loggingStreak)} · ' - 'Adherence streak: $adherenceStreak ${_plural(adherenceStreak)}', - textAlign: TextAlign.center, - style: theme.textTheme.titleSmall, + Wrap( + alignment: WrapAlignment.center, + spacing: AppSpacing.lg, + children: [ + _Figure( + icon: Icons.edit_note, + color: theme.colorScheme.primary, + semanticLabel: 'Logging streak', + value: '$loggingStreak', + style: theme.textTheme.titleSmall, + ), + _Figure( + icon: Icons.local_fire_department, + color: status.warning, + semanticLabel: 'Adherence streak', + value: '$adherenceStreak', + style: theme.textTheme.titleSmall, + ), + ], ), - const SizedBox(height: 4), - Text( - 'This year: logged ${tally.loggedDays}/${tally.elapsedDays} days · ' - '${tally.adherentDays} within budget', - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall, + const SizedBox(height: AppSpacing.xs), + Wrap( + alignment: WrapAlignment.center, + spacing: AppSpacing.lg, + children: [ + _Figure( + icon: Icons.event_available, + color: theme.colorScheme.onSurfaceVariant, + semanticLabel: 'Days logged this year', + value: '${tally.loggedDays}/${tally.elapsedDays}', + style: theme.textTheme.bodySmall, + ), + _Figure( + icon: Icons.verified, + color: status.success, + semanticLabel: 'Days within budget', + value: '${tally.adherentDays}', + style: theme.textTheme.bodySmall, + ), + ], ), ], ); } } + +class _Figure extends StatelessWidget { + const _Figure({ + required this.icon, + required this.color, + required this.semanticLabel, + required this.value, + required this.style, + }); + + final IconData icon; + final Color color; + final String semanticLabel; + final String value; + final TextStyle? style; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 18, semanticLabel: semanticLabel), + const SizedBox(width: 4), + Text(value, style: style), + ], + ); +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index eaa81e4..30241b4 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.140+1 +version: 1.0.141+1 environment: sdk: ^3.12.2 diff --git a/app/test/keyboard_operability_test.dart b/app/test/keyboard_operability_test.dart index b8b0a7d..f51f8fb 100644 --- a/app/test/keyboard_operability_test.dart +++ b/app/test/keyboard_operability_test.dart @@ -22,6 +22,7 @@ import 'package:diet_guard_app/widgets/day_status_calendar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'label_finders.dart'; /// Landscape and *short* — the supported floor. Phone-portrait sizes, which is /// all the existing suite pinned, exercise neither constraint. @@ -176,7 +177,7 @@ void main() { // Delete used to be bound only to onLongPress, which has no keyboard // equivalent whatsoever. expect( - find.byTooltip('Delete entry'), + byIconLabel('Delete entry'), findsOneWidget, reason: 'delete needs a tab-reachable control, not long-press only', ); @@ -207,7 +208,7 @@ void main() { // Delete stays id-only to avoid ambiguous time+desc matches, so the // control must be absent rather than present-and-failing. - expect(find.byTooltip('Delete entry'), findsNothing); + expect(byIconLabel('Delete entry'), findsNothing); }); }); } diff --git a/app/test/screens/calendar_screen_test.dart b/app/test/screens/calendar_screen_test.dart index 92a917e..db2cd7b 100644 --- a/app/test/screens/calendar_screen_test.dart +++ b/app/test/screens/calendar_screen_test.dart @@ -8,23 +8,10 @@ import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; -const _monthNames = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', -]; - -String _label(DateTime d) => '${_monthNames[d.month - 1]} ${d.year}'; +String _label(DateTime d) => + '${d.month.toString().padLeft(2, '0')} · ${d.year}'; void main() { late Directory tempDir; @@ -33,9 +20,7 @@ void main() { tempDir = await Directory.systemTemp.createTemp('diet_guard_calendar_'); LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); await AppSettingsService.initForTesting(FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { @@ -70,7 +55,7 @@ void main() { 'button', (tester) async { await pumpScreen(tester); expect(find.widgetWithText(TextField, '2200'), findsOneWidget); - expect(find.widgetWithText(FilledButton, 'Edit'), findsOneWidget); + expect(controlByLabel('Edit'), findsOneWidget); final field = tester.widget(find.byType(TextField)); expect(field.enabled, isFalse); }); @@ -79,10 +64,10 @@ void main() { tester, ) async { await pumpScreen(tester); - await tester.tap(find.widgetWithText(FilledButton, 'Edit')); + await tester.tap(controlByLabel('Edit')); await tester.pump(); - expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); + expect(controlByLabel('Save'), findsOneWidget); final field = tester.widget(find.byType(TextField)); expect(field.enabled, isTrue); }); @@ -91,27 +76,27 @@ void main() { tester, ) async { await pumpScreen(tester); - await tester.tap(find.widgetWithText(FilledButton, 'Edit')); + await tester.tap(controlByLabel('Edit')); await tester.pump(); await tester.enterText(find.byType(TextField), ''); - await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.tap(controlByLabel('Save')); await tester.pump(); - expect(find.text('Enter a whole number of kcal.'), findsOneWidget); - expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); + expect(cueLabelled('Enter a whole number of kcal.'), findsOneWidget); + expect(controlByLabel('Save'), findsOneWidget); }); testWidgets('a zero value is rejected and editing stays open', ( tester, ) async { await pumpScreen(tester); - await tester.tap(find.widgetWithText(FilledButton, 'Edit')); + await tester.tap(controlByLabel('Edit')); await tester.pump(); await tester.enterText(find.byType(TextField), '0'); - await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.tap(controlByLabel('Save')); await tester.pump(); - expect(find.text('Budget must be a positive number.'), findsOneWidget); + expect(cueLabelled('Budget must be a positive number.'), findsOneWidget); }); testWidgets('saving a valid value persists it and reverts to read-only', ( @@ -123,14 +108,17 @@ void main() { // frame pumps, mirroring settings_screen_test.dart's device-flow tests. await tester.runAsync(() async { await pumpScreen(tester); - await tester.tap(find.widgetWithText(FilledButton, 'Edit')); + await tester.tap(controlByLabel('Edit')); await tester.pump(); await tester.enterText(find.byType(TextField), '1800'); - await tester.tap(find.widgetWithText(FilledButton, 'Save')); - await pumpUntil(tester, () => find.text('Saved.').evaluate().isNotEmpty); + await tester.tap(controlByLabel('Save')); + await pumpUntil( + tester, + () => cueLabelled('Saved.').evaluate().isNotEmpty, + ); - expect(find.text('Saved.'), findsOneWidget); - expect(find.widgetWithText(FilledButton, 'Edit'), findsOneWidget); + expect(cueLabelled('Saved.'), findsOneWidget); + expect(controlByLabel('Edit'), findsOneWidget); expect(AppSettingsService.dailyKcalGoal, 1800); final field = tester.widget(find.byType(TextField)); expect(field.enabled, isFalse); @@ -139,8 +127,8 @@ void main() { testWidgets('shows streak and year-to-date summary text', (tester) async { await pumpScreen(tester); - expect(find.textContaining('Logging streak:'), findsOneWidget); - expect(find.textContaining('This year:'), findsOneWidget); + expect(byIconLabel('Logging streak'), findsOneWidget); + expect(byIconLabel('Days logged this year'), findsOneWidget); }); testWidgets('month navigation steps the displayed month back and forward', ( diff --git a/app/test/screens/edit_entry_screen_foodbank_test.dart b/app/test/screens/edit_entry_screen_foodbank_test.dart index a3fe822..83193ff 100644 --- a/app/test/screens/edit_entry_screen_foodbank_test.dart +++ b/app/test/screens/edit_entry_screen_foodbank_test.dart @@ -9,6 +9,7 @@ import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; const _entry = FoodEntry( id: 'test-uuid', @@ -23,7 +24,6 @@ const _entry = FoodEntry( ); void main() { - late Directory tempDir; setUp(() async { @@ -119,7 +119,7 @@ void main() { '999', ); await settle(tester); - await tester.tap(find.text('Save')); + await tester.tap(byIconLabel('Save')); await settle(tester); final log = await LogStorageService.instance.readLog(); @@ -141,7 +141,7 @@ void main() { await tester.tap(find.text('pizza obok pracy')); await settle(tester); - expect(find.text('Edit meal'), findsOneWidget); + expect(find.byType(EditEntryScreen), findsOneWidget); expect(find.text('pizza obok pracy'), findsOneWidget); // Macros are pre-filled. expect(find.text('1200'), findsOneWidget); @@ -180,15 +180,16 @@ void main() { await tester.tap(find.text('pizza obok pracy').last); await settle(tester); - await tester.tap(find.text('Save')); + await tester.tap(byIconLabel('Save')); await settle(tester); - final saved = (await LogStorageService.instance.readLog())['2026-06-22']! - .first; + final saved = + (await LogStorageService.instance.readLog())['2026-06-22']!.first; expect( saved.kcal, 1200, - reason: 'the banked portion must save as itself, not rescaled by ' + reason: + 'the banked portion must save as itself, not rescaled by ' 'its own reference weight.', ); expect(saved.grams, 400); diff --git a/app/test/screens/edit_entry_screen_test.dart b/app/test/screens/edit_entry_screen_test.dart index ebbfb46..1c909fc 100644 --- a/app/test/screens/edit_entry_screen_test.dart +++ b/app/test/screens/edit_entry_screen_test.dart @@ -9,6 +9,7 @@ import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; const _entry = FoodEntry( id: 'test-uuid', @@ -23,7 +24,6 @@ const _entry = FoodEntry( ); void main() { - late Directory tempDir; setUp(() async { @@ -72,8 +72,8 @@ void main() { MaterialApp(home: EditEntryScreen(entry: _entry)), ); await settle(tester); - expect(find.text('Edit meal'), findsOneWidget); - expect(find.text('Save'), findsOneWidget); + expect(find.byType(EditEntryScreen), findsOneWidget); + expect(byIconLabel('Save'), findsOneWidget); }); }); testWidgets('shows error when description is cleared', (tester) async { @@ -92,10 +92,10 @@ void main() { matching: find.byType(TextField), ); await tester.enterText(descField, ''); - await tester.tap(find.text('Save')); + await tester.tap(byIconLabel('Save')); await settle(tester); - expect(find.text('Description cannot be empty.'), findsOneWidget); + expect(cueLabelled('Description cannot be empty.'), findsOneWidget); }); }); testWidgets('Save persists updated kcal to the log', (tester) async { @@ -110,13 +110,10 @@ void main() { // Change kcal. await tester.enterText( - find.ancestor( - of: find.text('1200'), - matching: find.byType(TextField), - ), + find.ancestor(of: find.text('1200'), matching: find.byType(TextField)), '999', ); - await tester.tap(find.text('Save')); + await tester.tap(byIconLabel('Save')); await settle(tester); final log = await LogStorageService.instance.readLog(); @@ -146,7 +143,7 @@ void main() { MaterialApp(home: EditEntryScreen(entry: legacy)), ); await settle(tester); - await tester.tap(find.text('Save')); + await tester.tap(byIconLabel('Save')); await settle(tester); final log = await LogStorageService.instance.readLog(); diff --git a/app/test/screens/food_bank/fb_screen_test.dart b/app/test/screens/food_bank/fb_screen_test.dart index c890031..40b4941 100644 --- a/app/test/screens/food_bank/fb_screen_test.dart +++ b/app/test/screens/food_bank/fb_screen_test.dart @@ -1,21 +1,22 @@ import 'package:diet_guard_app/models/food_bank_record.dart'; import 'package:diet_guard_app/screens/food_bank_screen.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/widgets/food_bank/fb_add_entry_dialog.dart'; +import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../label_finders.dart'; import 'fb_test_support.dart'; void main() { useTempFoodBankStores(); - testWidgets('shows empty-bank message when no entries exist', ( - tester, - ) async { + testWidgets('shows empty-bank message when no entries exist', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: FoodBankScreen())); await settle(tester); - expect(find.textContaining('Food bank is empty'), findsOneWidget); + expect(byIconLabel('Food bank is empty.'), findsOneWidget); }); }); @@ -50,18 +51,12 @@ void main() { await tester.tap(find.byType(FloatingActionButton)); await settle(tester); - expect(find.text('Add to food bank'), findsOneWidget); + expect(find.byType(AddEntryDialog), findsOneWidget); - await tester.enterText( - find.widgetWithText(TextField, 'Name'), - 'Test food', - ); - await tester.enterText( - find.widgetWithText(TextField, 'Kcal'), - '200', - ); + await tester.enterText(fieldByLabel('Name'), 'Test food'); + await tester.enterText(fieldByLabel('kcal'), '200'); - await tester.tap(find.text('Save to bank')); + await tester.tap(byIconLabel('Save to bank')); await settle(tester); // After saving, the screen reloads and shows the new entry. @@ -77,10 +72,10 @@ void main() { await tester.tap(find.byType(FloatingActionButton)); await settle(tester); - await tester.tap(find.text('Cancel')); + await tester.tap(byIconLabel('Cancel')); await settle(tester); - expect(find.textContaining('Food bank is empty'), findsOneWidget); + expect(byIconLabel('Food bank is empty.'), findsOneWidget); }); }); @@ -93,11 +88,11 @@ void main() { await settle(tester); // Tap save without entering a name. - await tester.tap(find.text('Save to bank')); + await tester.tap(byIconLabel('Save to bank')); await settle(tester); // Dialog stays open; no entry saved. - expect(find.text('Add to food bank'), findsOneWidget); + expect(find.byType(AddEntryDialog), findsOneWidget); }); }); @@ -156,17 +151,17 @@ void main() { await tester.tap(find.byIcon(Icons.filter_list)); await settle(tester); - expect(find.text('Filter & Sort'), findsOneWidget); + expect(find.byType(FbFilterSheet), findsOneWidget); // Type in the only TextField in the sheet (the name search field). await tester.enterText(find.byType(TextField).first, 'Oat'); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); // Sheet is closed; only the matching entry is visible. - expect(find.text('Filter & Sort'), findsNothing); + expect(find.byType(FbFilterSheet), findsNothing); expect(find.text('Oat'), findsOneWidget); expect(find.text('Egg'), findsNothing); }); @@ -194,10 +189,10 @@ void main() { await tester.tap(find.byIcon(Icons.filter_list)); await settle(tester); - await tester.tap(find.text('Clear all')); + await tester.tap(byIconLabel('Clear all')); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); expect(find.text('Walnut'), findsOneWidget); diff --git a/app/test/screens/food_bank/fb_sheet_test.dart b/app/test/screens/food_bank/fb_sheet_test.dart index 619a536..6e419b2 100644 --- a/app/test/screens/food_bank/fb_sheet_test.dart +++ b/app/test/screens/food_bank/fb_sheet_test.dart @@ -2,9 +2,11 @@ import 'package:diet_guard_app/models/food_bank_record.dart'; import 'package:diet_guard_app/models/food_entry.dart'; import 'package:diet_guard_app/screens/food_bank_screen.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; +import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../label_finders.dart'; import 'fb_test_support.dart'; void main() { @@ -68,21 +70,16 @@ void main() { await tester.tap(find.byIcon(Icons.filter_list)); await settle(tester); - expect(find.text('Filter & Sort'), findsOneWidget); + expect(find.byType(FbFilterSheet), findsOneWidget); - // With no sliders rendered, 'Sort by' and its dropdown are immediately - // visible — open the sort-field dropdown (shows 'Usage count' by default). - await tester.tap(find.text('Usage count')); + // Usage count is the default sort chip; pick the name chip instead. + await tester.tap(chipByLabel('Name')); await settle(tester); - // Tap 'Name' in the dropdown overlay. - await tester.tap(find.text('Name').last); + await tester.tap(byIconLabel('Apply')); await settle(tester); - await tester.tap(find.text('Apply')); - await settle(tester); - - expect(find.text('Filter & Sort'), findsNothing); + expect(find.byType(FbFilterSheet), findsNothing); expect(find.text('ZeroItem'), findsOneWidget); }); }); @@ -140,10 +137,10 @@ void main() { ); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); - expect(find.text('Filter & Sort'), findsNothing); + expect(find.byType(FbFilterSheet), findsNothing); }); }); @@ -169,13 +166,13 @@ void main() { await tester.tap(find.byIcon(Icons.filter_list)); await settle(tester); - expect(find.text('Filter & Sort'), findsOneWidget); + expect(find.byType(FbFilterSheet), findsOneWidget); // Default sort is count-descending; the direction icon is arrow_downward. await tester.tap(find.byIcon(Icons.arrow_downward)); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); expect(find.text('ZeroItem2'), findsOneWidget); diff --git a/app/test/screens/history/history_delete_undo_test.dart b/app/test/screens/history/history_delete_undo_test.dart new file mode 100644 index 0000000..cef2922 --- /dev/null +++ b/app/test/screens/history/history_delete_undo_test.dart @@ -0,0 +1,55 @@ +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 '../../label_finders.dart'; +import 'history_test_support.dart'; + +const _entry = FoodEntry( + id: 'keep-me', + time: '2026-06-22T12:00:00+02:00', + desc: 'lentil soup', + grams: 100, + kcal: 300, + proteinG: 15, + carbsG: 40, + fatG: 5, + source: 'manual', +); + +void main() { + useTempHistoryStores(); + + testWidgets('delete is immediate and the icon-only undo restores it', ( + tester, + ) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog({ + '2026-06-22': [_entry], + }); + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + await settle(tester); + expect(find.text('lentil soup'), findsOneWidget); + + await tester.tap(byIconLabel('Delete entry')); + // No confirmation dialog: the row vanishes and a snackbar offers undo. + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('lentil soup'), findsNothing); + expect(bySemLabel('Entry deleted'), findsOneWidget); + + await tester.tap(byIconLabel('Undo')); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('lentil soup'), findsOneWidget); + + final stored = await LogStorageService.instance.allEntriesNewestFirst(); + expect(stored.single.id, 'keep-me'); + expect(stored.single.deleted, isFalse); + }); + }); +} diff --git a/app/test/screens/history/history_filter_range_test.dart b/app/test/screens/history/history_filter_range_test.dart index 6ff4a45..044c60d 100644 --- a/app/test/screens/history/history_filter_range_test.dart +++ b/app/test/screens/history/history_filter_range_test.dart @@ -37,7 +37,8 @@ void main() { await settle(tester); // The raw key is shown as the day header when formatting fails. - expect(find.text('NOT-A-DATE'), findsOneWidget); + // Header falls back to the raw key; the entry row prints its time too. + expect(find.text('NOT-A-DATE'), findsNWidgets(2)); }); }, ); diff --git a/app/test/screens/history/history_filter_sheet_test.dart b/app/test/screens/history/history_filter_sheet_test.dart index b1d8ad5..3d043f6 100644 --- a/app/test/screens/history/history_filter_sheet_test.dart +++ b/app/test/screens/history/history_filter_sheet_test.dart @@ -1,9 +1,11 @@ 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:diet_guard_app/widgets/history/history_filter_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../label_finders.dart'; import 'history_test_support.dart'; void main() { @@ -34,10 +36,10 @@ void main() { await tester.tap(find.byIcon(Icons.filter_list)); await settle(tester); - await tester.tap(find.text('Clear all')); + await tester.tap(byIconLabel('Clear all')); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); expect(find.text('toast'), findsOneWidget); @@ -72,21 +74,16 @@ void main() { 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); + expect(find.byType(FilterSheet), findsOneWidget); // 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 tester.tap(byIconLabel('Apply')); await settle(tester); - expect(find.text('Filter & Sort'), findsNothing); + expect(find.byType(FilterSheet), findsNothing); expect(find.textContaining('porridge'), findsOneWidget); }); }); @@ -119,22 +116,14 @@ void main() { 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)); + // Pick the kcal sort chip (date is selected by default). + await tester.tap(chipByLabel('Kcal')); await settle(tester); - // Open the sort dropdown (shows 'Date' by default). - await tester.tap(find.text('Date')); + await tester.tap(byIconLabel('Apply')); 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.byType(FilterSheet), findsNothing); expect(find.textContaining('chicken'), findsOneWidget); }); }); @@ -175,15 +164,15 @@ void main() { await tester.tap(find.byIcon(Icons.filter_list)); await settle(tester); - expect(find.text('Filter & Sort'), findsOneWidget); + expect(find.byType(FilterSheet), findsOneWidget); - await tester.tap(find.widgetWithText(FilterChip, 'manual')); + await tester.tap(chipByLabel('manual')); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); - expect(find.text('Filter & Sort'), findsNothing); + expect(find.byType(FilterSheet), 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 index 676fe00..c4455e6 100644 --- a/app/test/screens/history/history_filter_sort_test.dart +++ b/app/test/screens/history/history_filter_sort_test.dart @@ -1,9 +1,11 @@ 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:diet_guard_app/widgets/history/history_filter_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../label_finders.dart'; import 'history_test_support.dart'; void main() { @@ -41,7 +43,7 @@ void main() { await settle(tester); // Day header shows 800 kcal total (300 + 500) vs the 2200 goal. - expect(find.textContaining('800 / 2200 kcal'), findsOneWidget); + expect(find.textContaining('800 / 2200'), findsOneWidget); // Both entries appear as list tiles. expect(find.text('breakfast'), findsOneWidget); expect(find.text('lunch'), findsOneWidget); @@ -146,7 +148,7 @@ void main() { await tester.tap(find.byIcon(Icons.filter_list)); await settle(tester); - expect(find.text('Filter & Sort'), findsOneWidget); + expect(find.byType(FilterSheet), findsOneWidget); }); }); @@ -191,11 +193,11 @@ void main() { await tester.enterText(find.byType(TextField).first, 'oat'); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); // Sheet is closed; only the matching entry is visible. - expect(find.text('Filter & Sort'), findsNothing); + expect(find.byType(FilterSheet), 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 index 8618c53..2d0a30a 100644 --- a/app/test/screens/history/history_filter_source_test.dart +++ b/app/test/screens/history/history_filter_source_test.dart @@ -1,9 +1,11 @@ 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:diet_guard_app/widgets/history/history_filter_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../label_finders.dart'; import 'history_test_support.dart'; void main() { @@ -36,16 +38,16 @@ void main() { // 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 tester.tap(chipByLabel('manual')); await settle(tester); - await tester.tap(find.widgetWithText(FilterChip, 'All')); + await tester.tap(chipByLabel('All')); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); - expect(find.text('Filter & Sort'), findsNothing); + expect(find.byType(FilterSheet), findsNothing); expect(find.textContaining('all source entry'), findsOneWidget); }); }); @@ -97,9 +99,7 @@ void main() { await settle(tester); // Protein slider. - await tester.ensureVisible( - find.byKey(const Key('protein-range-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, @@ -125,10 +125,10 @@ void main() { ); await settle(tester); - await tester.tap(find.text('Apply')); + await tester.tap(byIconLabel('Apply')); await settle(tester); - expect(find.text('Filter & Sort'), findsNothing); + expect(find.byType(FilterSheet), findsNothing); }); }); @@ -160,7 +160,7 @@ void main() { await settle(tester); // Open date range picker. - await tester.tap(find.widgetWithText(OutlinedButton, 'Any date')); + await tester.tap(controlByLabel('Date range')); await settle(tester); // The picker opens on the current month (no `currentDate` override @@ -171,9 +171,9 @@ void main() { // runs. final now = DateTime.now(); final expectedStart = DateTime(now.year, now.month); - await tester.tap(find.text('1')); + await tester.tap(find.text('1').last); await settle(tester); - await tester.tap(find.text('2')); + await tester.tap(find.text('2').last); await settle(tester); await tester.tap(find.text('Save')); await settle(tester); @@ -187,9 +187,9 @@ void main() { ); // "Clear date range" is now visible — tap it to exercise lines 639-642. - await tester.tap(find.text('Clear date range')); + await tester.tap(byIconLabel('Clear date range')); await settle(tester); - expect(find.text('Any date'), findsOneWidget); + expect(byIconLabel('Clear date range'), findsNothing); }); }, ); diff --git a/app/test/screens/history/history_screen_test.dart b/app/test/screens/history/history_screen_test.dart index 119d6ee..f97ac27 100644 --- a/app/test/screens/history/history_screen_test.dart +++ b/app/test/screens/history/history_screen_test.dart @@ -6,6 +6,7 @@ import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../label_finders.dart'; import 'history_test_support.dart'; void main() { @@ -61,8 +62,8 @@ void main() { // 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); + expect(find.text('2100 / 2200'), findsOneWidget); + expect(find.text('2100 / 2000'), findsOneWidget); }); }); @@ -71,7 +72,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); await settle(tester); - expect(find.text('Nothing logged yet.'), findsOneWidget); + expect(byIconLabel('Nothing logged yet.'), findsOneWidget); }); }); diff --git a/app/test/widgets/average_summary_row_test.dart b/app/test/widgets/average_summary_row_test.dart index 7705bb4..03f1ac9 100644 --- a/app/test/widgets/average_summary_row_test.dart +++ b/app/test/widgets/average_summary_row_test.dart @@ -2,6 +2,7 @@ import 'package:diet_guard_app/models/period_average.dart'; import 'package:diet_guard_app/widgets/average_summary_row.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; PeriodAverage period({double? avgKcal, AverageBand? band}) => PeriodAverage( start: '2026-01-05', @@ -25,13 +26,10 @@ void main() { month: period(avgKcal: 1900, band: AverageBand.under), ), ); - expect( - find.text( - 'Avg/day (to yesterday): week 2100 kcal (slightly over) · ' - 'month 1900 kcal (under)', - ), - findsOneWidget, - ); + expect(find.text('2100'), findsOneWidget); + expect(find.text('1900'), findsOneWidget); + expect(byIconLabel('slightly over'), findsOneWidget); + expect(byIconLabel('under'), findsOneWidget); }); testWidgets('rounds the average to whole kcal', (tester) async { @@ -42,16 +40,15 @@ void main() { month: period(avgKcal: 1900, band: AverageBand.under), ), ); - expect(find.textContaining('week 2101 kcal'), findsOneWidget); + expect(find.text('2101'), findsOneWidget); }); - testWidgets('an empty period reads "no data", never 0 kcal', (tester) async { - await pump( - tester, - AverageSummaryRow(week: period(), month: period()), - ); - expect(find.textContaining('0 kcal'), findsNothing); - expect(find.textContaining('week no data'), findsOneWidget); + testWidgets('an empty period shows the no-data glyph, never 0', ( + tester, + ) async { + await pump(tester, AverageSummaryRow(week: period(), month: period())); + expect(find.text('0'), findsNothing); + expect(byIconLabel('no data'), findsNWidgets(2)); }); testWidgets('the placeholder empty period renders safely', (tester) async { @@ -62,6 +59,6 @@ void main() { month: PeriodAverage.empty, ), ); - expect(find.textContaining('no data'), findsOneWidget); + expect(byIconLabel('no data'), findsNWidgets(2)); }); } diff --git a/app/test/widgets/day_status_calendar_test.dart b/app/test/widgets/day_status_calendar_test.dart index a597d6f..0366939 100644 --- a/app/test/widgets/day_status_calendar_test.dart +++ b/app/test/widgets/day_status_calendar_test.dart @@ -22,10 +22,12 @@ void main() { ), ), ); - expect(find.text('June 2026'), findsOneWidget); + expect(find.text('06 · 2026'), findsOneWidget); }); - testWidgets('shows day-of-week headers', (tester) async { + testWidgets('marks the weekend columns with two accent dots', ( + tester, + ) async { await tester.pumpWidget( wrapInApp( DayStatusCalendar( @@ -37,8 +39,10 @@ void main() { ), ), ); - expect(find.text('Mo'), findsOneWidget); - expect(find.text('Su'), findsOneWidget); + final dots = tester + .widgetList(find.byIcon(Icons.circle)) + .where((i) => i.size == 8); + expect(dots, hasLength(2)); }); testWidgets('calls onPrevMonth when left arrow tapped', (tester) async { @@ -177,9 +181,7 @@ void main() { expect(colorOfDay(tester, '20'), appTheme.colorScheme.error); }); - testWidgets('renders a month starting on Sunday correctly', ( - tester, - ) async { + testWidgets('renders a month starting on Sunday correctly', (tester) async { final sep2026 = DateTime(2026, 9); await tester.pumpWidget( wrapInApp( @@ -192,7 +194,7 @@ void main() { ), ), ); - expect(find.text('September 2026'), findsOneWidget); + expect(find.text('09 · 2026'), findsOneWidget); }); testWidgets('tapping a day calls onDaySelected with that date', ( diff --git a/app/test/widgets/streak_summary_row_test.dart b/app/test/widgets/streak_summary_row_test.dart index bef76ba..6c21f78 100644 --- a/app/test/widgets/streak_summary_row_test.dart +++ b/app/test/widgets/streak_summary_row_test.dart @@ -2,6 +2,7 @@ import 'package:diet_guard_app/models/day_status.dart'; import 'package:diet_guard_app/widgets/streak_summary_row.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); @@ -17,16 +18,11 @@ void main() { ), ), ); - expect( - find.text( - 'Logging streak: 3 days · Adherence streak: 2 days', - ), - findsOneWidget, - ); - expect( - find.text('This year: logged 10/14 days · 8 within budget'), - findsOneWidget, - ); + expect(find.text('3'), findsWidgets); + expect(byIconLabel('Logging streak'), findsOneWidget); + expect(byIconLabel('Adherence streak'), findsOneWidget); + expect(find.text('10/14'), findsOneWidget); + expect(find.text('8'), findsOneWidget); }); testWidgets('renders singular streak counts', (tester) async { @@ -39,10 +35,9 @@ void main() { ), ), ); - expect( - find.text('Logging streak: 1 day · Adherence streak: 1 day'), - findsOneWidget, - ); + expect(find.text('1'), findsWidgets); + expect(byIconLabel('Logging streak'), findsOneWidget); + expect(byIconLabel('Adherence streak'), findsOneWidget); }); testWidgets('renders zero streaks', (tester) async { @@ -55,10 +50,9 @@ void main() { ), ), ); - expect( - find.text('Logging streak: 0 days · Adherence streak: 0 days'), - findsOneWidget, - ); + expect(find.text('0'), findsWidgets); + expect(byIconLabel('Logging streak'), findsOneWidget); + expect(byIconLabel('Adherence streak'), findsOneWidget); }); }); }