mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
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:
parent
895a87cf86
commit
7cb09a5b85
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
103
app/lib/widgets/food_bank/fb_add_entry_dialog.dart
Normal file
103
app/lib/widgets/food_bank/fb_add_entry_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
156
app/lib/widgets/food_bank/fb_filter.dart
Normal file
156
app/lib/widgets/food_bank/fb_filter.dart
Normal 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.
|
||||
244
app/lib/widgets/food_bank/fb_filter_sheet.dart
Normal file
244
app/lib/widgets/food_bank/fb_filter_sheet.dart
Normal 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
218
app/test/screens/food_bank/fb_filter_test.dart
Normal file
218
app/test/screens/food_bank/fb_filter_test.dart
Normal 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
|
||||
// ---------------------------------------------------------------------------
|
||||
}
|
||||
206
app/test/screens/food_bank/fb_screen_test.dart
Normal file
206
app/test/screens/food_bank/fb_screen_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
184
app/test/screens/food_bank/fb_sheet_test.dart
Normal file
184
app/test/screens/food_bank/fb_sheet_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
42
app/test/screens/food_bank/fb_test_support.dart
Normal file
42
app/test/screens/food_bank/fb_test_support.dart
Normal 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();
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user