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/food_suggestion.dart';
|
||||||
import 'package:diet_guard_app/models/nutrition.dart';
|
import 'package:diet_guard_app/models/nutrition.dart';
|
||||||
import 'package:diet_guard_app/models/slot.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/history_screen.dart';
|
||||||
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
|
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
|
||||||
import 'package:diet_guard_app/screens/settings_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/autocomplete_suggestion_list.dart';
|
||||||
import 'package:diet_guard_app/widgets/macro_input_row.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/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:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
@ -43,6 +44,7 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
final MacroControllers _macros = MacroControllers();
|
final MacroControllers _macros = MacroControllers();
|
||||||
List<FoodSuggestion> _suggestions = const [];
|
List<FoodSuggestion> _suggestions = const [];
|
||||||
Set<int> _loggedSlots = {};
|
Set<int> _loggedSlots = {};
|
||||||
|
int? _selectedSlot;
|
||||||
String _source = 'manual';
|
String _source = 'manual';
|
||||||
String? _status;
|
String? _status;
|
||||||
String? _imagePath;
|
String? _imagePath;
|
||||||
@ -65,6 +67,7 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
]) {
|
]) {
|
||||||
controller.addListener(_onMacroEdited);
|
controller.addListener(_onMacroEdited);
|
||||||
}
|
}
|
||||||
|
_selectedSlot = currentSlot(DateTime.now());
|
||||||
unawaited(_refreshSlots());
|
unawaited(_refreshSlots());
|
||||||
unawaited(_onDescChanged());
|
unawaited(_onDescChanged());
|
||||||
unawaited(_autoSync());
|
unawaited(_autoSync());
|
||||||
@ -182,11 +185,10 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
ateGrams: _parse(_macros.grams),
|
ateGrams: _parse(_macros.grams),
|
||||||
source: _source,
|
source: _source,
|
||||||
);
|
);
|
||||||
final slot = currentSlot(DateTime.now());
|
|
||||||
await LogStorageService.instance.logMeal(
|
await LogStorageService.instance.logMeal(
|
||||||
desc,
|
desc,
|
||||||
nutrition,
|
nutrition,
|
||||||
slot: slot,
|
slot: _selectedSlot,
|
||||||
imagePath: _imagePath,
|
imagePath: _imagePath,
|
||||||
);
|
);
|
||||||
final log = await LogStorageService.instance.readLog();
|
final log = await LogStorageService.instance.readLog();
|
||||||
@ -197,6 +199,7 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_source = 'manual';
|
_source = 'manual';
|
||||||
_imagePath = null;
|
_imagePath = null;
|
||||||
|
_selectedSlot = currentSlot(DateTime.now());
|
||||||
});
|
});
|
||||||
await _refreshSlots();
|
await _refreshSlots();
|
||||||
if (!mounted) return;
|
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() {
|
void _onOpenSettings() {
|
||||||
unawaited(
|
unawaited(
|
||||||
Navigator.of(context).push<void>(
|
Navigator.of(context).push<void>(
|
||||||
@ -234,6 +245,11 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Diet Guard'),
|
title: const Text('Diet Guard'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.restaurant_menu),
|
||||||
|
tooltip: 'Food bank',
|
||||||
|
onPressed: _onOpenFoodBank,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.history),
|
icon: const Icon(Icons.history),
|
||||||
tooltip: 'History',
|
tooltip: 'History',
|
||||||
@ -251,8 +267,13 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SlotStatusBar(now: DateTime.now(), loggedSlots: _loggedSlots),
|
SlotSelectorRow(
|
||||||
const SizedBox(height: 16),
|
now: DateTime.now(),
|
||||||
|
loggedSlots: _loggedSlots,
|
||||||
|
selectedSlot: _selectedSlot,
|
||||||
|
onSlotSelected: (slot) => setState(() => _selectedSlot = slot),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _descController,
|
controller: _descController,
|
||||||
decoration: const InputDecoration(labelText: 'What did you eat?'),
|
decoration: const InputDecoration(labelText: 'What did you eat?'),
|
||||||
@ -260,25 +281,33 @@ class _LogMealScreenState extends State<LogMealScreen>
|
|||||||
AutocompleteSuggestionList(
|
AutocompleteSuggestionList(
|
||||||
suggestions: _suggestions,
|
suggestions: _suggestions,
|
||||||
onSelected: _onSuggestionSelected,
|
onSelected: _onSuggestionSelected,
|
||||||
|
compact: true,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
MacroInputRow(controllers: _macros),
|
MacroInputRow(controllers: _macros, compact: true),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
PhotoAttachField(
|
Row(
|
||||||
imagePath: _imagePath,
|
|
||||||
onChanged: (path) => setState(() => _imagePath = path),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
PhotoAttachField(
|
||||||
onPressed: _onLogMeal,
|
imagePath: _imagePath,
|
||||||
child: const Text('Log meal'),
|
onChanged: (path) => setState(() => _imagePath = path),
|
||||||
|
compact: true,
|
||||||
),
|
),
|
||||||
OutlinedButton(
|
const Spacer(),
|
||||||
onPressed: _onBuildMeal,
|
Tooltip(
|
||||||
child: const Text('Build a multi-item meal'),
|
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// A tappable list of [FoodSuggestion]s, each filling the form on tap.
|
/// 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 {
|
class AutocompleteSuggestionList extends StatelessWidget {
|
||||||
/// Creates an [AutocompleteSuggestionList] for [suggestions].
|
/// Creates an [AutocompleteSuggestionList] for [suggestions].
|
||||||
const AutocompleteSuggestionList({
|
const AutocompleteSuggestionList({
|
||||||
required this.suggestions,
|
required this.suggestions,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
|
this.compact = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -19,23 +24,71 @@ class AutocompleteSuggestionList extends StatelessWidget {
|
|||||||
/// Called with the chosen suggestion when the user taps it.
|
/// Called with the chosen suggestion when the user taps it.
|
||||||
final ValueChanged<FoodSuggestion> onSelected;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (suggestions.isEmpty) return const SizedBox.shrink();
|
if (suggestions.isEmpty) return const SizedBox.shrink();
|
||||||
return ListView.builder(
|
if (!compact) {
|
||||||
shrinkWrap: true,
|
return ListView.builder(
|
||||||
itemCount: suggestions.length,
|
shrinkWrap: true,
|
||||||
itemBuilder: (context, index) {
|
itemCount: suggestions.length,
|
||||||
final suggestion = suggestions[index];
|
itemBuilder: (context, index) {
|
||||||
return ListTile(
|
final suggestion = suggestions[index];
|
||||||
dense: true,
|
return ListTile(
|
||||||
title: Text(suggestion.name),
|
dense: true,
|
||||||
subtitle: Text(
|
title: Text(suggestion.name),
|
||||||
'${suggestion.nutrition.kcal.toStringAsFixed(0)} kcal',
|
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
|
/// A labeled row of number-entry fields for calories, macros, and the
|
||||||
/// optional reference-weight-vs-eaten-weight split.
|
/// 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 {
|
class MacroInputRow extends StatelessWidget {
|
||||||
/// Creates a [MacroInputRow] bound to [controllers].
|
/// 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.
|
/// The text controllers this row reads from and writes to.
|
||||||
final MacroControllers controllers;
|
final MacroControllers controllers;
|
||||||
|
|
||||||
|
/// Whether to render all fields in one row with abbreviated labels.
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// per-gram reference weight and kcal on the same line.
|
||||||
Row(
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _macroField('kcal', controllers.kcal)),
|
SizedBox(
|
||||||
const SizedBox(width: 8),
|
width: 72,
|
||||||
Expanded(
|
child: _macroField('per (g)', controllers.perGrams),
|
||||||
child: _macroField(
|
|
||||||
'macros per (g)',
|
|
||||||
controllers.perGrams,
|
|
||||||
helperText: 'e.g. 100 for a per-100g label',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: _macroField('kcal', controllers.kcal)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@ -98,9 +133,9 @@ class MacroInputRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_macroField(
|
_macroField(
|
||||||
'amount eaten (g)',
|
'eaten (g)',
|
||||||
controllers.grams,
|
controllers.grams,
|
||||||
helperText: "blank = same as 'macros per'",
|
helperText: "blank = same as 'per (g)'",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ class PhotoAttachField extends StatelessWidget {
|
|||||||
const PhotoAttachField({
|
const PhotoAttachField({
|
||||||
required this.imagePath,
|
required this.imagePath,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
this.compact = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -29,6 +30,10 @@ class PhotoAttachField extends StatelessWidget {
|
|||||||
/// user removes the current photo.
|
/// user removes the current photo.
|
||||||
final ValueChanged<String?> onChanged;
|
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 {
|
Future<void> _attach(BuildContext context) async {
|
||||||
final source = await showModalBottomSheet<ImageSource>(
|
final source = await showModalBottomSheet<ImageSource>(
|
||||||
context: context,
|
context: context,
|
||||||
@ -39,14 +44,12 @@ class PhotoAttachField extends StatelessWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.photo_camera),
|
leading: const Icon(Icons.photo_camera),
|
||||||
title: const Text('Take a photo'),
|
title: const Text('Take a photo'),
|
||||||
onTap: () =>
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
||||||
Navigator.of(sheetContext).pop(ImageSource.camera),
|
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.photo_library),
|
leading: const Icon(Icons.photo_library),
|
||||||
title: const Text('Choose from gallery'),
|
title: const Text('Choose from gallery'),
|
||||||
onTap: () =>
|
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
|
||||||
Navigator.of(sheetContext).pop(ImageSource.gallery),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -61,33 +64,67 @@ class PhotoAttachField extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final path = imagePath;
|
final path = imagePath;
|
||||||
if (path == null) {
|
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(
|
return OutlinedButton.icon(
|
||||||
onPressed: () => _attach(context),
|
onPressed: () => _attach(context),
|
||||||
icon: const Icon(Icons.add_a_photo),
|
icon: const Icon(Icons.add_a_photo),
|
||||||
label: const Text('Attach photo'),
|
label: const Text('Attach photo'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Row(
|
final thumbnailSize = compact ? 32.0 : 64.0;
|
||||||
children: [
|
final thumbnail = GestureDetector(
|
||||||
GestureDetector(
|
onTap: () => Navigator.of(context).push<void>(
|
||||||
onTap: () => Navigator.of(context).push<void>(
|
MaterialPageRoute(builder: (_) => PhotoViewerScreen(path: path)),
|
||||||
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),
|
if (compact) {
|
||||||
width: 64,
|
return Stack(
|
||||||
height: 64,
|
clipBehavior: Clip.none,
|
||||||
fit: BoxFit.cover,
|
children: [
|
||||||
errorBuilder: (context, error, stackTrace) => const SizedBox(
|
thumbnail,
|
||||||
width: 64,
|
Positioned(
|
||||||
height: 64,
|
top: -6,
|
||||||
child: Icon(Icons.broken_image),
|
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),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => onChanged(null),
|
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',
|
testWidgets('lists logged entries newest first, excluding tombstones', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.runAsync(() async {
|
await tester.runAsync(() async {
|
||||||
await LogStorageService.instance.writeLog({
|
await LogStorageService.instance.writeLog({
|
||||||
'2026-06-01': [
|
'2026-06-01': [
|
||||||
@ -89,16 +90,15 @@ void main() {
|
|||||||
expect(find.text('old breakfast'), findsOneWidget);
|
expect(find.text('old breakfast'), findsOneWidget);
|
||||||
expect(find.text('undone lunch'), findsNothing);
|
expect(find.text('undone lunch'), findsNothing);
|
||||||
|
|
||||||
final tiles = tester
|
final tiles = tester.widgetList<ListTile>(find.byType(ListTile)).toList();
|
||||||
.widgetList<ListTile>(find.byType(ListTile))
|
|
||||||
.toList();
|
|
||||||
expect((tiles[0].title! as Text).data, 'new dinner');
|
expect((tiles[0].title! as Text).data, 'new dinner');
|
||||||
expect((tiles[1].title! as Text).data, 'old breakfast');
|
expect((tiles[1].title! as Text).data, 'old breakfast');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tapping a thumbnail opens the full-screen photo viewer',
|
testWidgets('tapping a thumbnail opens the full-screen photo viewer', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
await tester.runAsync(() async {
|
await tester.runAsync(() async {
|
||||||
final imageFile = File('${tempDir.path}/photo.png')
|
final imageFile = File('${tempDir.path}/photo.png')
|
||||||
..writeAsBytesSync([1, 2, 3]);
|
..writeAsBytesSync([1, 2, 3]);
|
||||||
@ -128,4 +128,884 @@ void main() {
|
|||||||
expect(find.byType(PhotoViewerScreen), findsOneWidget);
|
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 'dart:io';
|
||||||
|
|
||||||
import 'package:diet_guard_app/models/food_entry.dart';
|
import 'package:diet_guard_app/models/food_entry.dart';
|
||||||
|
import 'package:diet_guard_app/screens/food_bank_screen.dart';
|
||||||
import 'package:diet_guard_app/screens/log_meal_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/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/photo_viewer_screen.dart';
|
||||||
import 'package:diet_guard_app/screens/settings_screen.dart';
|
import 'package:diet_guard_app/screens/settings_screen.dart';
|
||||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||||
@ -118,7 +120,7 @@ void main() {
|
|||||||
await tempDir.delete(recursive: true);
|
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
|
// 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
|
// 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 tester.enterText(find.byType(TextField).at(0), 'toast');
|
||||||
await settle(tester);
|
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(3), '5');
|
||||||
await tester.enterText(find.byType(TextField).at(4), '20');
|
await tester.enterText(find.byType(TextField).at(4), '20');
|
||||||
await tester.enterText(find.byType(TextField).at(5), '3');
|
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 tester.enterText(find.byType(TextField).at(0), 'label food');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
await tester.enterText(find.byType(TextField).at(1), '200');
|
await tester.enterText(find.byType(TextField).at(1), '100');
|
||||||
await tester.enterText(find.byType(TextField).at(2), '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(3), '10');
|
||||||
await tester.enterText(find.byType(TextField).at(4), '20');
|
await tester.enterText(find.byType(TextField).at(4), '20');
|
||||||
await tester.enterText(find.byType(TextField).at(5), '5');
|
await tester.enterText(find.byType(TextField).at(5), '5');
|
||||||
@ -257,7 +259,7 @@ void main() {
|
|||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
// The empty-query suggestion list shows the only banked food.
|
// 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 settle(tester);
|
||||||
await tester.ensureVisible(logMealButton);
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
@ -268,9 +270,9 @@ void main() {
|
|||||||
expect(firstEntry.source, 'food bank');
|
expect(firstEntry.source, 'food bank');
|
||||||
expect(firstEntry.kcal, 250);
|
expect(firstEntry.kcal, 250);
|
||||||
|
|
||||||
await tester.tap(find.text('seeded food'));
|
await tester.tap(find.text('seeded food · 250 kcal'));
|
||||||
await settle(tester);
|
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 settle(tester);
|
||||||
await tester.ensureVisible(logMealButton);
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
@ -305,12 +307,12 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField).at(0), 'snack');
|
await tester.enterText(find.byType(TextField).at(0), 'snack');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
await tester.tap(find.text('Attach photo'));
|
await tester.tap(find.byTooltip('Attach photo'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
await tester.tap(find.text('Choose from gallery'));
|
await tester.tap(find.text('Choose from gallery'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
expect(find.text('Remove photo'), findsOneWidget);
|
expect(find.byTooltip('Remove photo'), findsOneWidget);
|
||||||
|
|
||||||
await tester.ensureVisible(logMealButton);
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
@ -323,11 +325,11 @@ void main() {
|
|||||||
|
|
||||||
await tester.enterText(find.byType(TextField).at(0), 'snack two');
|
await tester.enterText(find.byType(TextField).at(0), 'snack two');
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
await tester.tap(find.text('Attach photo'));
|
await tester.tap(find.byTooltip('Attach photo'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
await tester.tap(find.text('Choose from gallery'));
|
await tester.tap(find.text('Choose from gallery'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
await tester.tap(find.text('Remove photo'));
|
await tester.tap(find.byTooltip('Remove photo'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
await tester.ensureVisible(logMealButton);
|
await tester.ensureVisible(logMealButton);
|
||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
@ -353,7 +355,7 @@ void main() {
|
|||||||
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
await tester.tap(find.text('Attach photo'));
|
await tester.tap(find.byTooltip('Attach photo'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
await tester.tap(find.text('Choose from gallery'));
|
await tester.tap(find.text('Choose from gallery'));
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
@ -364,4 +366,106 @@ void main() {
|
|||||||
expect(find.byType(PhotoViewerScreen), findsOneWidget);
|
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