Split food_bank_screen; share the filter widgets with history

food_bank_screen.dart (712) carried a near-clone of the filter sheet batch 2
had just factored out of history: the same name-search field, the same slider
captions, and four more copies of the same min/max macro range block. Splitting
it in isolation would have baked that duplication in permanently, so the shared
widgets moved to a neutral `lib/widgets/filters/` first and both screens now
import them.

  food_bank_screen.dart      712 -> 238
  lib/widgets/food_bank/
    fb_filter.dart            156  FbFilter, FbSortField, applyFbFilter
    fb_filter_sheet.dart      244  the sheet itself
    fb_add_entry_dialog.dart  103  the manual-entry dialog
  lib/widgets/history/history_filter_fields.dart
                        -> lib/widgets/filters/filter_fields.dart

FilterRangeRow gained `showEndpointLabels`, defaulting to true. History showed
endpoint captions and a selected-range readout; the food-bank sheet never did,
and switching them on there would have been a UI change smuggled in under a
refactor. The food-bank call sites pass false, so both screens render exactly
what they rendered before.

FbSortField and HistorySortField stay separate on purpose -- they are not the
same enum (`name` vs `date`/`description`), so the sort dropdown is not shared.

The test file mirrors the source split, three files under test/screens/
food_bank/ plus fb_test_support.dart for the setUp/tearDown/settle prologue,
same shape as batch 2's history_test_support.dart.

Verified: 555 flutter tests (unchanged count -- no test lost, and food bank's
own RangeSlider/sort-toggle tests still pass against the shared widget),
flutter analyze clean over lib/ and test/, live ~/.local/share/diet_guard
byte-identical. Over-cap files: 40 -> 38.

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

View File

