mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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:
parent
38d66a8492
commit
895a87cf86
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
102
app/lib/widgets/history/history_filter.dart
Normal file
102
app/lib/widgets/history/history_filter.dart
Normal 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
|
||||
// ---------------------------------------------------------------------------
|
||||
168
app/lib/widgets/history/history_filter_fields.dart
Normal file
168
app/lib/widgets/history/history_filter_fields.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
242
app/lib/widgets/history/history_filter_sheet.dart
Normal file
242
app/lib/widgets/history/history_filter_sheet.dart
Normal 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.
|
||||
140
app/lib/widgets/history/history_filter_sort.dart
Normal file
140
app/lib/widgets/history/history_filter_sort.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
206
app/lib/widgets/history/history_grouping.dart
Normal file
206
app/lib/widgets/history/history_grouping.dart
Normal 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.
|
||||
194
app/lib/widgets/history/history_list.dart
Normal file
194
app/lib/widgets/history/history_list.dart
Normal 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -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
|
||||
|
||||
222
app/test/screens/history/history_filter_criteria_test.dart
Normal file
222
app/test/screens/history/history_filter_criteria_test.dart
Normal 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
|
||||
// ---------------------------------------------------------------------------
|
||||
}
|
||||
44
app/test/screens/history/history_filter_range_test.dart
Normal file
44
app/test/screens/history/history_filter_range_test.dart
Normal 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
191
app/test/screens/history/history_filter_sheet_test.dart
Normal file
191
app/test/screens/history/history_filter_sheet_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
203
app/test/screens/history/history_filter_sort_test.dart
Normal file
203
app/test/screens/history/history_filter_sort_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
196
app/test/screens/history/history_filter_source_test.dart
Normal file
196
app/test/screens/history/history_filter_source_test.dart
Normal 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
191
app/test/screens/history/history_screen_test.dart
Normal file
191
app/test/screens/history/history_screen_test.dart
Normal 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
}
|
||||
43
app/test/screens/history/history_test_support.dart
Normal file
43
app/test/screens/history/history_test_support.dart
Normal 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
Loading…
Reference in New Issue
Block a user