Drop text from history, food bank, calendar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 08:21:05 +01:00
parent 83d05c637a
commit 737d09d37c
34 changed files with 864 additions and 612 deletions

View File

@ -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<CalendarScreen> {
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<CalendarScreen> {
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',
),
),
],
);

View File

@ -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<EditEntryScreen> {
List<FoodSuggestion> _suggestions = const [];
String _source = 'manual';
String? _status;
int _rejections = 0;
@override
void initState() {
@ -80,9 +82,7 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
}
Future<void> _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<EditEntryScreen> {
Future<void> _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<EditEntryScreen> {
@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<EditEntryScreen> {
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!,
),
),
],
],
),

View File

@ -153,15 +153,16 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
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<FoodBankScreen> {
? 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'),
),
);
}

View File

@ -159,15 +159,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
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<HistoryScreen> {
? 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.',
),

View File

@ -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'),
],
),
),
],
);

View File

@ -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),
],
],
);
}
}

View File

@ -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(),

View File

@ -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<T> {
/// 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<T> 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<SortOption<T>> 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),
),
],
);
}
}

View File

@ -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<NameSearchField> {
@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(

View File

@ -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<AddEntryDialog> {
);
}
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'),
),
],
);

View File

@ -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<SortOption<FbSortField>> _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<FbSortField>(
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<FbSortField>(
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),
],
),
);

View File

@ -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,

View File

@ -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)

View File

@ -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),
],
),
);

View File

@ -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<String?> 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<SortOption<HistorySortField>> _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<HistorySortField>(
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<HistorySortField>(
options: _options,
selected: sortField,
ascending: ascending,
onChanged: onSortChanged,
);
}

View File

@ -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;
}

View File

@ -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<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
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<void> _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<void> _restore() async {
await LogStorageService.instance.updateEntry(
entry.copyWithDeleted(),
entry,
);
await onDelete?.call();
}
}

View File

@ -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>() ?? 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),
],
);
}

View File

@ -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

View File

@ -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);
});
});
}

View File

@ -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<TextField>(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<TextField>(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<TextField>(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', (

View File

@ -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);

View File

@ -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();

View File

@ -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);

View File

@ -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);

View File

@ -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<void>.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<void>.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);
});
});
}

View File

@ -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));
});
},
);

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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);
});
},
);

View File

@ -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);
});
});

View File

@ -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));
});
}

View File

@ -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<Icon>(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', (

View File

@ -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);
});
});
}