@ -1,164 +1,22 @@
/// Food bank browser: lists every entry across the log-derived and manual
/// banks with filtering, sorting, and the ability to add new manual entries.
///
/// The filter model, the filter sheet and the add-entry dialog live under
/// `lib/widgets/food_bank/` (split for the repo's 250-line cap); this file is
/// the screen that wires them together.
library;
import 'dart:async';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_add_entry_dialog.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter_sheet.dart';
import 'package:flutter/material.dart';
// ---------------------------------------------------------------------------
// Filter / sort state
// ---------------------------------------------------------------------------
/// Sort field for the food bank list.
enum FbSortField {
/// Sort alphabetically by name.
name,
/// Sort by calories.
kcal,
/// Sort by protein (g).
protein,
/// Sort by carbohydrates (g).
carbs,
/// Sort by fat (g).
fat,
/// Sort by usage count (most-used first by default).
count,
}
/// Active filter criteria for the food bank list.
class FbFilter {
/// Creates a [FbFilter] with the given criteria.
FbFilter({
this.nameQuery = '',
this.minKcal,
this.maxKcal,
this.minProtein,
this.maxProtein,
this.minCarbs,
this.maxCarbs,
this.minFat,
this.maxFat,
});
/// Substring match on the food name.
String nameQuery;
/// Minimum kcal.
double? minKcal;
/// Maximum kcal.
double? maxKcal;
/// Minimum protein (g).
double? minProtein;
/// Maximum protein (g).
double? maxProtein;
/// Minimum carbs (g).
double? minCarbs;
/// Maximum carbs (g).
double? maxCarbs;
/// Minimum fat (g).
double? minFat;
/// Maximum fat (g).
double? maxFat;
/// True when any criterion is set.
bool get isActive =>
nameQuery.isNotEmpty ||
minKcal != null ||
maxKcal != null ||
minProtein != null ||
maxProtein != null ||
minCarbs != null ||
maxCarbs != null ||
minFat != null ||
maxFat != null;
}
// ---------------------------------------------------------------------------
// Pure filter / sort helper
// ---------------------------------------------------------------------------
/// Filters and sorts [entries] by [filter] and [sortField]/[ascending].
///
/// Exposed as a top-level function for unit tests.
List<FoodBankRecord> applyFbFilter(
List<FoodBankRecord> entries,
FbFilter filter,
FbSortField sortField, {
required bool ascending,
}) {
var result = [...entries];
if (filter.nameQuery.isNotEmpty) {
final q = filter.nameQuery.toLowerCase();
result = result.where((e) => e.desc.toLowerCase().contains(q)).toList();
}
if (filter.minKcal != null) {
result = result.where((e) => e.kcal >= filter.minKcal!).toList();
}
if (filter.maxKcal != null) {
result = result.where((e) => e.kcal <= filter.maxKcal!).toList();
}
if (filter.minProtein != null) {
result = result.where((e) => e.proteinG >= filter.minProtein!).toList();
}
if (filter.maxProtein != null) {
result = result.where((e) => e.proteinG <= filter.maxProtein!).toList();
}
if (filter.minCarbs != null) {
result = result.where((e) => e.carbsG >= filter.minCarbs!).toList();
}
if (filter.maxCarbs != null) {
result = result.where((e) => e.carbsG <= filter.maxCarbs!).toList();
}
if (filter.minFat != null) {
result = result.where((e) => e.fatG >= filter.minFat!).toList();
}
if (filter.maxFat != null) {
result = result.where((e) => e.fatG <= filter.maxFat!).toList();
}
result.sort((a, b) {
int cmp;
switch (sortField) {
case FbSortField.name:
cmp = a.desc.compareTo(b.desc);
case FbSortField.kcal:
cmp = a.kcal.compareTo(b.kcal);
case FbSortField.protein:
cmp = a.proteinG.compareTo(b.proteinG);
case FbSortField.carbs:
cmp = a.carbsG.compareTo(b.carbsG);
case FbSortField.fat:
cmp = a.fatG.compareTo(b.fatG);
case FbSortField.count:
cmp = a.count.compareTo(b.count);
}
return ascending ? cmp : -cmp;
});
return result;
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
/// Lists all food bank entries (log-derived + manual) with filtering/sorting
/// and a FAB for adding new manual entries.
/// Browses every banked food -- log-derived and manually curated -- with
/// filtering, sorting, and an add-entry dialog.
class FoodBankScreen extends StatefulWidget {
/// Creates a [FoodBankScreen].
const FoodBankScreen({super.key});
@ -235,7 +93,7 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => _FbFilterSheet(
builder: (ctx, setSheet) => FbFilterSheet(
filter: draft,
sortField: draftSort,
ascending: draftAsc,
@ -274,7 +132,7 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
Future<void> _openAddDialog() async {
final result = await showDialog<FoodBankRecord>(
context: context,
builder: (_) => const _AddEntryDialog(),
builder: (_) => const AddEntryDialog(),
);
if (result == null) return;
await FoodBankService.instance.addManualEntry(result);
@ -328,7 +186,7 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
)
: ListView.builder(
itemCount: _displayed.length,
itemBuilder: (context, i) => _RecordTile(_displayed[i]),
itemBuilder: (context, i) => RecordTile(_displayed[i]),
),
floatingActionButton: FloatingActionButton(
onPressed: _openAddDialog,
@ -343,8 +201,12 @@ class _FoodBankScreenState extends State<FoodBankScreen> {
// Record tile
// ---------------------------------------------------------------------------
class _RecordTile extends StatelessWidget {
const _RecordTile(this.record);
/// One banked food: its name, macros, and how often it has been logged.
class RecordTile extends StatelessWidget {
/// Creates a [RecordTile] for [record].
const RecordTile(this.record, {super.key});
/// The banked record this tile renders.
final FoodBankRecord record;
@override
@ -374,339 +236,3 @@ class _RecordTile extends StatelessWidget {
// ---------------------------------------------------------------------------
// Filter sheet
// ---------------------------------------------------------------------------
class _FbFilterSheet extends StatelessWidget {
const _FbFilterSheet({
required this.filter,
required this.sortField,
required this.ascending,
required this.maxKcal,
required this.maxProtein,
required this.maxCarbs,
required this.maxFat,
required this.onFilterChanged,
required this.onSortChanged,
required this.onApply,
required this.onClear,
});
final FbFilter filter;
final FbSortField sortField;
final bool ascending;
final double maxKcal;
final double maxProtein;
final double maxCarbs;
final double maxFat;
final void Function(FbFilter) onFilterChanged;
final void Function({required FbSortField field, required bool asc})
onSortChanged;
final VoidCallback onApply;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
child: Row(
children: [
Expanded(
child: Text(
'Filter & Sort',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton(
onPressed: onClear,
child: const Text('Clear all'),
),
],
),
),
const Divider(),
Expanded(
child: ListView(
controller: scroll,
padding: const EdgeInsets.all(16),
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Search by name',
prefixIcon: Icon(Icons.search),
isDense: true,
),
controller: TextEditingController(text: filter.nameQuery)
..selection = TextSelection.collapsed(
offset: filter.nameQuery.length,
),
onChanged: (v) {
filter.nameQuery = v;
onFilterChanged(filter);
},
),
const SizedBox(height: 16),
if (maxKcal > 0) ...[
Text(
'Kcal range',
style: Theme.of(context).textTheme.labelLarge,
),
RangeSlider(
max: maxKcal,
values: RangeValues(
filter.minKcal ?? 0,
filter.maxKcal ?? maxKcal,
),
labels: RangeLabels(
(filter.minKcal ?? 0).toStringAsFixed(0),
(filter.maxKcal ?? maxKcal).toStringAsFixed(0),
),
onChanged: (v) {
filter.minKcal = v.start > 0 ? v.start : null;
filter.maxKcal = v.end < maxKcal ? v.end : null;
onFilterChanged(filter);
},
),
const SizedBox(height: 8),
],
if (maxProtein > 0) ...[
Text(
'Protein range (g)',
style: Theme.of(context).textTheme.labelLarge,
),
RangeSlider(
max: maxProtein,
values: RangeValues(
filter.minProtein ?? 0,
filter.maxProtein ?? maxProtein,
),
labels: RangeLabels(
(filter.minProtein ?? 0).toStringAsFixed(0),
(filter.maxProtein ?? maxProtein).toStringAsFixed(0),
),
onChanged: (v) {
filter.minProtein = v.start > 0 ? v.start : null;
filter.maxProtein = v.end < maxProtein ? v.end : null;
onFilterChanged(filter);
},
),
const SizedBox(height: 8),
],
if (maxCarbs > 0) ...[
Text(
'Carbs range (g)',
style: Theme.of(context).textTheme.labelLarge,
),
RangeSlider(
max: maxCarbs,
values: RangeValues(
filter.minCarbs ?? 0,
filter.maxCarbs ?? maxCarbs,
),
labels: RangeLabels(
(filter.minCarbs ?? 0).toStringAsFixed(0),
(filter.maxCarbs ?? maxCarbs).toStringAsFixed(0),
),
onChanged: (v) {
filter.minCarbs = v.start > 0 ? v.start : null;
filter.maxCarbs = v.end < maxCarbs ? v.end : null;
onFilterChanged(filter);
},
),
const SizedBox(height: 8),
],
if (maxFat > 0) ...[
Text(
'Fat range (g)',
style: Theme.of(context).textTheme.labelLarge,
),
RangeSlider(
max: maxFat,
values: RangeValues(
filter.minFat ?? 0,
filter.maxFat ?? maxFat,
),
labels: RangeLabels(
(filter.minFat ?? 0).toStringAsFixed(0),
(filter.maxFat ?? maxFat).toStringAsFixed(0),
),
onChanged: (v) {
filter.minFat = v.start > 0 ? v.start : null;
filter.maxFat = v.end < maxFat ? v.end : null;
onFilterChanged(filter);
},
),
const SizedBox(height: 8),
],
Text(
'Sort by',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButton<FbSortField>(
isExpanded: true,
value: sortField,
items: const [
DropdownMenuItem(
value: FbSortField.count,
child: Text('Usage count'),
),
DropdownMenuItem(
value: FbSortField.name,
child: Text('Name'),
),
DropdownMenuItem(
value: FbSortField.kcal,
child: Text('Kcal'),
),
DropdownMenuItem(
value: FbSortField.protein,
child: Text('Protein'),
),
DropdownMenuItem(
value: FbSortField.carbs,
child: Text('Carbs'),
),
DropdownMenuItem(
value: FbSortField.fat,
child: Text('Fat'),
),
],
onChanged: (v) {
if (v != null) {
onSortChanged(field: v, asc: ascending);
}
},
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
ascending ? Icons.arrow_upward : Icons.arrow_downward,
),
tooltip: ascending ? 'Ascending' : 'Descending',
onPressed: () =>
onSortChanged(field: sortField, asc: !ascending),
),
],
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onApply,
child: const Text('Apply'),
),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Add entry dialog
// ---------------------------------------------------------------------------
class _AddEntryDialog extends StatefulWidget {
const _AddEntryDialog();
@override
State<_AddEntryDialog> createState() => _AddEntryDialogState();
}
class _AddEntryDialogState extends State<_AddEntryDialog> {
final _name = TextEditingController();
final _grams = TextEditingController(text: '100');
final _kcal = TextEditingController();
final _protein = TextEditingController();
final _carbs = TextEditingController();
final _fat = TextEditingController();
@override
void dispose() {
_name.dispose();
_grams.dispose();
_kcal.dispose();
_protein.dispose();
_carbs.dispose();
_fat.dispose();
super.dispose();
}
void _save() {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.of(context).pop(
FoodBankRecord(
desc: name,
kcal: double.tryParse(_kcal.text) ?? 0,
proteinG: double.tryParse(_protein.text) ?? 0,
carbsG: double.tryParse(_carbs.text) ?? 0,
fatG: double.tryParse(_fat.text) ?? 0,
grams: double.tryParse(_grams.text) ?? 100,
count: 0,
),
);
}
Widget _field(String label, TextEditingController ctrl) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: ctrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: label, isDense: true),
),
);
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add to food bank'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: _name,
decoration: const InputDecoration(
labelText: 'Name',
isDense: true,
),
),
),
_field('Reference grams', _grams),
_field('Kcal', _kcal),
_field('Protein (g)', _protein),
_field('Carbs (g)', _carbs),
_field('Fat (g)', _fat),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _save,
child: const Text('Save to bank'),
),
],
);
}
}

