mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 12:03:08 +02:00
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:
parent
4c6083b768
commit
c43e37b09d
@ -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),
|
||||
PhotoAttachField(
|
||||
imagePath: _imagePath,
|
||||
onChanged: (path) => setState(() => _imagePath = path),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
const SizedBox(height: 8),
|
||||
MacroInputRow(controllers: _macros, compact: true),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: _onLogMeal,
|
||||
child: const Text('Log meal'),
|
||||
PhotoAttachField(
|
||||
imagePath: _imagePath,
|
||||
onChanged: (path) => setState(() => _imagePath = path),
|
||||
compact: true,
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: _onBuildMeal,
|
||||
child: const Text('Build a multi-item meal'),
|
||||
const Spacer(),
|
||||
Tooltip(
|
||||
message: 'Build a multi-item meal',
|
||||
child: OutlinedButton(
|
||||
onPressed: _onBuildMeal,
|
||||
child: const Icon(Icons.playlist_add),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: 'Log meal',
|
||||
child: FilledButton(
|
||||
onPressed: _onLogMeal,
|
||||
child: const Icon(Icons.check_circle),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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,23 +24,71 @@ 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();
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: suggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = suggestions[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(suggestion.name),
|
||||
subtitle: Text(
|
||||
'${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal',
|
||||
if (!compact) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: suggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = suggestions[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(suggestion.name),
|
||||
subtitle: Text(
|
||||
'${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal',
|
||||
),
|
||||
onTap: () => onSelected(suggestion),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () => onSelected(suggestion),
|
||||
);
|
||||
},
|
||||
if (remaining > 0)
|
||||
TextButton(
|
||||
onPressed: () => _showMore(context),
|
||||
child: Text('$remaining more'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _macroField(
|
||||
'macros per (g)',
|
||||
controllers.perGrams,
|
||||
helperText: 'e.g. 100 for a per-100g label',
|
||||
),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: _macroField('per (g)', controllers.perGrams),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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)'",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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,33 +64,67 @@ 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(
|
||||
onTap: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => PhotoViewerScreen(path: path)),
|
||||
final thumbnailSize = compact ? 32.0 : 64.0;
|
||||
final thumbnail = GestureDetector(
|
||||
onTap: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => PhotoViewerScreen(path: path)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(path),
|
||||
width: thumbnailSize,
|
||||
height: thumbnailSize,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => SizedBox(
|
||||
width: thumbnailSize,
|
||||
height: thumbnailSize,
|
||||
child: const Icon(Icons.broken_image),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
File(path),
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => const SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: 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),
|
||||
|
||||
74
app/lib/widgets/slot_selector_row.dart
Normal file
74
app/lib/widgets/slot_selector_row.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user