Split history_screen and its test into widget siblings

history_screen.dart (1052) and history_screen_test.dart (1017) were a third of
the remaining over-cap work. Widget extraction only -- no route changes, since
routes are user-visible and repo_invariants_test.dart pins the navigation
surface.

  history_screen.dart          1052 -> 211
  lib/widgets/history/
    history_filter.dart         102  filter criteria + sort enum (pure data)
    history_grouping.dart       206  HistoryItem tree, apply/group/format
    history_list.dart           194  GroupedList, DayHeaderTile, EntryTile
    history_filter_sheet.dart   242  the sheet itself
    history_filter_fields.dart  168  search field, slider labels, range row
    history_filter_sort.dart    140  source chips + sort controls

Names that now cross a file boundary became public (HistoryItem, DayHeader,
EntryRow, buildGroupedItems, formatDay, dateRangeLabel, the tile widgets), each
with the doc comment public_member_api_docs requires -- 49 of them, written
rather than suppressed.

The four macro range sliders (kcal/protein/carbs/fat) were four near-identical
30-line blocks differing only in label, key, unit and which HistoryFilter pair
they read/write. They are now one FilterRangeRow taking min/max plus an
onChanged, which is most of how the sheet got under the cap.

The test file mirrors the source split one-for-one, six files under
test/screens/history/. Its setUp/tearDown and the settle() helper (needed by
every part, because HistoryScreen loads via an untracked initState future) moved
to history_test_support.dart rather than being copy-pasted six times.

Verified: 555 flutter tests (same count as before -- no test lost in the split),
flutter analyze clean over all of lib/ and test/, 701 python tests at 100%
branch coverage, `python -m diet_guard sync` -> 402 entries/exit 0, live
~/.local/share/diet_guard byte-identical. Over-cap files: 42 -> 40.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 19:35:19 +02:00
parent 38d66a8492
commit 895a87cf86
16 changed files with 2158 additions and 1874 deletions

View File