View File

@ -1,6 +1,9 @@
/// Small input widgets used by the history filter sheet.
/// Filter-sheet input widgets shared by the history and food-bank screens.
///
/// Split from `history_filter_sheet.dart` for the repo's 250-line cap.
/// Both screens grew the same name-search field, the same slider captions and
/// four copies each of the same min/max macro range block. They live here, in a
/// neutral `widgets/filters/` rather than under either screen, so neither
/// screen owns the other's controls.
library;
import 'package:flutter/material.dart';
@ -116,6 +119,7 @@ class FilterRangeRow extends StatelessWidget {
required this.max,
required this.onChanged,
this.unit = '',
this.showEndpointLabels = true,
super.key,
});
@ -140,6 +144,12 @@ class FilterRangeRow extends StatelessWidget {
/// Suffix appended to the endpoint and selection labels, e.g. `g`.
final String unit;
/// Whether to print the endpoint captions and the selected-range readout.
///
/// The history sheet shows both; the food-bank sheet never did, and turning
/// them on there would be a UI change smuggled in under a refactor.
final bool showEndpointLabels;
@override
Widget build(BuildContext context) {
if (maxValue <= 0) return const SizedBox.shrink();
@ -149,7 +159,8 @@ class FilterRangeRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'),
if (showEndpointLabels)
SliderEndpointLabels(lo: '0', hi: '${maxValue.round()}$unit'),
RangeSlider(
key: sliderKey,
max: maxValue,
@ -160,7 +171,8 @@ class FilterRangeRow extends StatelessWidget {
v.end < maxValue ? v.end : null,
),
),
SliderSelectedLabel('${lo.round()} – ${hi.round()}$unit'),
if (showEndpointLabels)
SliderSelectedLabel('${lo.round()} – ${hi.round()}$unit'),
const SizedBox(height: 8),
],
);

View File

@ -0,0 +1,103 @@
/// The "add a manual food bank entry" dialog.
///
/// Split out of `food_bank_screen.dart` for the repo's 250-line cap.
library;
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:flutter/material.dart';
/// Prompts for a new manually-curated food bank entry.
///
/// Pops with the new [FoodBankRecord], or null when cancelled.
class AddEntryDialog extends StatefulWidget {
/// Creates an [AddEntryDialog] with empty fields.
const AddEntryDialog({super.key});
@override
State<AddEntryDialog> createState() => AddEntryDialogState();
}
/// State for [AddEntryDialog], owning its text controllers.
class AddEntryDialogState extends State<AddEntryDialog> {
final _name = TextEditingController();
final _grams = TextEditingController(text: '100');
final _kcal = TextEditingController();
final _protein = TextEditingController();
final _carbs = TextEditingController();
final _fat = TextEditingController();
@override
void dispose() {
_name.dispose();
_grams.dispose();
_kcal.dispose();
_protein.dispose();
_carbs.dispose();
_fat.dispose();
super.dispose();
}
void _save() {
final name = _name.text.trim();
if (name.isEmpty) return;
Navigator.of(context).pop(
FoodBankRecord(
desc: name,
kcal: double.tryParse(_kcal.text) ?? 0,
proteinG: double.tryParse(_protein.text) ?? 0,
carbsG: double.tryParse(_carbs.text) ?? 0,
fatG: double.tryParse(_fat.text) ?? 0,
grams: double.tryParse(_grams.text) ?? 100,
count: 0,
),
);
}
Widget _field(String label, TextEditingController ctrl) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: ctrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: label, isDense: true),
),
);
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add to food bank'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: _name,
decoration: const InputDecoration(
labelText: 'Name',
isDense: true,
),
),
),
_field('Reference grams', _grams),
_field('Kcal', _kcal),
_field('Protein (g)', _protein),
_field('Carbs (g)', _carbs),
_field('Fat (g)', _fat),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _save,
child: const Text('Save to bank'),
),
],
);
}
}

View File

