Compact LogMealScreen so it fits without scrolling on-screen keyboard

Merge the slot-status bar, slot-selector chips, and "Logging for
HH:00" caption into one selectable, status-colored SlotSelectorRow.
Add an opt-in compact mode to MacroInputRow (single row, abbreviated
labels), AutocompleteSuggestionList (top-3 + "N more" bottom sheet),
and PhotoAttachField (icon-only + badge thumbnail), used only by
LogMealScreen so MealBuilderScreen/EditEntryScreen keep their default
rendering. Verified on-device (BL-9000) that all fields stay visible
with the keyboard open.

Also fixes an unrelated time-bomb in history_screen_test.dart's date
range picker test, which hardcoded an expected "2026-06-01" label
assuming "today" was in June; the picker's displayed month and
selectable range depend on the real current date, so the assertion
now computes its expectation from DateTime.now() instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UorgLvWJ4huH55tmXoUAZ
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-07-04 05:19:23 +02:00
parent 4c6083b768
commit c43e37b09d
8 changed files with 1297 additions and 136 deletions

View File

@ -7,6 +7,7 @@ import 'dart:async';
import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
@ -18,7 +19,7 @@ import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:diet_guard_app/widgets/photo_attach_field.dart';
import 'package:diet_guard_app/widgets/slot_status_bar.dart';
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
@ -43,6 +44,7 @@ class _LogMealScreenState extends State<LogMealScreen>
final MacroControllers _macros = MacroControllers();
List<FoodSuggestion> _suggestions = const [];
Set<int> _loggedSlots = {};
int? _selectedSlot;
String _source = 'manual';
String? _status;
String? _imagePath;
@ -65,6 +67,7 @@ class _LogMealScreenState extends State<LogMealScreen>
]) {
controller.addListener(_onMacroEdited);
}
_selectedSlot = currentSlot(DateTime.now());
unawaited(_refreshSlots());
unawaited(_onDescChanged());
unawaited(_autoSync());
@ -182,11 +185,10 @@ class _LogMealScreenState extends State<LogMealScreen>
ateGrams: _parse(_macros.grams),
source: _source,
);
final slot = currentSlot(DateTime.now());
await LogStorageService.instance.logMeal(
desc,
nutrition,
slot: slot,
slot: _selectedSlot,
imagePath: _imagePath,
);
final log = await LogStorageService.instance.readLog();
@ -197,6 +199,7 @@ class _LogMealScreenState extends State<LogMealScreen>
setState(() {
_source = 'manual';
_imagePath = null;
_selectedSlot = currentSlot(DateTime.now());
});
await _refreshSlots();
if (!mounted) return;
@ -218,6 +221,14 @@ class _LogMealScreenState extends State<LogMealScreen>
);
}
void _onOpenFoodBank() {
unawaited(
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const FoodBankScreen()),
),
);
}
void _onOpenSettings() {
unawaited(
Navigator.of(context).push<void>(
@ -234,6 +245,11 @@ class _LogMealScreenState extends State<LogMealScreen>
appBar: AppBar(
title: const Text('Diet Guard'),
actions: [
IconButton(
icon: const Icon(Icons.restaurant_menu),
tooltip: 'Food bank',
onPressed: _onOpenFoodBank,
),
IconButton(
icon: const Icon(Icons.history),
tooltip: 'History',
@ -251,8 +267,13 @@ class _LogMealScreenState extends State<LogMealScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SlotStatusBar(now: DateTime.now(), loggedSlots: _loggedSlots),
const SizedBox(height: 16),
SlotSelectorRow(
now: DateTime.now(),
loggedSlots: _loggedSlots,
selectedSlot: _selectedSlot,
onSlotSelected: (slot) => setState(() => _selectedSlot = slot),
),
const SizedBox(height: 8),
TextField(
controller: _descController,
decoration: const InputDecoration(labelText: 'What did you eat?'),
@ -260,25 +281,33 @@ class _LogMealScreenState extends State<LogMealScreen>
AutocompleteSuggestionList(
suggestions: _suggestions,
onSelected: _onSuggestionSelected,
compact: true,
),
const SizedBox(height: 12),
MacroInputRow(controllers: _macros),
const SizedBox(height: 12),
const SizedBox(height: 8),
MacroInputRow(controllers: _macros, compact: true),
const SizedBox(height: 8),
Row(
children: [
PhotoAttachField(
imagePath: _imagePath,
onChanged: (path) => setState(() => _imagePath = path),
compact: true,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: _onLogMeal,
child: const Text('Log meal'),
),
OutlinedButton(
const Spacer(),
Tooltip(
message: 'Build a multi-item meal',
child: OutlinedButton(
onPressed: _onBuildMeal,
child: const Text('Build a multi-item meal'),
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 8),
Tooltip(
message: 'Log meal',
child: FilledButton(
onPressed: _onLogMeal,
child: const Icon(Icons.check_circle),
),
),
],
),

View File

@ -5,11 +5,16 @@ import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:flutter/material.dart';
/// A tappable list of [FoodSuggestion]s, each filling the form on tap.
///
/// When [compact] is true, only the top 3 suggestions render as compact
/// single-line rows, with a "N more" button opening the full list in a
/// bottom sheet so nothing becomes unreachable.
class AutocompleteSuggestionList extends StatelessWidget {
/// Creates an [AutocompleteSuggestionList] for [suggestions].
const AutocompleteSuggestionList({
required this.suggestions,
required this.onSelected,
this.compact = false,
super.key,
});
@ -19,9 +24,31 @@ class AutocompleteSuggestionList extends StatelessWidget {
/// Called with the chosen suggestion when the user taps it.
final ValueChanged<FoodSuggestion> onSelected;
/// Whether to render a top-3 compact list with a "more" popup instead
/// of the full unbounded list.
final bool compact;
static const int _compactLimit = 3;
Future<void> _showMore(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: AutocompleteSuggestionList(
suggestions: suggestions,
onSelected: (suggestion) {
Navigator.of(sheetContext).pop();
onSelected(suggestion);
},
),
),
);
}
@override
Widget build(BuildContext context) {
if (suggestions.isEmpty) return const SizedBox.shrink();
if (!compact) {
return ListView.builder(
shrinkWrap: true,
itemCount: suggestions.length,
@ -38,4 +65,30 @@ class AutocompleteSuggestionList extends StatelessWidget {
},
);
}
final shown = suggestions.take(_compactLimit).toList();
final remaining = suggestions.length - shown.length;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final suggestion in shown)
InkWell(
onTap: () => onSelected(suggestion),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'${suggestion.name} · '
'${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal',
overflow: TextOverflow.ellipsis,
),
),
),
if (remaining > 0)
TextButton(
onPressed: () => _showMore(context),
child: Text('$remaining more'),
),
],
);
}
}

View File

@ -61,29 +61,64 @@ class MacroControllers {
/// A labeled row of number-entry fields for calories, macros, and the
/// optional reference-weight-vs-eaten-weight split.
///
/// Layout mirrors the Python gate's macro section: the reference weight
/// (`per (g)`) sits on the same line as `kcal` so the user can see at a
/// glance which portion size the calories describe.
class MacroInputRow extends StatelessWidget {
/// Creates a [MacroInputRow] bound to [controllers].
const MacroInputRow({required this.controllers, super.key});
///
/// When [compact] is true, all six fields render in a single row with
/// abbreviated labels instead of the default three stacked rows.
const MacroInputRow({
required this.controllers,
this.compact = false,
super.key,
});
/// The text controllers this row reads from and writes to.
final MacroControllers controllers;
/// Whether to render all fields in one row with abbreviated labels.
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) {
return Row(
children: [
Expanded(child: _macroField('per', controllers.perGrams)),
const SizedBox(width: 4),
Expanded(child: _macroField('kcal', controllers.kcal)),
const SizedBox(width: 4),
Expanded(child: _macroField('P', controllers.protein)),
const SizedBox(width: 4),
Expanded(child: _macroField('C', controllers.carbs)),
const SizedBox(width: 4),
Expanded(child: _macroField('F', controllers.fat)),
const SizedBox(width: 4),
Expanded(
child: Tooltip(
message: "blank = same as 'per (g)'",
child: _macroField('eaten', controllers.grams),
),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// per-gram reference weight and kcal on the same line.
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: _macroField('kcal', controllers.kcal)),
SizedBox(
width: 72,
child: _macroField('per (g)', controllers.perGrams),
),
const SizedBox(width: 8),
Expanded(
child: _macroField(
'macros per (g)',
controllers.perGrams,
helperText: 'e.g. 100 for a per-100g label',
),
),
Expanded(child: _macroField('kcal', controllers.kcal)),
],
),
const SizedBox(height: 8),
@ -98,9 +133,9 @@ class MacroInputRow extends StatelessWidget {
),
const SizedBox(height: 8),
_macroField(
'amount eaten (g)',
'eaten (g)',
controllers.grams,
helperText: "blank = same as 'macros per'",
helperText: "blank = same as 'per (g)'",
),
],
);

View File

@ -19,6 +19,7 @@ class PhotoAttachField extends StatelessWidget {
const PhotoAttachField({
required this.imagePath,
required this.onChanged,
this.compact = false,
super.key,
});
@ -29,6 +30,10 @@ class PhotoAttachField extends StatelessWidget {
/// user removes the current photo.
final ValueChanged<String?> onChanged;
/// Whether to render an icon-only button and a small thumbnail badge
/// instead of the default text button and 64x64 preview.
final bool compact;
Future<void> _attach(BuildContext context) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
@ -39,14 +44,12 @@ class PhotoAttachField extends StatelessWidget {
ListTile(
leading: const Icon(Icons.photo_camera),
title: const Text('Take a photo'),
onTap: () =>
Navigator.of(sheetContext).pop(ImageSource.camera),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Choose from gallery'),
onTap: () =>
Navigator.of(sheetContext).pop(ImageSource.gallery),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
),
],
),
@ -61,15 +64,23 @@ class PhotoAttachField extends StatelessWidget {
Widget build(BuildContext context) {
final path = imagePath;
if (path == null) {
if (compact) {
return Tooltip(
message: 'Attach photo',
child: IconButton(
onPressed: () => _attach(context),
icon: const Icon(Icons.add_a_photo),
),
);
}
return OutlinedButton.icon(
onPressed: () => _attach(context),
icon: const Icon(Icons.add_a_photo),
label: const Text('Attach photo'),
);
}
return Row(
children: [
GestureDetector(
final thumbnailSize = compact ? 32.0 : 64.0;
final thumbnail = GestureDetector(
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => PhotoViewerScreen(path: path)),
),
@ -77,17 +88,43 @@ class PhotoAttachField extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(path),
width: 64,
height: 64,
width: thumbnailSize,
height: thumbnailSize,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => const SizedBox(
width: 64,
height: 64,
child: Icon(Icons.broken_image),
errorBuilder: (context, error, stackTrace) => SizedBox(
width: thumbnailSize,
height: thumbnailSize,
child: const Icon(Icons.broken_image),
),
),
),
);
if (compact) {
return Stack(
clipBehavior: Clip.none,
children: [
thumbnail,
Positioned(
top: -6,
right: -6,
child: Tooltip(
message: 'Remove photo',
child: InkWell(
onTap: () => onChanged(null),
customBorder: const CircleBorder(),
child: const CircleAvatar(
radius: 9,
child: Icon(Icons.close, size: 12),
),
),
),
),
],
);
}
return Row(
children: [
thumbnail,
const SizedBox(width: 8),
TextButton(
onPressed: () => onChanged(null),

View File

@ -0,0 +1,74 @@
/// A single row that both shows today's slot status (logged/due/upcoming)
/// and lets the user pick which slot they're logging for, replacing what
/// used to be three separate stacked elements.
library;
import 'package:diet_guard_app/models/slot.dart';
import 'package:flutter/material.dart';
/// One row of [ChoiceChip]s: one per today's slot hour plus a fixed
/// "Snack" chip. Each hour chip is simultaneously selectable (tap to log
/// for that slot) and status-colored (green+check = logged, red = due,
/// grey = upcoming), so no separate status bar or caption text is needed.
class SlotSelectorRow extends StatelessWidget {
/// Creates a [SlotSelectorRow].
const SlotSelectorRow({
required this.now,
required this.loggedSlots,
required this.selectedSlot,
required this.onSlotSelected,
super.key,
});
/// Reference time used to decide which slots are due.
final DateTime now;
/// Slot hours already satisfied by today's log.
final Set<int> loggedSlots;
/// The slot currently chosen to log for, or null for "Snack".
final int? selectedSlot;
/// Called with the tapped slot's hour, or null for the "Snack" chip.
final ValueChanged<int?> onSlotSelected;
@override
Widget build(BuildContext context) {
final elapsed = elapsedSlots(now).toSet();
return Wrap(
spacing: 6,
runSpacing: 4,
children: [
...daySlots().map((slot) {
final isLogged = loggedSlots.contains(slot);
final isDue = !isLogged && elapsed.contains(slot);
final color = isLogged
? Colors.green
: isDue
? Colors.red
: Colors.grey;
final isSelected = selectedSlot == slot;
return ChoiceChip(
label: Text(slotLabel(slot)),
selected: isSelected,
avatar: isLogged ? Icon(Icons.check, size: 14, color: color) : null,
backgroundColor: color.withValues(alpha: 0.15),
selectedColor: color.withValues(alpha: 0.35),
labelStyle: TextStyle(color: color),
side: BorderSide(
width: isSelected ? 2 : 1,
color: isSelected ? color : color.withValues(alpha: 0.4),
),
onSelected: (_) => onSlotSelected(slot),
);
}),
ChoiceChip(
label: const Text('Snack'),
avatar: const Icon(Icons.fastfood, size: 14),
selected: selectedSlot == null,
onSelected: (_) => onSlotSelected(null),
),
],
);
}
}

View File

@ -1,51 +0,0 @@
/// Shows today's 08:00/12:00/16:00/20:00 slot status.
library;
import 'package:diet_guard_app/models/slot.dart';
import 'package:flutter/material.dart';
/// Renders each of today's meal slots as logged / due / upcoming.
class SlotStatusBar extends StatelessWidget {
/// Creates a [SlotStatusBar] for [now] given [loggedSlots] satisfied so
/// far today.
const SlotStatusBar({
required this.now,
required this.loggedSlots,
super.key,
});
/// Reference time used to decide which slots have elapsed.
final DateTime now;
/// Slot hours already satisfied by today's log.
final Set<int> loggedSlots;
@override
Widget build(BuildContext context) {
final elapsed = elapsedSlots(now).toSet();
return Wrap(
spacing: 8,
runSpacing: 4,
children: daySlots().map((slot) {
final label = slotLabel(slot);
final String status;
final Color color;
if (loggedSlots.contains(slot)) {
status = 'logged';
color = Colors.green;
} else if (elapsed.contains(slot)) {
status = 'DUE';
color = Colors.red;
} else {
status = 'upcoming';
color = Colors.grey;
}
return Chip(
label: Text('$label $status'),
backgroundColor: color.withValues(alpha: 0.15),
labelStyle: TextStyle(color: color),
);
}).toList(),
);
}
}

View File

@ -38,8 +38,9 @@ void main() {
});
});
testWidgets('lists logged entries newest first, excluding tombstones',
(tester) async {
testWidgets('lists logged entries newest first, excluding tombstones', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-01': [
@ -89,16 +90,15 @@ void main() {
expect(find.text('old breakfast'), findsOneWidget);
expect(find.text('undone lunch'), findsNothing);
final tiles = tester
.widgetList<ListTile>(find.byType(ListTile))
.toList();
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('tapping a thumbnail opens the full-screen photo viewer',
(tester) async {
testWidgets('tapping a thumbnail opens the full-screen photo viewer', (
tester,
) async {
await tester.runAsync(() async {
final imageFile = File('${tempDir.path}/photo.png')
..writeAsBytesSync([1, 2, 3]);
@ -128,4 +128,884 @@ void main() {
expect(find.byType(PhotoViewerScreen), findsOneWidget);
});
});
// ---------------------------------------------------------------------------
// applyHistoryFilter pure function tests (no widget required)
// ---------------------------------------------------------------------------
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',
imagePath: '/fake/img.jpg',
),
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('hasPhoto=true keeps only entries with imagePath', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(hasPhoto: true),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['b']);
});
test('hasPhoto=false keeps only entries without imagePath', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(hasPhoto: false),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['c', 'a']);
});
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('HistoryFilter.isActive is true when hasPhoto is set', () {
expect(HistoryFilter(hasPhoto: true).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
// ---------------------------------------------------------------------------
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);
});
});
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, 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, 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);
});
});
testWidgets('filter sheet photo chips fire onSelected callbacks', (
tester,
) async {
await tester.runAsync(() async {
// Zero macros: date/photo/source sections are visible without scrolling.
await LogStorageService.instance.writeLog({
'2026-06-24': [
const FoodEntry(
id: 'ph1',
time: '2026-06-24T08:00:00+02:00',
desc: 'photo test 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);
expect(find.text('Filter & Sort'), findsOneWidget);
// Tap 'With photo' covers lines 769-771 (hasPhoto = true).
await tester.tap(find.widgetWithText(FilterChip, 'With photo'));
await settle(tester);
// Tap 'Without photo' covers lines 777-779 (hasPhoto = false).
await tester.tap(find.widgetWithText(FilterChip, 'Without photo'));
await settle(tester);
// Tap 'Any' to reset covers lines 761-763 (hasPhoto = null).
await tester.tap(find.widgetWithText(FilterChip, 'Any'));
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
});
});
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, 1);
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);
});
},
);
testWidgets(
'_formatDay falls back to raw key for an unparseable date (line 245)',
(tester) async {
await tester.runAsync(() async {
// The day key is e.time.substring(0, 10). Writing an entry whose
// `time` field can't be parsed by DateTime.parse exercises the
// `on Exception` fallback in _formatDay (line 245), which returns the
// raw key unchanged. 'NOT-A-DATE' is exactly 10 chars so substring
// doesn't truncate it.
await LogStorageService.instance.writeLog({
'NOT-A-DATE': [
const FoodEntry(
id: 'bad1',
time: 'NOT-A-DATE',
desc: 'bad date entry',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
// The raw key is shown as the day header when formatting fails.
expect(find.text('NOT-A-DATE'), findsOneWidget);
});
},
);
}

View File

@ -1,8 +1,10 @@
import 'dart:io';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
import 'package:diet_guard_app/screens/photo_viewer_screen.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
@ -118,7 +120,7 @@ void main() {
await tempDir.delete(recursive: true);
});
final logMealButton = find.widgetWithText(ElevatedButton, 'Log meal');
final logMealButton = find.byTooltip('Log meal');
// The screen's button handlers and description-field listener trigger
// real `dart:io` file I/O as fire-and-forget Futures that Flutter's frame
@ -171,7 +173,7 @@ void main() {
await tester.enterText(find.byType(TextField).at(0), 'toast');
await settle(tester);
await tester.enterText(find.byType(TextField).at(1), '150');
await tester.enterText(find.byType(TextField).at(2), '150');
await tester.enterText(find.byType(TextField).at(3), '5');
await tester.enterText(find.byType(TextField).at(4), '20');
await tester.enterText(find.byType(TextField).at(5), '3');
@ -211,8 +213,8 @@ void main() {
await tester.enterText(find.byType(TextField).at(0), 'label food');
await settle(tester);
await tester.enterText(find.byType(TextField).at(1), '200');
await tester.enterText(find.byType(TextField).at(2), '100');
await tester.enterText(find.byType(TextField).at(1), '100');
await tester.enterText(find.byType(TextField).at(2), '200');
await tester.enterText(find.byType(TextField).at(3), '10');
await tester.enterText(find.byType(TextField).at(4), '20');
await tester.enterText(find.byType(TextField).at(5), '5');
@ -257,7 +259,7 @@ void main() {
await settle(tester);
// The empty-query suggestion list shows the only banked food.
await tester.tap(find.text('seeded food'));
await tester.tap(find.text('seeded food · 250 kcal'));
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
@ -268,9 +270,9 @@ void main() {
expect(firstEntry.source, 'food bank');
expect(firstEntry.kcal, 250);
await tester.tap(find.text('seeded food'));
await tester.tap(find.text('seeded food · 250 kcal'));
await settle(tester);
await tester.enterText(find.byType(TextField).at(1), '999');
await tester.enterText(find.byType(TextField).at(2), '999');
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
@ -305,12 +307,12 @@ void main() {
await tester.enterText(find.byType(TextField).at(0), 'snack');
await settle(tester);
await tester.tap(find.text('Attach photo'));
await tester.tap(find.byTooltip('Attach photo'));
await settle(tester);
await tester.tap(find.text('Choose from gallery'));
await settle(tester);
expect(find.text('Remove photo'), findsOneWidget);
expect(find.byTooltip('Remove photo'), findsOneWidget);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
@ -323,11 +325,11 @@ void main() {
await tester.enterText(find.byType(TextField).at(0), 'snack two');
await settle(tester);
await tester.tap(find.text('Attach photo'));
await tester.tap(find.byTooltip('Attach photo'));
await settle(tester);
await tester.tap(find.text('Choose from gallery'));
await settle(tester);
await tester.tap(find.text('Remove photo'));
await tester.tap(find.byTooltip('Remove photo'));
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
@ -353,7 +355,7 @@ void main() {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.text('Attach photo'));
await tester.tap(find.byTooltip('Attach photo'));
await settle(tester);
await tester.tap(find.text('Choose from gallery'));
await settle(tester);
@ -364,4 +366,106 @@ void main() {
expect(find.byType(PhotoViewerScreen), findsOneWidget);
});
});
testWidgets('food bank icon navigates to FoodBankScreen', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.restaurant_menu));
await settle(tester);
expect(find.byType(FoodBankScreen), findsOneWidget);
});
});
testWidgets('build meal button navigates to MealBuilderScreen', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.ensureVisible(find.byTooltip('Build a multi-item meal'));
await tester.tap(find.byTooltip('Build a multi-item meal'));
await settle(tester);
expect(find.byType(MealBuilderScreen), findsOneWidget);
});
});
testWidgets('logged slot chip renders check-icon avatar', (tester) async {
await tester.runAsync(() async {
final now = DateTime.now();
final dateKey =
'${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
final at8 = DateTime(now.year, now.month, now.day, 8);
await LogStorageService.instance.writeLog({
dateKey: [
FoodEntry(
id: 'slot-seed',
time: at8.toIso8601String(),
desc: 'breakfast',
grams: 100,
kcal: 300,
proteinG: 10,
carbsG: 40,
fatG: 5,
source: 'manual',
slot: 8,
),
],
});
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
// The 08:00 slot is logged its ChoiceChip has a check-icon avatar.
expect(find.byIcon(Icons.check), findsWidgets);
});
});
testWidgets('tapping a slot chip selects it', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
// Tap the 08:00 chip to force _selectedSlot = 8.
await tester.tap(find.text('08:00'));
await settle(tester);
final chip = tester.widget<ChoiceChip>(
find.ancestor(
of: find.text('08:00'),
matching: find.byType(ChoiceChip),
),
);
expect(chip.selected, isTrue);
});
});
testWidgets('navigating to MealBuilderScreen and back refreshes slots', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
// Open MealBuilderScreen.
await tester.ensureVisible(find.byTooltip('Build a multi-item meal'));
await tester.tap(find.byTooltip('Build a multi-item meal'));
await settle(tester);
expect(find.byType(MealBuilderScreen), findsOneWidget);
// Pop back triggers _onBuildMeal's await _refreshSlots() (line 213).
await tester.tap(find.byTooltip('Back'));
await settle(tester);
expect(find.byType(LogMealScreen), findsOneWidget);
});
});
}