@ -1,276 +1,26 @@
/// Logged meal history with day grouping, filtering, and sorting.
///
/// The filter model, the grouped list and the filter sheet live in
/// `lib/widgets/history/` (split for the repo's 250-line cap); this file is the
/// screen that wires them together and owns the filter state.
library;
import 'dart:async';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/edit_entry_screen.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/day_status_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sheet.dart';
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
import 'package:diet_guard_app/widgets/history/history_list.dart';
import 'package:flutter/material.dart';
// ---------------------------------------------------------------------------
// Filter & sort state
// ---------------------------------------------------------------------------
/// Sort field for the history list.
enum HistorySortField {
/// Sort by entry date/time.
date,
/// Sort by calories.
kcal,
/// Sort by protein (g).
protein,
/// Sort by carbohydrates (g).
carbs,
/// Sort by fat (g).
fat,
/// Sort by description text.
description,
}
/// All active filter criteria; [isActive] is true when any criterion is set.
class HistoryFilter {
/// Creates a [HistoryFilter] with the given criteria.
HistoryFilter({
this.nameQuery = '',
this.dateRange,
this.minKcal,
this.maxKcal,
this.minProtein,
this.maxProtein,
this.minCarbs,
this.maxCarbs,
this.minFat,
this.maxFat,
this.source,
});
/// Substring match on the food description.
String nameQuery;
/// Optional date range filter.
DateTimeRange? dateRange;
/// Minimum kcal.
double? minKcal;
/// Maximum kcal.
double? maxKcal;
/// Minimum protein (g).
double? minProtein;
/// Maximum protein (g).
double? maxProtein;
/// Minimum carbs (g).
double? minCarbs;
/// Maximum carbs (g).
double? maxCarbs;
/// Minimum fat (g).
double? minFat;
/// Maximum fat (g).
double? maxFat;
/// null = all, or a source string from the log.
String? source;
/// True when any filter criterion is active.
bool get isActive =>
nameQuery.isNotEmpty ||
dateRange != null ||
minKcal != null ||
maxKcal != null ||
minProtein != null ||
maxProtein != null ||
minCarbs != null ||
maxCarbs != null ||
minFat != null ||
maxFat != null ||
source != null;
}
// ---------------------------------------------------------------------------
// List item sealed hierarchy for day-grouped rendering
// ---------------------------------------------------------------------------
sealed class _HistoryItem {}
final class _DayHeader extends _HistoryItem {
_DayHeader(
this.dateKey,
this.totalKcal,
this.entryCount,
this.totalProtein,
this.totalCarbs,
this.totalFat,
);
final String dateKey;
final double totalKcal;
final int entryCount;
final double totalProtein;
final double totalCarbs;
final double totalFat;
}
final class _EntryRow extends _HistoryItem {
_EntryRow(this.entry);
final FoodEntry entry;
}
// ---------------------------------------------------------------------------
// Pure filter / sort / group helpers
// ---------------------------------------------------------------------------
/// Applies [filter] and sort criteria to [entries] and returns the result.
/// Logged meal history: every entry, grouped by day, filterable and sortable.
///
/// Exposed as a top-level function for unit tests.
List<FoodEntry> applyHistoryFilter(
List<FoodEntry> entries,
HistoryFilter filter,
HistorySortField sortField, {
required bool ascending,
}) {
var result = [...entries];
if (filter.nameQuery.isNotEmpty) {
final q = filter.nameQuery.toLowerCase();
result = result.where((e) => e.desc.toLowerCase().contains(q)).toList();
}
if (filter.dateRange != null) {
final start = filter.dateRange!.start;
final end = filter.dateRange!.end.add(const Duration(days: 1));
result = result.where((e) {
final t = DateTime.tryParse(e.time);
return t != null && !t.isBefore(start) && t.isBefore(end);
}).toList();
}
if (filter.minKcal != null) {
result = result.where((e) => e.kcal >= filter.minKcal!).toList();
}
if (filter.maxKcal != null) {
result = result.where((e) => e.kcal <= filter.maxKcal!).toList();
}
if (filter.minProtein != null) {
result = result.where((e) => e.proteinG >= filter.minProtein!).toList();
}
if (filter.maxProtein != null) {
result = result.where((e) => e.proteinG <= filter.maxProtein!).toList();
}
if (filter.minCarbs != null) {
result = result.where((e) => e.carbsG >= filter.minCarbs!).toList();
}
if (filter.maxCarbs != null) {
result = result.where((e) => e.carbsG <= filter.maxCarbs!).toList();
}
if (filter.minFat != null) {
result = result.where((e) => e.fatG >= filter.minFat!).toList();
}
if (filter.maxFat != null) {
result = result.where((e) => e.fatG <= filter.maxFat!).toList();
}
if (filter.source != null) {
result = result.where((e) => e.source == filter.source).toList();
}
result.sort((a, b) {
int cmp;
switch (sortField) {
case HistorySortField.date:
final at = DateTime.tryParse(a.time) ?? DateTime(0);
final bt = DateTime.tryParse(b.time) ?? DateTime(0);
cmp = at.compareTo(bt);
case HistorySortField.kcal:
cmp = a.kcal.compareTo(b.kcal);
case HistorySortField.protein:
cmp = a.proteinG.compareTo(b.proteinG);
case HistorySortField.carbs:
cmp = a.carbsG.compareTo(b.carbsG);
case HistorySortField.fat:
cmp = a.fatG.compareTo(b.fatG);
case HistorySortField.description:
cmp = a.desc.compareTo(b.desc);
}
return ascending ? cmp : -cmp;
});
return result;
}
List<_HistoryItem> _buildGroupedItems(List<FoodEntry> entries) {
final byDay = <String, List<FoodEntry>>{};
for (final e in entries) {
final day = e.time.length >= 10 ? e.time.substring(0, 10) : 'unknown';
byDay.putIfAbsent(day, () => []).add(e);
}
final days = byDay.keys.toList()..sort((a, b) => b.compareTo(a));
final items = <_HistoryItem>[];
for (final day in days) {
final dayEntries = byDay[day]!;
final totalKcal = sumKcal(dayEntries);
final totalProtein = dayEntries.fold<double>(0, (s, e) => s + e.proteinG);
final totalCarbs = dayEntries.fold<double>(0, (s, e) => s + e.carbsG);
final totalFat = dayEntries.fold<double>(0, (s, e) => s + e.fatG);
items
..add(
_DayHeader(
day,
totalKcal,
dayEntries.length,
totalProtein,
totalCarbs,
totalFat,
),
)
..addAll(dayEntries.map(_EntryRow.new));
}
return items;
}
String _dateRangeLabel(DateTimeRange r) =>
'${r.start.toString().substring(0, 10)}'
' – ${r.end.toString().substring(0, 10)}';
String _formatDay(String dateKey) {
try {
final d = DateTime.parse(dateKey);
const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const mo = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return '${wd[d.weekday - 1]} ${d.day} ${mo[d.month - 1]} ${d.year}';
} on Exception {
return dateKey;
}
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
/// Shows every non-deleted logged entry, grouped by day, with optional
/// filtering and sorting.
/// Owns the live [HistoryFilter] and sort order; the sheet that edits them and
/// the list that renders the result are separate widgets under
/// `lib/widgets/history/`.
class HistoryScreen extends StatefulWidget {
/// Creates a [HistoryScreen].
///
@ -368,7 +118,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => _FilterSheet(
builder: (ctx, setSheet) => FilterSheet(
filter: draft,
sortField: draftSortField,
ascending: draftSortAscending,
@ -447,8 +197,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
: 'No entries match the current filter.',
),
)
: _GroupedList(
items: _buildGroupedItems(_displayed),
: GroupedList(
items: buildGroupedItems(_displayed),
onDeleteEntry: _load,
onEditEntry: _onEditEntry,
),
@ -459,594 +209,3 @@ class _HistoryScreenState extends State<HistoryScreen> {
// ---------------------------------------------------------------------------
// Grouped list widget
// ---------------------------------------------------------------------------
class _GroupedList extends StatelessWidget {
const _GroupedList({
required this.items,
required this.onDeleteEntry,
required this.onEditEntry,
});
final List<_HistoryItem> items;
final Future<void> Function() onDeleteEntry;
final Future<void> Function(FoodEntry) onEditEntry;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return switch (item) {
_DayHeader() => _DayHeaderTile(item),
_EntryRow() => _EntryTile(
item.entry,
onDelete: onDeleteEntry,
onEdit: () => onEditEntry(item.entry),
),
};
},
);
}
}
class _DayHeaderTile extends StatelessWidget {
const _DayHeaderTile(this.header);
final _DayHeader header;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// The budget that applied on *that* day, not today's -- a later budget
// change must not repaint months of history red.
final goal = BudgetHistoryService.schedule.forDay(header.dateKey);
final kcalColor = header.totalKcal > goal
? colorScheme.error
: colorScheme.primary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: colorScheme.surfaceContainerHighest,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_formatDay(header.dateKey),
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Text(
'${header.entryCount}'
' ${header.entryCount == 1 ? 'entry' : 'entries'}',
style: textTheme.bodySmall,
),
],
),
const SizedBox(height: 2),
Row(
children: [
Text(
'${header.totalKcal.round()} / $goal kcal',
style: textTheme.bodySmall?.copyWith(color: kcalColor),
),
const SizedBox(width: 8),
Text(
'P ${header.totalProtein.round()}g · '
'C ${header.totalCarbs.round()}g · '
'F ${header.totalFat.round()}g',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
);
}
}
class _EntryTile extends StatelessWidget {
const _EntryTile(this.entry, {this.onDelete, this.onEdit});
final FoodEntry entry;
/// Called after a confirmed delete so the parent can reload.
final Future<void> Function()? onDelete;
/// Called when the tile is tapped to open the edit screen.
final Future<void> Function()? onEdit;
@override
Widget build(BuildContext context) {
final canDelete = entry.id != null;
return ListTile(
leading: const Icon(Icons.restaurant),
title: Text(entry.desc),
subtitle: Text('${entry.time} • ${entry.source}'),
// Delete needs a focusable control, not just a long-press. Long-press has
// no keyboard equivalent at all, so on the desktop build (a Chrome --app
// window) deleting an entry was pointer-only: unreachable by keyboard and
// invisible to a screen reader. The trailing row keeps the kcal figure
// and adds an IconButton, which Tab reaches and Enter/Space activates.
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('${entry.kcal.toStringAsFixed(0)} kcal'),
if (canDelete)
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete entry',
onPressed: () => _confirmDelete(context),
),
],
),
// Any entry can be edited (legacy null-id entries gain a UUID on save).
// Delete remains id-only to avoid ambiguous time+desc matches.
onTap: () => onEdit?.call(),
// Retained as a redundant pointer shortcut, never the only route.
onLongPress: canDelete ? () => _confirmDelete(context) : null,
);
}
Future<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'),
),
],
),
);
if (confirmed == true) {
await LogStorageService.instance.deleteEntry(entry.id!);
await onDelete?.call();
}
}
}
// ---------------------------------------------------------------------------
// Filter sheet
// ---------------------------------------------------------------------------
class _FilterSheet extends StatelessWidget {
const _FilterSheet({
required this.filter,
required this.sortField,
required this.ascending,
required this.maxKcal,
required this.maxProtein,
required this.maxCarbs,
required this.maxFat,
required this.onFilterChanged,
required this.onSortChanged,
required this.onApply,
required this.onClear,
});
final HistoryFilter filter;
final HistorySortField sortField;
final bool ascending;
final double maxKcal;
final double maxProtein;
final double maxCarbs;
final double maxFat;
final void Function(HistoryFilter) onFilterChanged;
final void Function({required HistorySortField field, required bool asc})
onSortChanged;
final VoidCallback onApply;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
child: Row(
children: [
Expanded(
child: Text(
'Filter & Sort',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton(onPressed: onClear, child: const Text('Clear all')),
],
),
),
const Divider(),
Expanded(
child: SingleChildScrollView(
controller: scroll,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Name search
_NameSearchField(
initialQuery: filter.nameQuery,
onChanged: (v) {
filter.nameQuery = v;
onFilterChanged(filter);
},
),
const SizedBox(height: 16),
// Date range
Text(
'Date range',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
OutlinedButton.icon(
icon: const Icon(Icons.date_range),
label: Text(
filter.dateRange == null
? 'Any date'
: _dateRangeLabel(filter.dateRange!),
),
onPressed: () async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
initialDateRange: filter.dateRange,
);
if (picked != null) {
filter.dateRange = picked;
onFilterChanged(filter);
}
},
),
if (filter.dateRange != null)
TextButton(
onPressed: () {
filter.dateRange = null;
onFilterChanged(filter);
},
child: const Text('Clear date range'),
),
const SizedBox(height: 16),
// Kcal range
if (maxKcal > 0) ...[
Text(
'Kcal range',
style: Theme.of(context).textTheme.labelLarge,
),
_SliderEndpointLabels(
lo: '0',
hi: maxKcal.round().toString(),
),
RangeSlider(
key: const Key('kcal-range-slider'),
max: maxKcal,
values: RangeValues(
filter.minKcal ?? 0,
filter.maxKcal ?? maxKcal,
),
labels: RangeLabels(
(filter.minKcal ?? 0).toStringAsFixed(0),
(filter.maxKcal ?? maxKcal).toStringAsFixed(0),
),
onChanged: (v) {
filter.minKcal = v.start > 0 ? v.start : null;
filter.maxKcal = v.end < maxKcal ? v.end : null;
onFilterChanged(filter);
},
),
_SliderSelectedLabel(
'${(filter.minKcal ?? 0).round()}'
' – ${(filter.maxKcal ?? maxKcal).round()} kcal',
),
const SizedBox(height: 8),
],
// Protein range
if (maxProtein > 0) ...[
Text(
'Protein range (g)',
style: Theme.of(context).textTheme.labelLarge,
),
_SliderEndpointLabels(
lo: '0',
hi: '${maxProtein.round()}g',
),
RangeSlider(
key: const Key('protein-range-slider'),
max: maxProtein,
values: RangeValues(
filter.minProtein ?? 0,
filter.maxProtein ?? maxProtein,
),
labels: RangeLabels(
(filter.minProtein ?? 0).toStringAsFixed(0),
(filter.maxProtein ?? maxProtein).toStringAsFixed(0),
),
onChanged: (v) {
filter.minProtein = v.start > 0 ? v.start : null;
filter.maxProtein = v.end < maxProtein ? v.end : null;
onFilterChanged(filter);
},
),
_SliderSelectedLabel(
'${(filter.minProtein ?? 0).round()}'
' – ${(filter.maxProtein ?? maxProtein).round()}g',
),
const SizedBox(height: 8),
],
// Carbs range
if (maxCarbs > 0) ...[
Text(
'Carbs range (g)',
style: Theme.of(context).textTheme.labelLarge,
),
_SliderEndpointLabels(lo: '0', hi: '${maxCarbs.round()}g'),
RangeSlider(
key: const Key('carbs-range-slider'),
max: maxCarbs,
values: RangeValues(
filter.minCarbs ?? 0,
filter.maxCarbs ?? maxCarbs,
),
labels: RangeLabels(
(filter.minCarbs ?? 0).toStringAsFixed(0),
(filter.maxCarbs ?? maxCarbs).toStringAsFixed(0),
),
onChanged: (v) {
filter.minCarbs = v.start > 0 ? v.start : null;
filter.maxCarbs = v.end < maxCarbs ? v.end : null;
onFilterChanged(filter);
},
),
_SliderSelectedLabel(
'${(filter.minCarbs ?? 0).round()}'
' – ${(filter.maxCarbs ?? maxCarbs).round()}g',
),
const SizedBox(height: 8),
],
// Fat range
if (maxFat > 0) ...[
Text(
'Fat range (g)',
style: Theme.of(context).textTheme.labelLarge,
),
_SliderEndpointLabels(lo: '0', hi: '${maxFat.round()}g'),
RangeSlider(
key: const Key('fat-range-slider'),
max: maxFat,
values: RangeValues(
filter.minFat ?? 0,
filter.maxFat ?? maxFat,
),
labels: RangeLabels(
(filter.minFat ?? 0).toStringAsFixed(0),
(filter.maxFat ?? maxFat).toStringAsFixed(0),
),
onChanged: (v) {
filter.minFat = v.start > 0 ? v.start : null;
filter.maxFat = v.end < maxFat ? v.end : null;
onFilterChanged(filter);
},
),
_SliderSelectedLabel(
'${(filter.minFat ?? 0).round()}'
' – ${(filter.maxFat ?? maxFat).round()}g',
),
const SizedBox(height: 8),
],
// Source filter
Text('Source', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('All'),
selected: filter.source == null,
onSelected: (_) {
filter.source = null;
onFilterChanged(filter);
},
),
for (final src in ['manual', 'food bank'])
FilterChip(
label: Text(src),
selected: filter.source == src,
onSelected: (_) {
filter.source = src;
onFilterChanged(filter);
},
),
],
),
const SizedBox(height: 16),
// Sort
Text(
'Sort by',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButton<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),
),
],
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onApply,
child: const Text('Apply'),
),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Slider label helpers
// ---------------------------------------------------------------------------
/// Thin row showing the min (0) and max endpoint values for a range slider.
/// The filter sheet's name-search field, owning a persistent controller.
///
/// Stateful on purpose. The controller used to be built inside
/// `_FilterSheet.build` with the caret forced to the end of the text, and
/// `onChanged` rebuilds the sheet — so every keystroke discarded the controller
/// and re-slammed the caret to end-of-text. Appending worked, but Home/End and
/// arrow-key editing in the middle of the query were silently undone on each
/// character. Keeping the controller in State fixes that without changing how
/// the filter is plumbed.
class _NameSearchField extends StatefulWidget {
const _NameSearchField({required this.initialQuery, required this.onChanged});
/// Query text to seed the field with on first build only.
final String initialQuery;
/// Called on each edit with the new query.
final ValueChanged<String> onChanged;
@override
State<_NameSearchField> createState() => _NameSearchFieldState();
}
class _NameSearchFieldState extends State<_NameSearchField> {
late final TextEditingController _controller = TextEditingController(
text: widget.initialQuery,
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Search by name',
prefixIcon: Icon(Icons.search),
isDense: true,
),
onChanged: widget.onChanged,
);
}
}
class _SliderEndpointLabels extends StatelessWidget {
const _SliderEndpointLabels({required this.lo, required this.hi});
final String lo;
final String hi;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
return Row(
children: [
Text(lo, style: style),
const Spacer(),
Text(hi, style: style),
],
);
}
}
/// Centred text showing the currently-selected range value (always visible).
class _SliderSelectedLabel extends StatelessWidget {
const _SliderSelectedLabel(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}

View File

@ -0,0 +1,102 @@
/// Filter, sort and grouping model for the history screen.
///
/// Split out of `history_screen.dart` to hold the repo's 250-line cap. This is
/// the pure-data half -- no widgets -- so the sheet, the list and the screen
/// can each depend on it without depending on one another.
library;
import 'package:flutter/material.dart';
// ---------------------------------------------------------------------------
// Filter & sort state
// ---------------------------------------------------------------------------
/// Sort field for the history list.
enum HistorySortField {
/// Sort by entry date/time.
date,
/// Sort by calories.
kcal,
/// Sort by protein (g).
protein,
/// Sort by carbohydrates (g).
carbs,
/// Sort by fat (g).
fat,
/// Sort by description text.
description,
}
/// All active filter criteria; [isActive] is true when any criterion is set.
class HistoryFilter {
/// Creates a [HistoryFilter] with the given criteria.
HistoryFilter({
this.nameQuery = '',
this.dateRange,
this.minKcal,
this.maxKcal,
this.minProtein,
this.maxProtein,
this.minCarbs,
this.maxCarbs,
this.minFat,
this.maxFat,
this.source,
});
/// Substring match on the food description.
String nameQuery;
/// Optional date range filter.
DateTimeRange? dateRange;
/// Minimum kcal.
double? minKcal;
/// Maximum kcal.
double? maxKcal;
/// Minimum protein (g).
double? minProtein;
/// Maximum protein (g).
double? maxProtein;
/// Minimum carbs (g).
double? minCarbs;
/// Maximum carbs (g).
double? maxCarbs;
/// Minimum fat (g).
double? minFat;
/// Maximum fat (g).
double? maxFat;
/// null = all, or a source string from the log.
String? source;
/// True when any filter criterion is active.
bool get isActive =>
nameQuery.isNotEmpty ||
dateRange != null ||
minKcal != null ||
maxKcal != null ||
minProtein != null ||
maxProtein != null ||
minCarbs != null ||
maxCarbs != null ||
minFat != null ||
maxFat != null ||
source != null;
}
// ---------------------------------------------------------------------------
// List item sealed hierarchy for day-grouped rendering
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,168 @@
/// Small input widgets used by the history filter sheet.
///
/// Split from `history_filter_sheet.dart` for the repo's 250-line cap.
library;
import 'package:flutter/material.dart';
/// The free-text "search by name" field in the filter sheet.
///
/// Stateful only to own its [TextEditingController]; the query itself lives in
/// the screen's [HistoryFilter].
class NameSearchField extends StatefulWidget {
/// Creates a [NameSearchField] seeded with [initialQuery].
const NameSearchField({
required this.initialQuery,
required this.onChanged,
super.key,
});
/// Query text to seed the field with on first build only.
final String initialQuery;
/// Called on each edit with the new query.
final ValueChanged<String> onChanged;
@override
State<NameSearchField> createState() => NameSearchFieldState();
}
/// State for [NameSearchField], holding its text controller.
class NameSearchFieldState extends State<NameSearchField> {
late final TextEditingController _controller = TextEditingController(
text: widget.initialQuery,
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Search by name',
prefixIcon: Icon(Icons.search),
isDense: true,
),
onChanged: widget.onChanged,
);
}
}
/// The min/max captions printed under a range slider's two ends.
class SliderEndpointLabels extends StatelessWidget {
/// Creates endpoint labels reading [lo] on the left and [hi] on the right.
const SliderEndpointLabels({required this.lo, required this.hi, super.key});
/// Caption for the slider's lower bound.
final String lo;
/// Caption for the slider's upper bound.
final String hi;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
return Row(
children: [
Text(lo, style: style),
const Spacer(),
Text(hi, style: style),
],
);
}
}
/// Centred text showing the currently-selected range value (always visible).
class SliderSelectedLabel extends StatelessWidget {
/// Creates a centred [label] for the current slider selection.
const SliderSelectedLabel(this.label, {super.key});
/// The formatted selection text, e.g. `120 – 640 kcal`.
final String label;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}
/// One labelled min/max [RangeSlider] row in the filter sheet.
///
/// The four macro ranges (kcal, protein, carbs, fat) were four near-identical
/// 30-line blocks differing only in label, key, unit suffix and which pair of
/// [HistoryFilter] fields they read and write. Reading and writing go through
/// [min]/[max] and [onChanged] so this widget never needs to know which pair.
class FilterRangeRow extends StatelessWidget {
/// Creates a range row bounded by [maxValue].
const FilterRangeRow({
required this.label,
required this.sliderKey,
required this.maxValue,
required this.min,
required this.max,
required this.onChanged,
this.unit = '',
super.key,
});
/// Section heading, e.g. `Protein range (g)`.
final String label;
/// Widget key on the slider itself, so tests can drive one row.
final Key sliderKey;
/// Upper bound, derived from the logged data. A value of 0 hides the row.
final double maxValue;
/// Current lower bound, or null when unset (meaning 0).
final double? min;
/// Current upper bound, or null when unset (meaning [maxValue]).
final double? max;
/// Called with the new bounds; null means "no bound", not zero.
final void Function(double? min, double? max) onChanged;
/// Suffix appended to the endpoint and selection labels, e.g. `g`.
final String unit;
@override
Widget build(BuildContext context) {
if (maxValue <= 0) return const SizedBox.shrink();
final lo = min ?? 0;
final hi = max ?? maxValue;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'),
RangeSlider(
key: sliderKey,
max: maxValue,
values: RangeValues(lo, hi),
labels: RangeLabels(lo.toStringAsFixed(0), hi.toStringAsFixed(0)),
onChanged: (v) => onChanged(
v.start > 0 ? v.start : null,
v.end < maxValue ? v.end : null,
),
),
SliderSelectedLabel('${lo.round()} – ${hi.round()}$unit'),
const SizedBox(height: 8),
],
);
}
}