@ -0,0 +1,156 @@
/// Filter and sort model for the food bank browser.
///
/// Split out of `food_bank_screen.dart` for the repo's 250-line cap. Pure data
/// -- no widgets -- so the sheet, the list and the screen can each depend on it
/// without depending on one another.
library;
import 'package:diet_guard_app/models/food_bank_record.dart';
/// Sort field for the food bank list.
enum FbSortField {
/// Sort alphabetically by name.
name,
/// Sort by calories.
kcal,
/// Sort by protein (g).
protein,
/// Sort by carbohydrates (g).
carbs,
/// Sort by fat (g).
fat,
/// Sort by usage count (most-used first by default).
count,
}
/// Active filter criteria for the food bank list.
class FbFilter {
/// Creates a [FbFilter] with the given criteria.
FbFilter({
this.nameQuery = '',
this.minKcal,
this.maxKcal,
this.minProtein,
this.maxProtein,
this.minCarbs,
this.maxCarbs,
this.minFat,
this.maxFat,
});
/// Substring match on the food name.
String nameQuery;
/// Minimum kcal.
double? minKcal;
/// Maximum kcal.
double? maxKcal;
/// Minimum protein (g).
double? minProtein;
/// Maximum protein (g).
double? maxProtein;
/// Minimum carbs (g).
double? minCarbs;
/// Maximum carbs (g).
double? maxCarbs;
/// Minimum fat (g).
double? minFat;
/// Maximum fat (g).
double? maxFat;
/// True when any criterion is set.
bool get isActive =>
nameQuery.isNotEmpty ||
minKcal != null ||
maxKcal != null ||
minProtein != null ||
maxProtein != null ||
minCarbs != null ||
maxCarbs != null ||
minFat != null ||
maxFat != null;
}
// ---------------------------------------------------------------------------
// Pure filter / sort helper
// ---------------------------------------------------------------------------
/// Filters and sorts [entries] by [filter] and [sortField]/[ascending].
///
/// Exposed as a top-level function for unit tests.
List<FoodBankRecord> applyFbFilter(
List<FoodBankRecord> entries,
FbFilter filter,
FbSortField sortField, {
required bool ascending,
}) {
var result = [...entries];
if (filter.nameQuery.isNotEmpty) {
final q = filter.nameQuery.toLowerCase();
result = result.where((e) => e.desc.toLowerCase().contains(q)).toList();
}
if (filter.minKcal != null) {
result = result.where((e) => e.kcal >= filter.minKcal!).toList();
}
if (filter.maxKcal != null) {
result = result.where((e) => e.kcal <= filter.maxKcal!).toList();
}
if (filter.minProtein != null) {
result = result.where((e) => e.proteinG >= filter.minProtein!).toList();
}
if (filter.maxProtein != null) {
result = result.where((e) => e.proteinG <= filter.maxProtein!).toList();
}
if (filter.minCarbs != null) {
result = result.where((e) => e.carbsG >= filter.minCarbs!).toList();
}
if (filter.maxCarbs != null) {
result = result.where((e) => e.carbsG <= filter.maxCarbs!).toList();
}
if (filter.minFat != null) {
result = result.where((e) => e.fatG >= filter.minFat!).toList();
}
if (filter.maxFat != null) {
result = result.where((e) => e.fatG <= filter.maxFat!).toList();
}
result.sort((a, b) {
int cmp;
switch (sortField) {
case FbSortField.name:
cmp = a.desc.compareTo(b.desc);
case FbSortField.kcal:
cmp = a.kcal.compareTo(b.kcal);
case FbSortField.protein:
cmp = a.proteinG.compareTo(b.proteinG);
case FbSortField.carbs:
cmp = a.carbsG.compareTo(b.carbsG);
case FbSortField.fat:
cmp = a.fatG.compareTo(b.fatG);
case FbSortField.count:
cmp = a.count.compareTo(b.count);
}
return ascending ? cmp : -cmp;
});
return result;
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
/// Lists all food bank entries (log-derived + manual) with filtering/sorting
/// and a FAB for adding new manual entries.

View File

@ -0,0 +1,244 @@
/// The bottom sheet that edits an [FbFilter].
///
/// Split out of `food_bank_screen.dart` for the repo's 250-line cap. The macro
/// range rows come from `widgets/filters/`, shared with the history sheet.
library;
import 'package:diet_guard_app/widgets/filters/filter_fields.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart';
import 'package:flutter/material.dart';
/// The draggable bottom sheet that edits the food bank's filter and sort order.
///
/// Stateless on purpose: the live [FbFilter] belongs to the screen, and every
/// edit goes back out through [onFilterChanged] rather than being held here.
class FbFilterSheet extends StatelessWidget {
/// Creates an [FbFilterSheet] over the screen's current filter/sort state.
const FbFilterSheet({
required this.filter,
required this.sortField,
required this.ascending,
required this.maxKcal,
required this.maxProtein,
required this.maxCarbs,
required this.maxFat,
required this.onFilterChanged,
required this.onSortChanged,
required this.onApply,
required this.onClear,
super.key,
});
/// The filter criteria currently being edited.
final FbFilter filter;
/// The field the list is sorted by.
final FbSortField sortField;
/// Whether the sort runs ascending.
final bool ascending;
/// Upper bound of the calories slider, from the banked records.
final double maxKcal;
/// Upper bound of the protein slider, from the banked records.
final double maxProtein;
/// Upper bound of the carbohydrate slider, from the banked records.
final double maxCarbs;
/// Upper bound of the fat slider, from the banked records.
final double maxFat;
/// Called with a new filter on every edit.
final void Function(FbFilter) onFilterChanged;
/// Called when the sort field or direction changes.
final void Function({required FbSortField field, required bool asc})
onSortChanged;
/// Called when the user applies the filter and dismisses the sheet.
final VoidCallback onApply;
/// Called when the user clears every criterion.
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
child: Row(
children: [
Expanded(
child: Text(
'Filter & Sort',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton(
onPressed: onClear,
child: const Text('Clear all'),
),
],
),
),
const Divider(),
Expanded(
child: ListView(
controller: scroll,
padding: const EdgeInsets.all(16),
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Search by name',
prefixIcon: Icon(Icons.search),
isDense: true,
),
controller: TextEditingController(text: filter.nameQuery)
..selection = TextSelection.collapsed(
offset: filter.nameQuery.length,
),
onChanged: (v) {
filter.nameQuery = v;
onFilterChanged(filter);
},
),
const SizedBox(height: 16),
FilterRangeRow(
label: 'Kcal range',
sliderKey: const Key('fb-kcal-range-slider'),
maxValue: maxKcal,
min: filter.minKcal,
max: filter.maxKcal,
showEndpointLabels: false,
onChanged: (lo, hi) {
filter.minKcal = lo;
filter.maxKcal = hi;
onFilterChanged(filter);
},
),
FilterRangeRow(
label: 'Protein range (g)',
sliderKey: const Key('fb-protein-range-slider'),
maxValue: maxProtein,
min: filter.minProtein,
max: filter.maxProtein,
unit: 'g',
showEndpointLabels: false,
onChanged: (lo, hi) {
filter.minProtein = lo;
filter.maxProtein = hi;
onFilterChanged(filter);
},
),
FilterRangeRow(
label: 'Carbs range (g)',
sliderKey: const Key('fb-carbs-range-slider'),
maxValue: maxCarbs,
min: filter.minCarbs,
max: filter.maxCarbs,
unit: 'g',
showEndpointLabels: false,
onChanged: (lo, hi) {
filter.minCarbs = lo;
filter.maxCarbs = hi;
onFilterChanged(filter);
},
),
FilterRangeRow(
label: 'Fat range (g)',
sliderKey: const Key('fb-fat-range-slider'),
maxValue: maxFat,
min: filter.minFat,
max: filter.maxFat,
unit: 'g',
showEndpointLabels: false,
onChanged: (lo, hi) {
filter.minFat = lo;
filter.maxFat = hi;
onFilterChanged(filter);
},
),
Text(
'Sort by',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButton<FbSortField>(
isExpanded: true,
value: sortField,
items: const [
DropdownMenuItem(
value: FbSortField.count,
child: Text('Usage count'),
),
DropdownMenuItem(
value: FbSortField.name,
child: Text('Name'),
),
DropdownMenuItem(
value: FbSortField.kcal,
child: Text('Kcal'),
),
DropdownMenuItem(
value: FbSortField.protein,
child: Text('Protein'),
),
DropdownMenuItem(
value: FbSortField.carbs,
child: Text('Carbs'),
),
DropdownMenuItem(
value: FbSortField.fat,
child: Text('Fat'),
),
],
onChanged: (v) {
if (v != null) {
onSortChanged(field: v, asc: ascending);
}
},
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
ascending ? Icons.arrow_upward : Icons.arrow_downward,
),
tooltip: ascending ? 'Ascending' : 'Descending',
onPressed: () =>
onSortChanged(field: sortField, asc: !ascending),
),
],
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onApply,
child: const Text('Apply'),
),
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Add entry dialog
// ---------------------------------------------------------------------------

View File

@ -3,8 +3,8 @@
/// Split out of `history_screen.dart` to hold the repo's 250-line cap.
library;
import 'package:diet_guard_app/widgets/filters/filter_fields.dart';
import 'package:diet_guard_app/widgets/history/history_filter.dart';
import 'package:diet_guard_app/widgets/history/history_filter_fields.dart';
import 'package:diet_guard_app/widgets/history/history_filter_sort.dart';
import 'package:diet_guard_app/widgets/history/history_grouping.dart';
import 'package:flutter/material.dart';

View File

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

View File

@ -0,0 +1,218 @@
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/widgets/food_bank/fb_filter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'fb_test_support.dart';
void main() {
useTempFoodBankStores();
group('applyFbFilter', () {
final records = [
const FoodBankRecord(
desc: 'Apple',
kcal: 80,
proteinG: 0.5,
carbsG: 20,
fatG: 0.3,
grams: 100,
count: 5,
),
const FoodBankRecord(
desc: 'Banana',
kcal: 90,
proteinG: 1,
carbsG: 22,
fatG: 0.4,
grams: 100,
count: 10,
),
const FoodBankRecord(
desc: 'Chicken breast',
kcal: 165,
proteinG: 31,
carbsG: 0,
fatG: 3.6,
grams: 100,
count: 2,
),
];
test('no filter returns entries sorted by count descending', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.count,
ascending: false,
);
expect(result.map((r) => r.desc), ['Banana', 'Apple', 'Chicken breast']);
});
test('nameQuery filters by case-insensitive substring', () {
final result = applyFbFilter(
records,
FbFilter(nameQuery: 'an'),
FbSortField.name,
ascending: true,
);
expect(result.map((r) => r.desc), [
'Banana',
]); // only 'Banana' contains 'an'
});
test('minKcal and maxKcal filter by kcal', () {
final result = applyFbFilter(
records,
FbFilter(minKcal: 85, maxKcal: 100),
FbSortField.kcal,
ascending: true,
);
expect(result.map((r) => r.desc), ['Banana']);
});
test('minProtein filters by protein', () {
final result = applyFbFilter(
records,
FbFilter(minProtein: 10),
FbSortField.count,
ascending: false,
);
expect(result.map((r) => r.desc), ['Chicken breast']);
});
test('maxCarbs filters by carbs', () {
final result = applyFbFilter(
records,
FbFilter(maxCarbs: 5),
FbSortField.count,
ascending: false,
);
expect(result.map((r) => r.desc), ['Chicken breast']);
});
test('minFat and maxFat filter by fat', () {
final result = applyFbFilter(
records,
FbFilter(minFat: 0.35, maxFat: 1),
FbSortField.count,
ascending: false,
);
expect(result.map((r) => r.desc), ['Banana']);
});
test('maxProtein filters by protein', () {
final result = applyFbFilter(
records,
FbFilter(maxProtein: 5),
FbSortField.count,
ascending: false,
);
// Banana (1 g) and Apple (0.5 g) have protein ≤ 5 g; sorted count desc.
expect(result.map((r) => r.desc), ['Banana', 'Apple']);
});
test('minCarbs filters by carbs', () {
final result = applyFbFilter(
records,
FbFilter(minCarbs: 10),
FbSortField.count,
ascending: false,
);
// Banana (22 g) and Apple (20 g) have carbs ≥ 10 g; sorted count desc.
expect(result.map((r) => r.desc), ['Banana', 'Apple']);
});
test('sort ascending by name', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.name,
ascending: true,
);
expect(result.map((r) => r.desc), ['Apple', 'Banana', 'Chicken breast']);
});
test('sort descending by kcal', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.kcal,
ascending: false,
);
expect(result.first.desc, 'Chicken breast');
});
test('sort ascending by protein', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.protein,
ascending: true,
);
expect(result.first.desc, 'Apple');
});
test('sort by carbs ascending', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.carbs,
ascending: true,
);
expect(result.first.desc, 'Chicken breast'); // 0g
});
test('sort by fat descending', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.fat,
ascending: false,
);
expect(result.first.desc, 'Chicken breast'); // 3.6g
});
test('FbFilter.isActive is false when nothing is set', () {
expect(FbFilter().isActive, isFalse);
});
test('FbFilter.isActive is true when nameQuery is set', () {
expect(FbFilter(nameQuery: 'x').isActive, isTrue);
});
test('FbFilter.isActive is true when minKcal is set', () {
expect(FbFilter(minKcal: 50).isActive, isTrue);
});
test('FbFilter.isActive is true when maxKcal is set', () {
expect(FbFilter(maxKcal: 500).isActive, isTrue);
});
test('FbFilter.isActive is true when minProtein is set', () {
expect(FbFilter(minProtein: 5).isActive, isTrue);
});
test('FbFilter.isActive is true when maxProtein is set', () {
expect(FbFilter(maxProtein: 50).isActive, isTrue);
});
test('FbFilter.isActive is true when minCarbs is set', () {
expect(FbFilter(minCarbs: 5).isActive, isTrue);
});
test('FbFilter.isActive is true when maxCarbs is set', () {
expect(FbFilter(maxCarbs: 50).isActive, isTrue);
});
test('FbFilter.isActive is true when minFat is set', () {
expect(FbFilter(minFat: 1).isActive, isTrue);
});
test('FbFilter.isActive is true when maxFat is set', () {
expect(FbFilter(maxFat: 10).isActive, isTrue);
});
});
// ---------------------------------------------------------------------------
// Widget tests
// ---------------------------------------------------------------------------
}

