mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:07:40 +01:00
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:
parent
83d05c637a
commit
737d09d37c
@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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!,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
),
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
124
app/lib/widgets/filters/filter_chrome.dart
Normal file
124
app/lib/widgets/filters/filter_chrome.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
55
app/test/screens/history/history_delete_undo_test.dart
Normal file
55
app/test/screens/history/history_delete_undo_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user