View File

@ -0,0 +1,242 @@
/// The bottom sheet that edits a [HistoryFilter].
///
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
library;
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:diet_guard_app/widgets/history/history_filter_fields.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sort.dart';
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
import 'package:flutter/material.dart';
/// The draggable bottom sheet that edits the history filter and sort order.
///
/// Stateless on purpose: the live [HistoryFilter] belongs to the screen, and
/// every edit goes back out through [onFilterChanged] rather than being held
/// here, so "apply" and "clear" cannot disagree with what is displayed.
class FilterSheet extends StatelessWidget {
/// Creates a [FilterSheet] over the screen's current filter and sort state.
const FilterSheet({
required this.filter,
required this.sortField,
required this.ascending,
required this.maxKcal,
required this.maxProtein,
required this.maxCarbs,
required this.maxFat,
required this.onFilterChanged,
required this.onSortChanged,
required this.onApply,
required this.onClear,
super.key,
});
/// The filter criteria currently being edited.
final HistoryFilter filter;
/// The field the list is sorted by.
final HistorySortField sortField;
/// Whether the sort runs ascending.
final bool ascending;
/// Upper bound of the calories slider, from the logged data.
final double maxKcal;
/// Upper bound of the protein slider, from the logged data.
final double maxProtein;
/// Upper bound of the carbohydrate slider, from the logged data.
final double maxCarbs;
/// Upper bound of the fat slider, from the logged data.
final double maxFat;
/// Called with a new filter on every edit.
final void Function(HistoryFilter) onFilterChanged;
/// Called when the sort field or direction changes.
final void Function({required HistorySortField field, required bool asc})
onSortChanged;
/// Called when the user applies the filter and dismisses the sheet.
final VoidCallback onApply;
/// Called when the user clears every criterion.
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
child: Row(
children: [
Expanded(
child: Text(
'Filter & Sort',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton(onPressed: onClear, child: const Text('Clear all')),
],
),
),
const Divider(),
Expanded(
child: SingleChildScrollView(
controller: scroll,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Name search
NameSearchField(
initialQuery: filter.nameQuery,
onChanged: (v) {
filter.nameQuery = v;
onFilterChanged(filter);
},
),
const SizedBox(height: 16),
// Date range
Text(
'Date range',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
OutlinedButton.icon(
icon: const Icon(Icons.date_range),
label: Text(
filter.dateRange == null
? 'Any date'
: dateRangeLabel(filter.dateRange!),
),
onPressed: () async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
initialDateRange: filter.dateRange,
);
if (picked != null) {
filter.dateRange = picked;
onFilterChanged(filter);
}
},
),
if (filter.dateRange != null)
TextButton(
onPressed: () {
filter.dateRange = null;
onFilterChanged(filter);
},
child: const Text('Clear date range'),
),
const SizedBox(height: 16),
FilterRangeRow(
label: 'Kcal range',
sliderKey: const Key('kcal-range-slider'),
maxValue: maxKcal,
min: filter.minKcal,
max: filter.maxKcal,
onChanged: (lo, hi) {
filter.minKcal = lo;
filter.maxKcal = hi;
onFilterChanged(filter);
},
),
FilterRangeRow(
label: 'Protein range (g)',
sliderKey: const Key('protein-range-slider'),
maxValue: maxProtein,
min: filter.minProtein,
max: filter.maxProtein,
unit: 'g',
onChanged: (lo, hi) {
filter.minProtein = lo;
filter.maxProtein = hi;
onFilterChanged(filter);
},
),
FilterRangeRow(
label: 'Carbs range (g)',
sliderKey: const Key('carbs-range-slider'),
maxValue: maxCarbs,
min: filter.minCarbs,
max: filter.maxCarbs,
unit: 'g',
onChanged: (lo, hi) {
filter.minCarbs = lo;
filter.maxCarbs = hi;
onFilterChanged(filter);
},
),
FilterRangeRow(
label: 'Fat range (g)',
sliderKey: const Key('fat-range-slider'),
maxValue: maxFat,
min: filter.minFat,
max: filter.maxFat,
unit: 'g',
onChanged: (lo, hi) {
filter.minFat = lo;
filter.maxFat = hi;
onFilterChanged(filter);
},
),
FilterSourceChips(
selected: filter.source,
onSelected: (src) {
filter.source = src;
onFilterChanged(filter);
},
),
const SizedBox(height: 16),
FilterSortControls(
sortField: sortField,
ascending: ascending,
onSortChanged: onSortChanged,
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onApply,
child: const Text('Apply'),
),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Slider label helpers
// ---------------------------------------------------------------------------
/// Thin row showing the min (0) and max endpoint values for a range slider.
/// The filter sheet's name-search field, owning a persistent controller.
///
/// Stateful on purpose. The controller used to be built inside
/// `FilterSheet.build` with the caret forced to the end of the text, and
/// `onChanged` rebuilds the sheet — so every keystroke discarded the controller
/// and re-slammed the caret to end-of-text. Appending worked, but Home/End and
/// arrow-key editing in the middle of the query were silently undone on each
/// character. Keeping the controller in State fixes that without changing how
/// the filter is plumbed.

View File

@ -0,0 +1,140 @@
/// The source-chip and sort controls at the foot of the history filter sheet.
///
/// Split from `history_filter_sheet.dart` for the repo's 250-line cap. Both are
/// stateless and report upward: the live filter and sort order belong to the
/// screen, so nothing here holds state that could disagree with what is shown.
library;
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:flutter/material.dart';
/// The known `source` values an entry can carry, in display order.
///
/// Kept next to the chips that render them rather than in the filter model:
/// this is a UI affordance (which sources are worth offering as one tap), not
/// a constraint on what `source` may contain.
const _sources = ['manual', 'food bank'];
/// Single-select chips filtering by entry source, with an "All" reset.
class FilterSourceChips extends StatelessWidget {
/// Creates source chips with [selected] currently active.
const FilterSourceChips({
required this.selected,
required this.onSelected,
super.key,
});
/// The active source, or null for "All".
final String? selected;
/// Called with the chosen source, or null when "All" is tapped.
final ValueChanged<String?> onSelected;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Source', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('All'),
selected: selected == null,
onSelected: (_) => onSelected(null),
),
for (final src in _sources)
FilterChip(
label: Text(src),
selected: selected == src,
onSelected: (_) => onSelected(src),
),
],
),
],
);
}
}
/// The sort-field dropdown plus its ascending/descending toggle.
class FilterSortControls extends StatelessWidget {
/// Creates sort controls reflecting [sortField] and [ascending].
const FilterSortControls({
required this.sortField,
required this.ascending,
required this.onSortChanged,
super.key,
});
/// The field the list is currently sorted by.
final HistorySortField sortField;
/// Whether the sort currently runs ascending.
final bool ascending;
/// Called when either the field or the direction changes.
final void Function({required HistorySortField field, required bool asc})
onSortChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Sort by', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButton<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),
),
],
),
],
);
}
}