View File

@ -0,0 +1,206 @@
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'fb_test_support.dart';
void main() {
useTempFoodBankStores();
testWidgets('shows empty-bank message when no entries exist', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
expect(find.textContaining('Food bank is empty'), findsOneWidget);
});
});
testWidgets('lists entries from the merged bank', (tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Manual oat',
kcal: 370,
proteinG: 13,
carbsG: 66,
fatG: 7,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
expect(find.text('Manual oat'), findsOneWidget);
});
});
testWidgets('FAB opens add-entry dialog and saving adds to bank', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byType(FloatingActionButton));
await settle(tester);
expect(find.text('Add to food bank'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, 'Name'),
'Test food',
);
await tester.enterText(
find.widgetWithText(TextField, 'Kcal'),
'200',
);
await tester.tap(find.text('Save to bank'));
await settle(tester);
// After saving, the screen reloads and shows the new entry.
expect(find.text('Test food'), findsOneWidget);
});
});
testWidgets('dialog cancel does not save anything', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byType(FloatingActionButton));
await settle(tester);
await tester.tap(find.text('Cancel'));
await settle(tester);
expect(find.textContaining('Food bank is empty'), findsOneWidget);
});
});
testWidgets('dialog save with empty name does nothing', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byType(FloatingActionButton));
await settle(tester);
// Tap save without entering a name.
await tester.tap(find.text('Save to bank'));
await settle(tester);
// Dialog stays open; no entry saved.
expect(find.text('Add to food bank'), findsOneWidget);
});
});
testWidgets('filter icon appears when entries exist', (tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Oat',
kcal: 370,
proteinG: 13,
carbsG: 66,
fatG: 7,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
expect(
find.widgetWithIcon(IconButton, Icons.filter_list),
findsOneWidget,
);
});
});
testWidgets('filter sheet opens and Apply filters results', (tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Oat',
kcal: 370,
proteinG: 13,
carbsG: 66,
fatG: 7,
grams: 100,
count: 0,
),
);
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Egg',
kcal: 155,
proteinG: 13,
carbsG: 1,
fatG: 11,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// Type in the only TextField in the sheet (the name search field).
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'), findsOneWidget);
expect(find.text('Egg'), findsNothing);
});
});
testWidgets('filter sheet Clear all resets draft then Apply shows all', (
tester,
) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Walnut',
kcal: 654,
proteinG: 15,
carbsG: 14,
fatG: 65,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
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('Walnut'), findsOneWidget);
});
});
}

View File

@ -0,0 +1,184 @@
import 'package:diet_guard_app/models/food_bank_record.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/services/foodbank_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'fb_test_support.dart';
void main() {
useTempFoodBankStores();
testWidgets('record tile shows usage count for log-derived entries', (
tester,
) async {
await tester.runAsync(() async {
await FoodBankService.instance.rebuildAndPersist({
'2026-06-22': [
const FoodEntry(
id: '1',
time: '2026-06-22T08:00:00+02:00',
desc: 'rice',
grams: 100,
kcal: 130,
proteinG: 3,
carbsG: 28,
fatG: 0.3,
source: 'manual',
),
const FoodEntry(
id: '2',
time: '2026-06-22T12:00:00+02:00',
desc: 'rice',
grams: 100,
kcal: 130,
proteinG: 3,
carbsG: 28,
fatG: 0.3,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
// The rice entry was logged twice — the tile trailing shows ×2.
expect(find.textContaining('×2'), findsOneWidget);
});
});
testWidgets('filter sheet sort dropdown changes sort field', (tester) async {
await tester.runAsync(() async {
// Zero macros: no RangeSliders appear, sort section is immediately visible.
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'ZeroItem',
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// With no sliders rendered, 'Sort by' and its dropdown are immediately
// visible — open the sort-field dropdown (shows 'Usage count' by default).
await tester.tap(find.text('Usage count'));
await settle(tester);
// Tap 'Name' in the dropdown overlay.
await tester.tap(find.text('Name').last);
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.text('ZeroItem'), 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 FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'SliderFood',
kcal: 200,
proteinG: 10,
carbsG: 25,
fatG: 8,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
// Use getRect() + dragFrom() to bypass the _maybeViewOf ancestor-search
// failure that tester.drag(finder, …) triggers inside modal overlays.
// Kcal slider covers lines 468-471.
await tester.dragFrom(
tester.getRect(find.byType(RangeSlider).at(0)).center,
const Offset(-30, 0),
);
await settle(tester);
// Protein slider covers lines 491-495.
await tester.dragFrom(
tester.getRect(find.byType(RangeSlider).at(1)).center,
const Offset(-30, 0),
);
await settle(tester);
// Carbs slider covers lines 515-518.
await tester.dragFrom(
tester.getRect(find.byType(RangeSlider).at(2)).center,
const Offset(-30, 0),
);
await settle(tester);
// Fat slider covers lines 538-541.
await tester.dragFrom(
tester.getRect(find.byType(RangeSlider).at(3)).center,
const Offset(-30, 0),
);
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
});
});
testWidgets('filter sheet sort direction toggle fires onSortChanged', (
tester,
) async {
await tester.runAsync(() async {
// Zero macros: no RangeSliders appear, sort section is immediately visible.
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'ZeroItem2',
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// Default sort is count-descending; the 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('ZeroItem2'), findsOneWidget);
});
});
}

View File

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

View File

@ -1,614 +0,0 @@
import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/models/food_bank_record.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/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_screen_');
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
});
tearDown(() async {
FoodBankService.resetForTesting();
LogStorageService.resetForTesting();
await tempDir.delete(recursive: true);
});
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
// ---------------------------------------------------------------------------
// applyFbFilter — pure function tests
// ---------------------------------------------------------------------------
group('applyFbFilter', () {
final records = [
const FoodBankRecord(
desc: 'Apple',
kcal: 80,
proteinG: 0.5,
carbsG: 20,
fatG: 0.3,
grams: 100,
count: 5,
),
const FoodBankRecord(
desc: 'Banana',
kcal: 90,
proteinG: 1,
carbsG: 22,
fatG: 0.4,
grams: 100,
count: 10,
),
const FoodBankRecord(
desc: 'Chicken breast',
kcal: 165,
proteinG: 31,
carbsG: 0,
fatG: 3.6,
grams: 100,
count: 2,
),
];
test('no filter returns entries sorted by count descending', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.count,
ascending: false,
);
expect(result.map((r) => r.desc), ['Banana', 'Apple', 'Chicken breast']);
});
test('nameQuery filters by case-insensitive substring', () {
final result = applyFbFilter(
records,
FbFilter(nameQuery: 'an'),
FbSortField.name,
ascending: true,
);
expect(result.map((r) => r.desc), [
'Banana',
]); // only 'Banana' contains 'an'
});
test('minKcal and maxKcal filter by kcal', () {
final result = applyFbFilter(
records,
FbFilter(minKcal: 85, maxKcal: 100),
FbSortField.kcal,
ascending: true,
);
expect(result.map((r) => r.desc), ['Banana']);
});
test('minProtein filters by protein', () {
final result = applyFbFilter(
records,
FbFilter(minProtein: 10),
FbSortField.count,
ascending: false,
);
expect(result.map((r) => r.desc), ['Chicken breast']);
});
test('maxCarbs filters by carbs', () {
final result = applyFbFilter(
records,
FbFilter(maxCarbs: 5),
FbSortField.count,
ascending: false,
);
expect(result.map((r) => r.desc), ['Chicken breast']);
});
test('minFat and maxFat filter by fat', () {
final result = applyFbFilter(
records,
FbFilter(minFat: 0.35, maxFat: 1),
FbSortField.count,
ascending: false,
);
expect(result.map((r) => r.desc), ['Banana']);
});
test('maxProtein filters by protein', () {
final result = applyFbFilter(
records,
FbFilter(maxProtein: 5),
FbSortField.count,
ascending: false,
);
// Banana (1 g) and Apple (0.5 g) have protein ≤ 5 g; sorted count desc.
expect(result.map((r) => r.desc), ['Banana', 'Apple']);
});
test('minCarbs filters by carbs', () {
final result = applyFbFilter(
records,
FbFilter(minCarbs: 10),
FbSortField.count,
ascending: false,
);
// Banana (22 g) and Apple (20 g) have carbs ≥ 10 g; sorted count desc.
expect(result.map((r) => r.desc), ['Banana', 'Apple']);
});
test('sort ascending by name', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.name,
ascending: true,
);
expect(result.map((r) => r.desc), ['Apple', 'Banana', 'Chicken breast']);
});
test('sort descending by kcal', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.kcal,
ascending: false,
);
expect(result.first.desc, 'Chicken breast');
});
test('sort ascending by protein', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.protein,
ascending: true,
);
expect(result.first.desc, 'Apple');
});
test('sort by carbs ascending', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.carbs,
ascending: true,
);
expect(result.first.desc, 'Chicken breast'); // 0g
});
test('sort by fat descending', () {
final result = applyFbFilter(
records,
FbFilter(),
FbSortField.fat,
ascending: false,
);
expect(result.first.desc, 'Chicken breast'); // 3.6g
});
test('FbFilter.isActive is false when nothing is set', () {
expect(FbFilter().isActive, isFalse);
});
test('FbFilter.isActive is true when nameQuery is set', () {
expect(FbFilter(nameQuery: 'x').isActive, isTrue);
});
test('FbFilter.isActive is true when minKcal is set', () {
expect(FbFilter(minKcal: 50).isActive, isTrue);
});
test('FbFilter.isActive is true when maxKcal is set', () {
expect(FbFilter(maxKcal: 500).isActive, isTrue);
});
test('FbFilter.isActive is true when minProtein is set', () {
expect(FbFilter(minProtein: 5).isActive, isTrue);
});
test('FbFilter.isActive is true when maxProtein is set', () {
expect(FbFilter(maxProtein: 50).isActive, isTrue);
});
test('FbFilter.isActive is true when minCarbs is set', () {
expect(FbFilter(minCarbs: 5).isActive, isTrue);
});
test('FbFilter.isActive is true when maxCarbs is set', () {
expect(FbFilter(maxCarbs: 50).isActive, isTrue);
});
test('FbFilter.isActive is true when minFat is set', () {
expect(FbFilter(minFat: 1).isActive, isTrue);
});
test('FbFilter.isActive is true when maxFat is set', () {
expect(FbFilter(maxFat: 10).isActive, isTrue);
});
});
// ---------------------------------------------------------------------------
// Widget tests
// ---------------------------------------------------------------------------
testWidgets('shows empty-bank message when no entries exist', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
expect(find.textContaining('Food bank is empty'), findsOneWidget);
});
});
testWidgets('lists entries from the merged bank', (tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Manual oat',
kcal: 370,
proteinG: 13,
carbsG: 66,
fatG: 7,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
expect(find.text('Manual oat'), findsOneWidget);
});
});
testWidgets('FAB opens add-entry dialog and saving adds to bank', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byType(FloatingActionButton));
await settle(tester);
expect(find.text('Add to food bank'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, 'Name'),
'Test food',
);
await tester.enterText(
find.widgetWithText(TextField, 'Kcal'),
'200',
);
await tester.tap(find.text('Save to bank'));
await settle(tester);
// After saving, the screen reloads and shows the new entry.
expect(find.text('Test food'), findsOneWidget);
});
});
testWidgets('dialog cancel does not save anything', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byType(FloatingActionButton));
await settle(tester);
await tester.tap(find.text('Cancel'));
await settle(tester);
expect(find.textContaining('Food bank is empty'), findsOneWidget);
});
});
testWidgets('dialog save with empty name does nothing', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byType(FloatingActionButton));
await settle(tester);
// Tap save without entering a name.
await tester.tap(find.text('Save to bank'));
await settle(tester);
// Dialog stays open; no entry saved.
expect(find.text('Add to food bank'), findsOneWidget);
});
});
testWidgets('filter icon appears when entries exist', (tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Oat',
kcal: 370,
proteinG: 13,
carbsG: 66,
fatG: 7,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
expect(
find.widgetWithIcon(IconButton, Icons.filter_list),
findsOneWidget,
);
});
});
testWidgets('filter sheet opens and Apply filters results', (tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Oat',
kcal: 370,
proteinG: 13,
carbsG: 66,
fatG: 7,
grams: 100,
count: 0,
),
);
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Egg',
kcal: 155,
proteinG: 13,
carbsG: 1,
fatG: 11,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// Type in the only TextField in the sheet (the name search field).
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'), findsOneWidget);
expect(find.text('Egg'), findsNothing);
});
});
testWidgets('filter sheet Clear all resets draft then Apply shows all', (
tester,
) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Walnut',
kcal: 654,
proteinG: 15,
carbsG: 14,
fatG: 65,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
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('Walnut'), findsOneWidget);
});
});
testWidgets('record tile shows usage count for log-derived entries', (
tester,
) async {
await tester.runAsync(() async {
await FoodBankService.instance.rebuildAndPersist({
'2026-06-22': [
const FoodEntry(
id: '1',
time: '2026-06-22T08:00:00+02:00',
desc: 'rice',
grams: 100,
kcal: 130,
proteinG: 3,
carbsG: 28,
fatG: 0.3,
source: 'manual',
),
const FoodEntry(
id: '2',
time: '2026-06-22T12:00:00+02:00',
desc: 'rice',
grams: 100,
kcal: 130,
proteinG: 3,
carbsG: 28,
fatG: 0.3,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
// The rice entry was logged twice — the tile trailing shows ×2.
expect(find.textContaining('×2'), findsOneWidget);
});
});
testWidgets('filter sheet sort dropdown changes sort field', (tester) async {
await tester.runAsync(() async {
// Zero macros: no RangeSliders appear, sort section is immediately visible.
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'ZeroItem',
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// With no sliders rendered, 'Sort by' and its dropdown are immediately
// visible — open the sort-field dropdown (shows 'Usage count' by default).
await tester.tap(find.text('Usage count'));
await settle(tester);
// Tap 'Name' in the dropdown overlay.
await tester.tap(find.text('Name').last);
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
expect(find.text('ZeroItem'), 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 FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'SliderFood',
kcal: 200,
proteinG: 10,
carbsG: 25,
fatG: 8,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
// Use getRect() + dragFrom() to bypass the _maybeViewOf ancestor-search
// failure that tester.drag(finder, …) triggers inside modal overlays.
// Kcal slider covers lines 468-471.
await tester.dragFrom(
tester.getRect(find.byType(RangeSlider).at(0)).center,
const Offset(-30, 0),
);
await settle(tester);
// Protein slider covers lines 491-495.
await tester.dragFrom(
tester.getRect(find.byType(RangeSlider).at(1)).center,
const Offset(-30, 0),
);
await settle(tester);
// Carbs slider covers lines 515-518.
await tester.dragFrom(
tester.getRect(find.byType(RangeSlider).at(2)).center,
const Offset(-30, 0),
);
await settle(tester);
// Fat slider covers lines 538-541.
await tester.dragFrom(
tester.getRect(find.byType(RangeSlider).at(3)).center,
const Offset(-30, 0),
);
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
});
});
testWidgets('filter sheet sort direction toggle fires onSortChanged', (
tester,
) async {
await tester.runAsync(() async {
// Zero macros: no RangeSliders appear, sort section is immediately visible.
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'ZeroItem2',
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
grams: 100,
count: 0,
),
);
await tester.pumpWidget(const MaterialApp(home: FoodBankScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// Default sort is count-descending; the 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('ZeroItem2'), findsOneWidget);
});
});
}