View File

@ -0,0 +1,206 @@
/// Grouping, filtering and day-label helpers for the history list.
///
/// Split from `history_filter.dart` for the repo's 250-line cap: that file is
/// the filter *criteria*, this one is what those criteria do to a list.
library;
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/day_status_service.dart';
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:flutter/material.dart';
/// One row in the flattened history list: either a [DayHeader] or an
/// [EntryRow]. Sealed so the list's `switch` stays exhaustive.
sealed class HistoryItem {}
/// A day's summary row, shown above that day's entries.
final class DayHeader extends HistoryItem {
/// Creates a [DayHeader] with a day's pre-computed totals.
DayHeader(
this.dateKey,
this.totalKcal,
this.entryCount,
this.totalProtein,
this.totalCarbs,
this.totalFat,
);
/// The `YYYY-MM-DD` key this header groups.
final String dateKey;
/// Calories logged across the whole day.
final double totalKcal;
/// How many entries the day holds.
final int entryCount;
/// Protein grams logged across the whole day.
final double totalProtein;
/// Carbohydrate grams logged across the whole day.
final double totalCarbs;
/// Fat grams logged across the whole day.
final double totalFat;
}
/// A single logged meal within a day's group.
final class EntryRow extends HistoryItem {
/// Creates an [EntryRow] wrapping [entry].
EntryRow(this.entry);
/// The entry this row renders.
final FoodEntry entry;
}
// ---------------------------------------------------------------------------
// Pure filter / sort / group helpers
// ---------------------------------------------------------------------------
/// Applies [filter] and sort criteria to [entries] and returns the result.
///
/// Exposed as a top-level function for unit tests.
List<FoodEntry> applyHistoryFilter(
List<FoodEntry> entries,
HistoryFilter filter,
HistorySortField sortField, {
required bool ascending,
}) {
var result = [...entries];
if (filter.nameQuery.isNotEmpty) {
final q = filter.nameQuery.toLowerCase();
result = result.where((e) => e.desc.toLowerCase().contains(q)).toList();
}
if (filter.dateRange != null) {
final start = filter.dateRange!.start;
final end = filter.dateRange!.end.add(const Duration(days: 1));
result = result.where((e) {
final t = DateTime.tryParse(e.time);
return t != null && !t.isBefore(start) && t.isBefore(end);
}).toList();
}
if (filter.minKcal != null) {
result = result.where((e) => e.kcal >= filter.minKcal!).toList();
}
if (filter.maxKcal != null) {
result = result.where((e) => e.kcal <= filter.maxKcal!).toList();
}
if (filter.minProtein != null) {
result = result.where((e) => e.proteinG >= filter.minProtein!).toList();
}
if (filter.maxProtein != null) {
result = result.where((e) => e.proteinG <= filter.maxProtein!).toList();
}
if (filter.minCarbs != null) {
result = result.where((e) => e.carbsG >= filter.minCarbs!).toList();
}
if (filter.maxCarbs != null) {
result = result.where((e) => e.carbsG <= filter.maxCarbs!).toList();
}
if (filter.minFat != null) {
result = result.where((e) => e.fatG >= filter.minFat!).toList();
}
if (filter.maxFat != null) {
result = result.where((e) => e.fatG <= filter.maxFat!).toList();
}
if (filter.source != null) {
result = result.where((e) => e.source == filter.source).toList();
}
result.sort((a, b) {
int cmp;
switch (sortField) {
case HistorySortField.date:
final at = DateTime.tryParse(a.time) ?? DateTime(0);
final bt = DateTime.tryParse(b.time) ?? DateTime(0);
cmp = at.compareTo(bt);
case HistorySortField.kcal:
cmp = a.kcal.compareTo(b.kcal);
case HistorySortField.protein:
cmp = a.proteinG.compareTo(b.proteinG);
case HistorySortField.carbs:
cmp = a.carbsG.compareTo(b.carbsG);
case HistorySortField.fat:
cmp = a.fatG.compareTo(b.fatG);
case HistorySortField.description:
cmp = a.desc.compareTo(b.desc);
}
return ascending ? cmp : -cmp;
});
return result;
}
/// Flatten [entries] into day headers followed by that day's rows.
///
/// Each day's totals are summed once here rather than per-rebuild in the list.
List<HistoryItem> buildGroupedItems(List<FoodEntry> entries) {
final byDay = <String, List<FoodEntry>>{};
for (final e in entries) {
final day = e.time.length >= 10 ? e.time.substring(0, 10) : 'unknown';
byDay.putIfAbsent(day, () => []).add(e);
}
final days = byDay.keys.toList()..sort((a, b) => b.compareTo(a));
final items = <HistoryItem>[];
for (final day in days) {
final dayEntries = byDay[day]!;
final totalKcal = sumKcal(dayEntries);
final totalProtein = dayEntries.fold<double>(0, (s, e) => s + e.proteinG);
final totalCarbs = dayEntries.fold<double>(0, (s, e) => s + e.carbsG);
final totalFat = dayEntries.fold<double>(0, (s, e) => s + e.fatG);
items
..add(
DayHeader(
day,
totalKcal,
dayEntries.length,
totalProtein,
totalCarbs,
totalFat,
),
)
..addAll(dayEntries.map(EntryRow.new));
}
return items;
}
/// Render a picked date range as `YYYY-MM-DD – YYYY-MM-DD` for the filter chip.
String dateRangeLabel(DateTimeRange r) =>
'${r.start.toString().substring(0, 10)}'
' – ${r.end.toString().substring(0, 10)}';
/// Render a `YYYY-MM-DD` key as a short `Wed 12 Aug`-style label.
///
/// Falls back to the raw key when it cannot be parsed, so a malformed date
/// shows something rather than throwing inside a list build.
String formatDay(String dateKey) {
try {
final d = DateTime.parse(dateKey);
const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const mo = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return '${wd[d.weekday - 1]} ${d.day} ${mo[d.month - 1]} ${d.year}';
} on Exception {
return dateKey;
}
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
/// Shows every non-deleted logged entry, grouped by day, with optional
/// filtering and sorting.

View File

@ -0,0 +1,194 @@
/// The grouped day/entry list rendered by the history screen.
///
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
library;
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
import 'package:flutter/material.dart';
/// The history list: a flat [ListView] over pre-grouped day headers and rows.
///
/// Grouping happens in [buildGroupedItems] rather than here, so the list stays
/// a dumb renderer of whatever [HistoryItem] sequence it is handed.
class GroupedList extends StatelessWidget {
/// Creates a [GroupedList] over already-grouped [items].
const GroupedList({
required this.items,
required this.onDeleteEntry,
required this.onEditEntry,
super.key,
});
/// Day headers and entry rows, in display order.
final List<HistoryItem> items;
/// Called after a confirmed delete so the parent can reload.
final Future<void> Function() onDeleteEntry;
/// Called with the entry to edit when a row is tapped.
final Future<void> Function(FoodEntry) onEditEntry;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return switch (item) {
DayHeader() => DayHeaderTile(item),
EntryRow() => EntryTile(
item.entry,
onDelete: onDeleteEntry,
onEdit: () => onEditEntry(item.entry),
),
};
},
);
}
}
/// The sticky-looking header that starts each day's group of entries.
class DayHeaderTile extends StatelessWidget {
/// Creates a [DayHeaderTile] for [header].
const DayHeaderTile(this.header, {super.key});
/// The day this header summarises (date key, totals, budget for that day).
final DayHeader header;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
// The budget that applied on *that* day, not today's -- a later budget
// change must not repaint months of history red.
final goal = BudgetHistoryService.schedule.forDay(header.dateKey);
final kcalColor = header.totalKcal > goal
? colorScheme.error
: colorScheme.primary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: colorScheme.surfaceContainerHighest,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
formatDay(header.dateKey),
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Text(
'${header.entryCount}'
' ${header.entryCount == 1 ? 'entry' : 'entries'}',
style: textTheme.bodySmall,
),
],
),
const SizedBox(height: 2),
Row(
children: [
Text(
'${header.totalKcal.round()} / $goal kcal',
style: textTheme.bodySmall?.copyWith(color: kcalColor),
),
const SizedBox(width: 8),
Text(
'P ${header.totalProtein.round()}g · '
'C ${header.totalCarbs.round()}g · '
'F ${header.totalFat.round()}g',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
);
}
}
/// One logged meal: description, macros, and swipe/tap actions.
class EntryTile extends StatelessWidget {
/// Creates an [EntryTile] for [entry].
const EntryTile(this.entry, {super.key, this.onDelete, this.onEdit});
/// The entry this tile renders.
final FoodEntry entry;
/// Called after a confirmed delete so the parent can reload.
final Future<void> Function()? onDelete;
/// Called when the tile is tapped to open the edit screen.
final Future<void> Function()? onEdit;
@override
Widget build(BuildContext context) {
final canDelete = entry.id != null;
return ListTile(
leading: const Icon(Icons.restaurant),
title: Text(entry.desc),
subtitle: Text('${entry.time} • ${entry.source}'),
// Delete needs a focusable control, not just a long-press. Long-press has
// no keyboard equivalent at all, so on the desktop build (a Chrome --app
// window) deleting an entry was pointer-only: unreachable by keyboard and
// invisible to a screen reader. The trailing row keeps the kcal figure
// and adds an IconButton, which Tab reaches and Enter/Space activates.
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('${entry.kcal.toStringAsFixed(0)} kcal'),
if (canDelete)
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete entry',
onPressed: () => _confirmDelete(context),
),
],
),
// Any entry can be edited (legacy null-id entries gain a UUID on save).
// Delete remains id-only to avoid ambiguous time+desc matches.
onTap: () => onEdit?.call(),
// Retained as a redundant pointer shortcut, never the only route.
onLongPress: canDelete ? () => _confirmDelete(context) : null,
);
}
Future<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'),
),
],
),
);
if (confirmed == true) {
await LogStorageService.instance.deleteEntry(entry.id!);
await onDelete?.call();
}
}
}
// ---------------------------------------------------------------------------
// Filter sheet
// ---------------------------------------------------------------------------

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.16+1
version: 1.0.17+1
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,222 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'history_test_support.dart';
void main() {
useTempHistoryStores();
group('applyHistoryFilter', () {
final entries = [
const FoodEntry(
id: 'a',
time: '2026-06-20T08:00:00+02:00',
desc: 'Apple',
grams: 100,
kcal: 80,
proteinG: 0.5,
carbsG: 20,
fatG: 0.3,
source: 'manual',
),
const FoodEntry(
id: 'b',
time: '2026-06-21T12:00:00+02:00',
desc: 'Banana smoothie',
grams: 250,
kcal: 200,
proteinG: 3,
carbsG: 40,
fatG: 1,
source: 'food bank',
),
const FoodEntry(
id: 'c',
time: '2026-06-22T20:00:00+02:00',
desc: 'Chicken breast',
grams: 150,
kcal: 230,
proteinG: 45,
carbsG: 0,
fatG: 5,
source: 'meal',
),
];
test('no filter returns all entries sorted by date descending', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['c', 'b', 'a']);
});
test('nameQuery filters by case-insensitive substring', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(nameQuery: 'an'),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['b']); // 'Banana smoothie'
});
test('minKcal and maxKcal filter by kcal range', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(minKcal: 100, maxKcal: 210),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['b']); // 200 kcal
});
test('minProtein filters by protein', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(minProtein: 10),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['c']); // 45g protein
});
test('maxCarbs filters by carbs', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(maxCarbs: 5),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['c']); // 0 carbs
});
test('minFat and maxFat filter by fat', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(minFat: 0.4, maxFat: 2),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['b']); // fat=1
});
test('source filter keeps only matching source', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(source: 'meal'),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['c']);
});
test('dateRange filter includes only entries within range', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(
dateRange: DateTimeRange(
start: DateTime(2026, 6, 21),
end: DateTime(2026, 6, 21),
),
),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['b']);
});
test('sort ascending by kcal', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(),
HistorySortField.kcal,
ascending: true,
);
expect(result.map((e) => e.id), ['a', 'b', 'c']);
});
test('sort descending by protein', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(),
HistorySortField.protein,
ascending: false,
);
expect(result.first.id, 'c'); // 45g
});
test('sort by description ascending', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(),
HistorySortField.description,
ascending: true,
);
// Apple, Banana smoothie, Chicken breast
expect(result.map((e) => e.id), ['a', 'b', 'c']);
});
test('sort by fat', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(),
HistorySortField.fat,
ascending: true,
);
expect(result.first.id, 'a'); // fat=0.3
});
test('sort by carbs descending', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(),
HistorySortField.carbs,
ascending: false,
);
expect(result.first.id, 'b'); // 40g carbs
});
test('HistoryFilter.isActive is false when nothing is set', () {
expect(HistoryFilter().isActive, isFalse);
});
test('HistoryFilter.isActive is true when nameQuery is set', () {
expect(HistoryFilter(nameQuery: 'x').isActive, isTrue);
});
test('HistoryFilter.isActive is true when source is set', () {
expect(HistoryFilter(source: 'manual').isActive, isTrue);
});
test('maxProtein filters by protein', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(maxProtein: 5),
HistorySortField.date,
ascending: false,
);
// Apple (0.5 g) and Banana (3 g) have protein ≤ 5 g.
expect(result.map((e) => e.id), ['b', 'a']);
});
test('minCarbs filters by carbs', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(minCarbs: 15),
HistorySortField.date,
ascending: false,
);
// Banana (40 g) and Apple (20 g) have carbs ≥ 15 g.
expect(result.map((e) => e.id), ['b', 'a']);
});
});
// ---------------------------------------------------------------------------
// Widget-level — day grouping and filter badge
// ---------------------------------------------------------------------------
}

View File

@ -0,0 +1,44 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'history_test_support.dart';
void main() {
useTempHistoryStores();
testWidgets(
'_formatDay falls back to raw key for an unparsable date (line 245)',
(tester) async {
await tester.runAsync(() async {
// The day key is e.time.substring(0, 10). Writing an entry whose
// `time` field can't be parsed by DateTime.parse exercises the
// `on Exception` fallback in _formatDay (line 245), which returns the
// raw key unchanged. 'NOT-A-DATE' is exactly 10 chars so substring
// doesn't truncate it.
await LogStorageService.instance.writeLog({
'NOT-A-DATE': [
const FoodEntry(
id: 'bad1',
time: 'NOT-A-DATE',
desc: 'bad date entry',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
// The raw key is shown as the day header when formatting fails.
expect(find.text('NOT-A-DATE'), findsOneWidget);
});
},
);
}

View File

@ -0,0 +1,191 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'history_test_support.dart';
void main() {
useTempHistoryStores();
testWidgets('filter sheet Clear all resets draft then Apply shows all', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [
const FoodEntry(
id: 'a',
time: '2026-06-22T08:00:00+02:00',
desc: 'toast',
grams: 100,
kcal: 200,
proteinG: 7,
carbsG: 35,
fatG: 3,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
await tester.tap(find.text('Clear all'));
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('toast'), findsOneWidget);
});
});
testWidgets('filter sheet sort direction toggle fires onSortChanged', (
tester,
) async {
await tester.runAsync(() async {
// Zero macros: no RangeSliders render, so the sort section is
// immediately visible.
await LogStorageService.instance.writeLog({
'2026-06-20': [
const FoodEntry(
id: 'sd1',
time: '2026-06-20T09:00:00+02:00',
desc: 'porridge',
grams: 200,
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// The sort section is ~8px below the fold even with zero macros — scroll
// it into view before tapping the direction button.
await tester.drag(find.byType(ListView).last, const Offset(0, -120));
await settle(tester);
// Default sort is date-descending; direction icon is arrow_downward.
await tester.tap(find.byIcon(Icons.arrow_downward));
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.textContaining('porridge'), findsOneWidget);
});
});
testWidgets('filter sheet sort field dropdown changes sort field', (
tester,
) async {
await tester.runAsync(() async {
// Zero macros: no RangeSliders render, so the sort section is
// immediately visible.
await LogStorageService.instance.writeLog({
'2026-06-21': [
const FoodEntry(
id: 'sf1',
time: '2026-06-21T12:00:00+02:00',
desc: 'chicken',
grams: 150,
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
// Scroll just enough to make the sort section visible.
await tester.drag(find.byType(ListView).last, const Offset(0, -120));
await settle(tester);
// Open the sort dropdown (shows 'Date' by default).
await tester.tap(find.text('Date'));
await settle(tester);
// Select 'Kcal' from the dropdown overlay.
await tester.tap(find.text('Kcal').last);
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.textContaining('chicken'), findsOneWidget);
});
});
testWidgets('filter sheet source chip filters by source', (tester) async {
await tester.runAsync(() async {
// Zero macros: no sliders, source chips appear right after date button.
await LogStorageService.instance.writeLog({
'2026-06-23': [
const FoodEntry(
id: 'src1',
time: '2026-06-23T08:00:00+02:00',
desc: 'manual meal',
grams: 100,
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'manual',
),
const FoodEntry(
id: 'src2',
time: '2026-06-23T12:00:00+02:00',
desc: 'bank meal',
grams: 100,
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'food bank',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
await tester.tap(find.widgetWithText(FilterChip, 'manual'));
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.textContaining('manual meal'), findsOneWidget);
expect(find.textContaining('bank meal'), findsNothing);
});
});
}

View File

@ -0,0 +1,203 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'history_test_support.dart';
void main() {
useTempHistoryStores();
testWidgets('shows day headers with date and total kcal', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [
const FoodEntry(
id: 'a',
time: '2026-06-22T08:00:00+02:00',
desc: 'breakfast',
grams: 100,
kcal: 300,
proteinG: 10,
carbsG: 40,
fatG: 5,
source: 'manual',
),
const FoodEntry(
id: 'b',
time: '2026-06-22T12:00:00+02:00',
desc: 'lunch',
grams: 200,
kcal: 500,
proteinG: 20,
carbsG: 60,
fatG: 10,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
// Day header shows 800 kcal total (300 + 500) vs the 2200 goal.
expect(find.textContaining('800 / 2200 kcal'), findsOneWidget);
// Both entries appear as list tiles.
expect(find.text('breakfast'), findsOneWidget);
expect(find.text('lunch'), findsOneWidget);
});
});
testWidgets('filter icon badge appears when filter is active', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [
const FoodEntry(
id: 'x',
time: '2026-06-22T08:00:00+02:00',
desc: 'oat',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 15,
fatG: 2,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
// No filter active — the dot Container is absent.
// We look for a small Container widget in the Stack above the
// filter icon.
expect(
find.byWidgetPredicate((w) {
if (w is Container) {
final d = w.decoration;
if (d is BoxDecoration) {
return d.shape == BoxShape.circle && d.color != null;
}
}
return false;
}),
findsNothing,
);
});
});
testWidgets('shows "no entries match" when filter eliminates all results', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [
const FoodEntry(
id: 'x',
time: '2026-06-22T08:00:00+02:00',
desc: 'oat',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 15,
fatG: 2,
source: 'manual',
),
],
});
// Build a custom wrapper that injects a filter through the state.
// Easiest: extend HistoryScreen is not possible (private state), so we
// test via the pure `applyHistoryFilter` function instead, which is
// already covered above. This test verifies the "no match" empty-state
// message path through the widget.
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
// Verify the normal path renders the entry.
expect(find.text('oat'), findsOneWidget);
});
});
testWidgets('filter sheet opens and renders search field', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [
const FoodEntry(
id: 'a',
time: '2026-06-22T08:00:00+02:00',
desc: 'oat',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 15,
fatG: 2,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
});
});
testWidgets('filter sheet Apply filters results and closes sheet', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [
const FoodEntry(
id: 'a',
time: '2026-06-22T08:00:00+02:00',
desc: 'oat porridge',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 15,
fatG: 2,
source: 'manual',
),
const FoodEntry(
id: 'b',
time: '2026-06-22T12:00:00+02:00',
desc: 'chicken breast',
grams: 150,
kcal: 250,
proteinG: 40,
carbsG: 0,
fatG: 5,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
// Type in the search field (first TextField in the sheet).
await tester.enterText(find.byType(TextField).first, 'oat');
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
// Sheet is closed; only the matching entry is visible.
expect(find.text('Filter & Sort'), findsNothing);
expect(find.text('oat porridge'), findsOneWidget);
expect(find.text('chicken breast'), findsNothing);
});
});
}

View File

@ -0,0 +1,196 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'history_test_support.dart';
void main() {
useTempHistoryStores();
testWidgets('filter sheet source All chip fires onSelected callback', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-25': [
const FoodEntry(
id: 'sa1',
time: '2026-06-25T08:00:00+02:00',
desc: 'all source entry',
grams: 100,
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
// Tap 'manual' to set a source filter, then 'All' to reset it.
// Tapping 'All' when source is not null covers lines 798-800.
await tester.tap(find.widgetWithText(FilterChip, 'manual'));
await settle(tester);
await tester.tap(find.widgetWithText(FilterChip, 'All'));
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.textContaining('all source entry'), findsOneWidget);
});
});
testWidgets('filter sheet RangeSlider onChanged callbacks fire', (
tester,
) async {
await tester.runAsync(() async {
// Non-zero macros: all four RangeSliders appear in the filter sheet.
await LogStorageService.instance.writeLog({
'2026-06-26': [
const FoodEntry(
id: 'rs1',
time: '2026-06-26T08:00:00+02:00',
desc: 'slider test entry',
grams: 100,
kcal: 300,
proteinG: 20,
carbsG: 40,
fatG: 10,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
// tester.drag(finder, offset) fails for RangeSliders inside a modal
// overlay because its internal _maybeViewOf ancestor search cannot find
// a View ancestor through the overlay's render subtree. Use
// getRect()+dragFrom() instead (resolves via renderObjectOf, no
// _maybeViewOf call).
//
// The filter sheet uses SingleChildScrollView+Column, so all four
// sliders are always in the widget tree. ensureVisible() scrolls each
// one into the viewport before getRect() is called.
// Kcal slider.
await tester.ensureVisible(find.byKey(const Key('kcal-range-slider')));
await settle(tester);
await tester.dragFrom(
tester.getRect(find.byKey(const Key('kcal-range-slider'))).center,
const Offset(-30, 0),
);
await settle(tester);
// Protein slider.
await tester.ensureVisible(
find.byKey(const Key('protein-range-slider')),
);
await settle(tester);
await tester.dragFrom(
tester.getRect(find.byKey(const Key('protein-range-slider'))).center,
const Offset(-30, 0),
);
await settle(tester);
// Carbs slider.
await tester.ensureVisible(find.byKey(const Key('carbs-range-slider')));
await settle(tester);
await tester.dragFrom(
tester.getRect(find.byKey(const Key('carbs-range-slider'))).center,
const Offset(-30, 0),
);
await settle(tester);
// Fat slider.
await tester.ensureVisible(find.byKey(const Key('fat-range-slider')));
await settle(tester);
await tester.dragFrom(
tester.getRect(find.byKey(const Key('fat-range-slider'))).center,
const Offset(-30, 0),
);
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
});
});
testWidgets(
'date range picker selection shows _dateRangeLabel and Clear button '
'(lines 232-234, 639-642)',
(tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-26': [
const FoodEntry(
id: 'dr1',
time: '2026-06-26T08:00:00+02:00',
desc: 'range test',
grams: 100,
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 5,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
// Open date range picker.
await tester.tap(find.widgetWithText(OutlinedButton, 'Any date'));
await settle(tester);
// The picker opens on the current month (no `currentDate` override
// and a null `initialDateRange`) and its `lastDate` is capped at
// tomorrow, so days "10"+ aren't always selectable this early in a
// month. Days "1" and "2" of the displayed month are always within
// [firstDate, lastDate] regardless of which day it is when the test
// runs.
final now = DateTime.now();
final expectedStart = DateTime(now.year, now.month);
await tester.tap(find.text('1'));
await settle(tester);
await tester.tap(find.text('2'));
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
// After a successful selection the filter button label shows the
// formatted range via _dateRangeLabel (lines 232-234). Use a date-
// specific prefix so the kcal slider's "0 – N kcal" label is excluded.
expect(
find.textContaining(expectedStart.toString().substring(0, 10)),
findsOneWidget,
);
// "Clear date range" is now visible — tap it to exercise lines 639-642.
await tester.tap(find.text('Clear date range'));
await settle(tester);
expect(find.text('Any date'), findsOneWidget);
});
},
);
}

View File

@ -0,0 +1,191 @@
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'history_test_support.dart';
void main() {
useTempHistoryStores();
testWidgets('a day header shows the budget that applied on that day', (
tester,
) async {
await tester.runAsync(() async {
// 2200 until 2026-06-15, 2000 from then on.
await BudgetHistoryService.instance.applyMerged(const [
BudgetEntry(
effectiveFrom: kEpochDay,
kcal: 2200,
editedAt: '2026-01-01T00:00:00.000Z',
),
BudgetEntry(
effectiveFrom: '2026-06-15',
kcal: 2000,
editedAt: '2026-06-15T00:00:00.000Z',
),
]);
await LogStorageService.instance.writeLog({
'2026-06-01': [
const FoodEntry(
id: 'before',
time: '2026-06-01T08:00:00+02:00',
desc: 'before the cut',
grams: 100,
kcal: 2100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
),
],
'2026-06-20': [
const FoodEntry(
id: 'after',
time: '2026-06-20T08:00:00+02:00',
desc: 'after the cut',
grams: 100,
kcal: 2100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
// Same 2100 kcal on both days, judged against different budgets --
// lowering the budget must not repaint the earlier day.
expect(find.text('2100 / 2200 kcal'), findsOneWidget);
expect(find.text('2100 / 2000 kcal'), findsOneWidget);
});
});
testWidgets('shows a message when nothing has been logged', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
expect(find.text('Nothing logged yet.'), findsOneWidget);
});
});
testWidgets('lists logged entries newest first, excluding tombstones', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-01': [
const FoodEntry(
id: 'old',
time: '2026-06-01T08:00:00+02:00',
desc: 'old breakfast',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
),
],
'2026-06-22': [
const FoodEntry(
id: 'new',
time: '2026-06-22T20:00:00+02:00',
desc: 'new dinner',
grams: 100,
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 4,
source: 'manual',
),
const FoodEntry(
id: 'gone',
time: '2026-06-22T12:00:00+02:00',
desc: 'undone lunch',
grams: 100,
kcal: 300,
proteinG: 1,
carbsG: 1,
fatG: 1,
source: 'manual',
deleted: true,
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
expect(find.text('new dinner'), findsOneWidget);
expect(find.text('old breakfast'), findsOneWidget);
expect(find.text('undone lunch'), findsNothing);
final tiles = tester.widgetList<ListTile>(find.byType(ListTile)).toList();
expect((tiles[0].title! as Text).data, 'new dinner');
expect((tiles[1].title! as Text).data, 'old breakfast');
});
});
testWidgets(
'initialDateRange pre-filters to just that day, matching a Calendar '
'screen day tap',
(tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-01': [
const FoodEntry(
id: 'other-day',
time: '2026-06-01T08:00:00+02:00',
desc: 'other day breakfast',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
),
],
'2026-06-22': [
const FoodEntry(
id: 'target-day',
time: '2026-06-22T20:00:00+02:00',
desc: 'target day dinner',
grams: 100,
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 4,
source: 'manual',
),
],
});
await tester.pumpWidget(
MaterialApp(
home: HistoryScreen(
initialDateRange: DateTimeRange(
start: DateTime(2026, 6, 22),
end: DateTime(2026, 6, 22),
),
),
),
);
await settle(tester);
expect(find.text('target day dinner'), findsOneWidget);
expect(find.text('other day breakfast'), findsNothing);
});
},
);
// ---------------------------------------------------------------------------
// applyHistoryFilter — pure function tests (no widget required)
// ---------------------------------------------------------------------------
}

View File

@ -0,0 +1,43 @@
/// Shared setup for the history screen's split test files.
///
/// `history_screen_test.dart` was one 1017-line file; splitting it for the
/// repo's 250-line cap left every part needing the same temp-dir isolation and
/// the same settle helper, so they live here rather than being copy-pasted six
/// times (which is how two copies drift and one silently stops isolating).
library;
import 'dart:io';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter_test/flutter_test.dart';
/// Point the log and budget-history services at a fresh temp dir per test, and
/// tear it down afterwards. Call once at the top of a file's `main()`.
void useTempHistoryStores() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_history_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
});
tearDown(() async {
LogStorageService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
}
/// Pump until the screen has finished its untracked initState load.
///
/// HistoryScreen loads via a fire-and-forget Future in initState that Flutter's
/// frame scheduler does not track -- see log_meal_screen_test.dart for the same
/// issue. Every test therefore runs inside runAsync() with a short real delay
/// before settling.
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}

File diff suppressed because it is too large Load Diff