Add food bank, edit-entry, and app settings screens; history redesign

Work-in-progress feature set accumulated ahead of the log-meal compact
layout change: a food bank browser, a shared entry-edit screen, an
app-wide settings service, and a substantially reworked history screen
with filtering/sorting.

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

View File

@ -5,6 +5,7 @@ library;
import 'dart:io';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/background_check_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
@ -15,6 +16,7 @@ import 'package:workmanager/workmanager.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await LogStorageService.init();
await AppSettingsService.init();
await FoodBankService.init();
final notifications = await NotificationService.init();
await notifications.requestPermission();

View File

@ -23,8 +23,7 @@ class FoodBankRecord {
});
/// Builds a [FoodBankRecord] from its JSON map representation.
factory FoodBankRecord.fromJson(Map<String, dynamic> json) =>
FoodBankRecord(
factory FoodBankRecord.fromJson(Map<String, dynamic> json) => FoodBankRecord(
desc: json['desc'] as String? ?? '',
kcal: (json['kcal'] as num?)?.toDouble() ?? 0,
proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0,

View File

@ -23,8 +23,11 @@ const int gateEatingEndHour = 22;
/// Mirrors `_slots.day_slots`.
List<int> daySlots() {
final slots = <int>[];
for (var hour = gateDayStartHour; hour < gateEatingEndHour;
hour += gateSlotIntervalHours) {
for (
var hour = gateDayStartHour;
hour < gateEatingEndHour;
hour += gateSlotIntervalHours
) {
slots.add(hour);
}
return slots;

View File

@ -0,0 +1,182 @@
/// Screen for editing an existing meal-history entry in-place.
library;
import 'dart:async';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
/// Edit screen for an existing [FoodEntry].
///
/// Preserves [FoodEntry.id], [FoodEntry.time], [FoodEntry.slot],
/// [FoodEntry.imagePath], and [FoodEntry.deleted]. All nutritional fields and
/// the description are editable.
class EditEntryScreen extends StatefulWidget {
/// Creates an [EditEntryScreen] pre-filled with [entry].
const EditEntryScreen({required this.entry, super.key});
/// The entry to edit.
final FoodEntry entry;
@override
State<EditEntryScreen> createState() => _EditEntryScreenState();
}
class _EditEntryScreenState extends State<EditEntryScreen> {
late final TextEditingController _descController;
final MacroControllers _macros = MacroControllers();
List<FoodSuggestion> _suggestions = const [];
String _source = 'manual';
String? _status;
@override
void initState() {
super.initState();
final e = widget.entry;
_descController = TextEditingController(text: e.desc);
_macros.kcal.text = e.kcal.toStringAsFixed(0);
_macros.protein.text = e.proteinG.toStringAsFixed(0);
_macros.carbs.text = e.carbsG.toStringAsFixed(0);
_macros.fat.text = e.fatG.toStringAsFixed(0);
_macros.grams.text = e.grams > 0 ? e.grams.toStringAsFixed(0) : '';
_source = e.source;
_descController.addListener(_onDescChanged);
for (final c in [
_macros.kcal,
_macros.protein,
_macros.carbs,
_macros.fat,
_macros.perGrams,
_macros.grams,
]) {
c.addListener(_onMacroEdited);
}
unawaited(_onDescChanged());
}
@override
void dispose() {
_descController.dispose();
_macros.dispose();
super.dispose();
}
void _onMacroEdited() {
if (_source == 'food bank') {
setState(() => _source = 'manual');
}
}
Future<void> _onDescChanged() async {
final matches = await FoodBankService.instance.search(
_descController.text,
);
if (!mounted) return;
setState(() => _suggestions = matches);
}
void _onSuggestionSelected(FoodSuggestion suggestion) {
_descController.text = suggestion.name;
_macros.kcal.text = suggestion.nutrition.kcal.toStringAsFixed(0);
_macros.protein.text = suggestion.nutrition.proteinG.toStringAsFixed(0);
_macros.carbs.text = suggestion.nutrition.carbsG.toStringAsFixed(0);
_macros.fat.text = suggestion.nutrition.fatG.toStringAsFixed(0);
_macros.perGrams.text = suggestion.nutrition.grams.toStringAsFixed(0);
_macros.grams.text = suggestion.nutrition.grams.toStringAsFixed(0);
setState(() {
_source = 'food bank';
_suggestions = const [];
});
}
double _parse(TextEditingController c) => double.tryParse(c.text.trim()) ?? 0;
Future<void> _onSave() async {
final desc = _descController.text.trim();
if (desc.isEmpty) {
setState(() => _status = 'Description cannot be empty.');
return;
}
final nutrition = nutritionForPortion(
kcal: _parse(_macros.kcal),
proteinG: _parse(_macros.protein),
carbsG: _parse(_macros.carbs),
fatG: _parse(_macros.fat),
perGrams: _parse(_macros.perGrams),
ateGrams: _parse(_macros.grams),
source: _source,
);
final e = widget.entry;
final updated = FoodEntry(
// Assign a UUID when editing a legacy entry that never had one.
// This upgrades it to a first-class sync entry without creating a
// duplicate: the Python sync deduplicates null-id entries by time+desc.
id: e.id ?? const Uuid().v4(),
time: e.time,
desc: desc,
grams: nutrition.grams,
kcal: nutrition.kcal,
proteinG: nutrition.proteinG,
carbsG: nutrition.carbsG,
fatG: nutrition.fatG,
source: _source,
slot: e.slot,
imagePath: e.imagePath,
);
await LogStorageService.instance.updateEntry(e, updated);
final log = await LogStorageService.instance.readLog();
await FoodBankService.instance.rebuildAndPersist(log);
if (!mounted) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit meal')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'What did you eat?',
),
),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: AutocompleteSuggestionList(
suggestions: _suggestions,
onSelected: _onSuggestionSelected,
),
),
const SizedBox(height: 12),
MacroInputRow(controllers: _macros),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _onSave,
child: const Text('Save'),
),
),
if (_status != null) ...[
const SizedBox(height: 12),
Text(_status!),
],
],
),
),
);
}
}

View File

@ -0,0 +1,712 @@
/// Food bank browser: lists every entry across the log-derived and manual
/// banks with filtering, sorting, and the ability to add new manual entries.
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: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.
class FoodBankScreen extends StatefulWidget {
/// Creates a [FoodBankScreen].
const FoodBankScreen({super.key});
@override
State<FoodBankScreen> createState() => _FoodBankScreenState();
}
class _FoodBankScreenState extends State<FoodBankScreen> {
List<FoodBankRecord>? _allEntries;
List<FoodBankRecord> _displayed = const [];
FbFilter _filter = FbFilter();
FbSortField _sortField = FbSortField.count;
bool _sortAscending = false;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final entries = await FoodBankService.instance.mergedEntries();
if (!mounted) return;
setState(() {
_allEntries = entries;
_displayed = applyFbFilter(
entries,
_filter,
_sortField,
ascending: _sortAscending,
);
});
}
void _applyFilterSort() {
setState(() {
_displayed = applyFbFilter(
_allEntries!,
_filter,
_sortField,
ascending: _sortAscending,
);
});
}
Future<void> _openFilterSheet() async {
final all = _allEntries!;
double maxVal(double Function(FoodBankRecord) f, double fallback) =>
all.isEmpty ? fallback : all.map(f).reduce((a, b) => a > b ? a : b);
final maxKcal = maxVal((e) => e.kcal, 2000);
final maxProtein = maxVal((e) => e.proteinG, 200);
final maxCarbs = maxVal((e) => e.carbsG, 200);
final maxFat = maxVal((e) => e.fatG, 100);
var draft = FbFilter(
nameQuery: _filter.nameQuery,
minKcal: _filter.minKcal,
maxKcal: _filter.maxKcal,
minProtein: _filter.minProtein,
maxProtein: _filter.maxProtein,
minCarbs: _filter.minCarbs,
maxCarbs: _filter.maxCarbs,
minFat: _filter.minFat,
maxFat: _filter.maxFat,
);
var draftSort = _sortField;
var draftAsc = _sortAscending;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheet) => _FbFilterSheet(
filter: draft,
sortField: draftSort,
ascending: draftAsc,
maxKcal: maxKcal,
maxProtein: maxProtein,
maxCarbs: maxCarbs,
maxFat: maxFat,
onFilterChanged: (f) => setSheet(() => draft = f),
onSortChanged: ({required field, required asc}) {
setSheet(() {
draftSort = field;
draftAsc = asc;
});
},
onApply: () {
setState(() {
_filter = draft;
_sortField = draftSort;
_sortAscending = draftAsc;
});
_applyFilterSort();
Navigator.of(ctx).pop();
},
onClear: () {
setSheet(() {
draft = FbFilter();
draftSort = FbSortField.count;
draftAsc = false;
});
},
),
),
);
}
Future<void> _openAddDialog() async {
final result = await showDialog<FoodBankRecord>(
context: context,
builder: (_) => const _AddEntryDialog(),
);
if (result == null) return;
await FoodBankService.instance.addManualEntry(result);
await _load();
}
@override
Widget build(BuildContext context) {
final all = _allEntries;
return Scaffold(
appBar: AppBar(
title: const Text('Food Bank'),
actions: [
if (all != null)
Stack(
alignment: Alignment.center,
children: [
IconButton(
icon: const Icon(Icons.filter_list),
tooltip: 'Filter & sort',
onPressed: _openFilterSheet,
),
if (_filter.isActive)
Positioned(
top: 8,
right: 8,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
),
),
],
),
],
),
body: all == null
? const Center(child: CircularProgressIndicator())
: _displayed.isEmpty
? Center(
child: Text(
all.isEmpty
? 'Food bank is empty.\n'
'Log meals to populate it, or add entries manually.'
: 'No entries match the current filter.',
textAlign: TextAlign.center,
),
)
: ListView.builder(
itemCount: _displayed.length,
itemBuilder: (context, i) => _RecordTile(_displayed[i]),
),
floatingActionButton: FloatingActionButton(
onPressed: _openAddDialog,
tooltip: 'Add manual entry',
child: const Icon(Icons.add),
),
);
}
}
// ---------------------------------------------------------------------------
// Record tile
// ---------------------------------------------------------------------------
class _RecordTile extends StatelessWidget {
const _RecordTile(this.record);
final FoodBankRecord record;
@override
Widget build(BuildContext context) {
final macros =
'P ${record.proteinG.toStringAsFixed(0)} g '
'C ${record.carbsG.toStringAsFixed(0)} g '
'F ${record.fatG.toStringAsFixed(0)} g';
final per = record.grams > 0
? ' per ${record.grams.toStringAsFixed(0)} g'
: '';
return ListTile(
title: Text(record.desc),
subtitle: Text(
'${record.kcal.toStringAsFixed(0)} kcal$per · $macros',
),
trailing: record.count > 0
? Text(
'×${record.count.toStringAsFixed(0)}',
style: Theme.of(context).textTheme.bodySmall,
)
: null,
);
}
}
// ---------------------------------------------------------------------------
// 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'),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ library;
import 'dart:async';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/github_client.dart';
import 'package:diet_guard_app/services/github_device_auth.dart';
import 'package:diet_guard_app/services/sync_service.dart';
@ -41,6 +42,7 @@ class SettingsScreen extends StatefulWidget {
}
class _SettingsScreenState extends State<SettingsScreen> {
final _kcalGoalController = TextEditingController();
final _ownerController = TextEditingController();
final _repoController = TextEditingController();
final _tokenController = TextEditingController();
@ -66,6 +68,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
settings = const SyncSettings(owner: '', repo: '', token: '');
}
if (!mounted) return;
_kcalGoalController.text = AppSettingsService.dailyKcalGoal.toString();
_ownerController.text = settings.owner;
_repoController.text = settings.repo;
_tokenController.text = settings.token;
@ -75,6 +78,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
void dispose() {
_kcalGoalController.dispose();
_ownerController.dispose();
_repoController.dispose();
_tokenController.dispose();
@ -227,10 +231,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: const Text('Sync settings')),
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Nutrition', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _kcalGoalController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Daily kcal goal',
helperText: 'Shown in the history day summary',
suffixText: 'kcal',
),
onChanged: (v) {
final n = int.tryParse(v);
if (n != null && n > 0) {
unawaited(AppSettingsService.instance.saveDailyKcalGoal(n));
}
},
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Text(
'Authorize in your browser — no token to paste. Syncs to '
'kuhyx/diet-guard-sync by default.',

View File

@ -0,0 +1,87 @@
/// User-adjustable app settings persisted locally as JSON.
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// Singleton storing lightweight settings such as the daily kcal goal.
///
/// The static [dailyKcalGoal] getter returns the default (2200) when the
/// singleton has not been initialised safe to read in widget tests that
/// never call [init].
class AppSettingsService {
AppSettingsService._(this._file);
static AppSettingsService? _instance;
/// Returns the initialized singleton; throws if [init] was not called.
static AppSettingsService get instance => _instance!;
final File _file;
int _dailyKcalGoal = 2200;
/// Returns the configured daily kcal goal, or 2200 when uninitialised.
static int get dailyKcalGoal => _instance?._dailyKcalGoal ?? 2200;
/// Initialises the singleton, pointing at the app's documents directory.
static Future<AppSettingsService> init() async {
if (_instance != null) return _instance!;
final dir = await getApplicationDocumentsDirectory();
final svc = AppSettingsService._(
File(p.join(dir.path, 'app_settings.json')),
);
await svc._load();
_instance = svc;
return svc;
}
/// Resets the singleton so [init] can be called again in tests.
///
/// When [testDir] is given, reads/writes go there instead of the real
/// documents directory.
@visibleForTesting
static void resetForTesting({Directory? testDir}) {
_instance = testDir == null
? null
: AppSettingsService._(
File(p.join(testDir.path, 'app_settings.json')),
);
}
/// Initialises from [testDir], calling [_load], for use in unit tests.
///
/// Bypasses [getApplicationDocumentsDirectory] so tests don't need platform
/// channels.
@visibleForTesting
static Future<AppSettingsService> initForTesting(Directory testDir) async {
final svc = AppSettingsService._(
File(p.join(testDir.path, 'app_settings.json')),
);
await svc._load();
_instance = svc;
return svc;
}
Future<void> _load() async {
if (!_file.existsSync()) return;
try {
final data = jsonDecode(await _file.readAsString());
if (data is Map && data['daily_kcal_goal'] is int) {
_dailyKcalGoal = data['daily_kcal_goal'] as int;
}
} on Exception {
// Ignore parse errors and keep default.
}
}
/// Updates the in-memory value and persists [goal] to disk.
Future<void> saveDailyKcalGoal(int goal) async {
_dailyKcalGoal = goal;
await _file.parent.create(recursive: true);
await _file.writeAsString(jsonEncode({'daily_kcal_goal': goal}));
}
}

View File

@ -111,7 +111,8 @@ class FoodBankService {
/// `_foodbank.remember_food`/`remember_meal`'s upsert semantics: latest
/// macros win per normalized name, `count` increments per occurrence.
static Map<String, FoodBankRecord> rebuild(DayLog log) {
final entries = log.values
final entries =
log.values
.expand((entries) => entries)
.where((entry) => !entry.deleted)
.toList()
@ -199,15 +200,86 @@ class FoodBankService {
return bank;
}
// ---------------------------------------------------------------------------
// Manual bank (food items added directly without logging them as eaten)
// ---------------------------------------------------------------------------
File get _manualFile =>
File(p.join(_file.parent.path, 'food_bank_manual.json'));
Future<Map<String, FoodBankRecord>> _readManualBank() async {
final file = _manualFile;
if (!file.existsSync()) return {};
String raw;
try {
raw = await file.readAsString();
} on FileSystemException {
return {};
}
Object? data;
try {
data = jsonDecode(raw);
} on FormatException {
return {};
}
if (data is! Map) return {};
final result = <String, FoodBankRecord>{};
for (final entry in data.entries) {
final key = entry.key;
final value = entry.value;
if (key is String && value is Map) {
result[key] = FoodBankRecord.fromJson(value.cast<String, dynamic>());
}
}
return result;
}
Future<void> _writeManualBank(Map<String, FoodBankRecord> bank) async {
final file = _manualFile;
await file.parent.create(recursive: true);
final encoded = <String, Object?>{
for (final entry in bank.entries) entry.key: entry.value.toJson(),
};
await file.writeAsString(jsonEncode(encoded));
}
/// Adds or updates [record] in the manually-curated bank without logging it
/// as eaten. A repeated call with the same normalized name overwrites the
/// previous entry.
Future<void> addManualEntry(FoodBankRecord record) async {
final bank = await _readManualBank();
bank[_normalize(record.desc)] = record;
await _writeManualBank(bank);
}
/// All known food records: log-derived entries merged with manually-added
/// ones, sorted by count descending.
///
/// Log-derived records (from [readBank]) take precedence over manual records
/// with the same normalized name.
Future<List<FoodBankRecord>> mergedEntries() async {
final logBank = await readBank();
final manualBank = await _readManualBank();
final merged = <String, FoodBankRecord>{...manualBank, ...logBank};
return merged.values.toList()..sort((a, b) => b.count.compareTo(a.count));
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
/// Returns banked foods matching [query], best match first.
///
/// An empty query returns the most-logged foods. Mirrors
/// `_foodbank.search_foods`.
/// `_foodbank.search_foods`. Searches both log-derived and manually-added
/// entries; log-derived entries win on name collision.
Future<List<FoodSuggestion>> search(
String query, {
int limit = defaultSuggestions,
}) async {
final bank = await readBank();
final logBank = await readBank();
final manualBank = await _readManualBank();
final bank = <String, FoodBankRecord>{...manualBank, ...logBank};
final normalized = _normalize(query);
if (normalized.isEmpty) return _rankedAll(bank, limit);

View File

@ -165,7 +165,8 @@ class LogStorageService {
/// "today".
Future<List<FoodEntry>> allEntriesNewestFirst() async {
final log = await readLog();
final entries = [
final entries =
[
for (final dayEntries in log.values)
...dayEntries.where((e) => !e.deleted),
]..sort((a, b) {
@ -187,13 +188,49 @@ class LogStorageService {
return double.parse(total.toStringAsFixed(1));
}
/// Tombstones the entry with [id] wherever it appears in the log.
///
/// Silently does nothing when the id is not found or the entry is already
/// deleted covers both legacy null-id entries and double-delete races.
Future<void> deleteEntry(String id) async {
final log = await readLog();
for (final entries in log.values) {
for (var i = 0; i < entries.length; i++) {
if (entries[i].id == id && !entries[i].deleted) {
entries[i] = entries[i].copyWithDeleted();
await writeLog(log);
return;
}
}
}
}
/// Replaces the stored entry matching [original] with [updated].
///
/// Matches by [FoodEntry.id] when present; falls back to
/// `time + desc` for legacy entries that predate UUID support.
/// Silently does nothing when no match is found.
Future<void> updateEntry(FoodEntry original, FoodEntry updated) async {
final log = await readLog();
for (final entries in log.values) {
for (var i = 0; i < entries.length; i++) {
final e = entries[i];
final matches = original.id != null
? e.id == original.id
: e.time == original.time && e.desc == original.desc;
if (matches) {
entries[i] = updated;
await writeLog(log);
return;
}
}
}
}
/// Returns the slot hours already satisfied today, mirrors
/// `_state.logged_slots_today`.
Future<Set<int>> loggedSlotsToday() async {
final entries = await todayEntries();
return entries
.where((e) => e.slot != null)
.map((e) => e.slot!)
.toSet();
return entries.where((e) => e.slot != null).map((e) => e.slot!).toSet();
}
}

View File

@ -26,7 +26,8 @@ const _devicesDir = 'devices';
/// phone is the only other device in this design.
const phoneDeviceId = 'phone';
String _deviceLogPath(String deviceId) => '$_devicesDir/$deviceId/food_log.json';
String _deviceLogPath(String deviceId) =>
'$_devicesDir/$deviceId/food_log.json';
/// Runs one full sync tick: pull, merge, preserve photos, persist, push.
///

View File

@ -0,0 +1,268 @@
import 'dart:io';
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/edit_entry_screen.dart';
import 'package:diet_guard_app/screens/history_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';
const _entry = FoodEntry(
id: 'test-uuid',
time: '2026-06-22T12:00:00+02:00',
desc: 'pizza obok pracy',
grams: 400,
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
source: 'manual',
);
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_edit_test_');
LogStorageService.resetForTesting(testDir: tempDir);
FoodBankService.resetForTesting(testDir: tempDir);
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
await tempDir.delete(recursive: true);
});
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
testWidgets('pre-fills all fields from the entry', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
expect(find.text('pizza obok pracy'), findsOneWidget);
expect(find.text('1200'), findsOneWidget);
expect(find.text('52'), findsOneWidget);
expect(find.text('145'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.text('400'), findsOneWidget);
});
});
testWidgets('Save button is present and AppBar title is correct', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
expect(find.text('Edit meal'), findsOneWidget);
expect(find.text('Save'), findsOneWidget);
});
});
testWidgets('shows error when description is cleared', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// Clear the description field.
final descField = find.ancestor(
of: find.text('pizza obok pracy'),
matching: find.byType(TextField),
);
await tester.enterText(descField, '');
await tester.tap(find.text('Save'));
await settle(tester);
expect(find.text('Description cannot be empty.'), findsOneWidget);
});
});
testWidgets('Save persists updated kcal to the log', (tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// Change kcal.
await tester.enterText(
find.ancestor(
of: find.text('1200'),
matching: find.byType(TextField),
),
'999',
);
await tester.tap(find.text('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
final saved = log['2026-06-22']!.first;
expect(saved.kcal, 999);
expect(saved.desc, 'pizza obok pracy');
expect(saved.id, 'test-uuid');
});
});
testWidgets('legacy null-id entry gains a UUID on save', (tester) async {
await tester.runAsync(() async {
const legacy = FoodEntry(
time: '2026-06-22T08:00:00+02:00',
desc: 'kabanosy',
grams: 380,
kcal: 1174,
proteinG: 53,
carbsG: 19,
fatG: 152,
source: 'food bank',
);
await LogStorageService.instance.writeLog({
'2026-06-22': [legacy],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: legacy)),
);
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
final saved = log['2026-06-22']!.first;
expect(saved.id, isNotNull);
expect(saved.id, isNotEmpty);
expect(saved.kcal, 1174);
});
});
testWidgets('selecting a food bank suggestion fills all macro fields', (
tester,
) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'pizza obok pracy',
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
grams: 400,
count: 1,
),
);
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// The desc field already matches the bank entry a suggestion appears.
expect(find.text('pizza obok pracy'), findsWidgets);
// Tap the suggestion tile (the one in the autocomplete list, not the
// TextField itself).
final suggestionTiles = find.text('pizza obok pracy');
// At least 2 matches: TextField text + suggestion tile.
expect(suggestionTiles, findsWidgets);
await tester.tap(suggestionTiles.last);
await settle(tester);
// After selection, suggestions are cleared and macros are filled.
expect(find.text('1200'), findsOneWidget);
});
});
testWidgets(
'editing a macro after selecting a suggestion resets source to manual',
(tester) async {
await tester.runAsync(() async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'pizza obok pracy',
kcal: 1200,
proteinG: 52,
carbsG: 145,
fatG: 42,
grams: 400,
count: 1,
),
);
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(
MaterialApp(home: EditEntryScreen(entry: _entry)),
);
await settle(tester);
// Select the suggestion to set _source = 'food bank'.
final suggestionTiles = find.text('pizza obok pracy');
await tester.tap(suggestionTiles.last);
await settle(tester);
// Manually edit kcal should flip _source back to 'manual'.
await tester.enterText(
find.ancestor(
of: find.text('1200'),
matching: find.byType(TextField),
),
'999',
);
await settle(tester);
await tester.tap(find.text('Save'));
await settle(tester);
final log = await LogStorageService.instance.readLog();
// Source reverts to 'manual' after macro edit.
expect(log['2026-06-22']!.first.source, 'manual');
});
},
);
testWidgets('tapping an entry tile navigates to EditEntryScreen', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [_entry],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.text('pizza obok pracy'));
await settle(tester);
expect(find.text('Edit meal'), findsOneWidget);
expect(find.text('pizza obok pracy'), findsOneWidget);
// Macros are pre-filled.
expect(find.text('1200'), findsOneWidget);
});
});
}

View File

@ -0,0 +1,613 @@
import 'dart:io';
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(testDir: tempDir);
LogStorageService.resetForTesting(testDir: 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);
});
});
}

View File

@ -75,11 +75,11 @@ void main() {
);
await settle(tester);
// Field order: [0] meal name, [1] item name, [2] kcal,
// [3] per (g), [4] protein, [5] carbs, [6] fat, [7] ate (g).
// Field order: [0] meal name, [1] item name, [2] per (g),
// [3] kcal, [4] protein, [5] carbs, [6] fat, [7] ate (g).
await tester.enterText(find.byType(TextField).at(1), 'rice');
await tester.enterText(find.byType(TextField).at(2), '200');
await tester.enterText(find.byType(TextField).at(3), '100');
await tester.enterText(find.byType(TextField).at(2), '100');
await tester.enterText(find.byType(TextField).at(3), '200');
await tester.enterText(find.byType(TextField).at(4), '4');
await tester.enterText(find.byType(TextField).at(5), '44');
await tester.enterText(find.byType(TextField).at(6), '1');
@ -92,7 +92,7 @@ void main() {
expect(find.textContaining('300 kcal'), findsOneWidget);
await tester.enterText(find.byType(TextField).at(1), 'chicken');
await tester.enterText(find.byType(TextField).at(2), '165');
await tester.enterText(find.byType(TextField).at(3), '165');
await tester.enterText(find.byType(TextField).at(4), '31');
await tester.enterText(find.byType(TextField).at(5), '0');
await tester.enterText(find.byType(TextField).at(6), '4');
@ -103,8 +103,7 @@ void main() {
await tester.tap(logMealButton);
await settle(tester);
final entry =
(await LogStorageService.instance.todayEntries()).single;
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.source, 'meal');
expect(entry.kcal, 465); // 300 (scaled rice) + 165 (chicken)
expect(entry.components, hasLength(2));
@ -135,7 +134,7 @@ void main() {
await settle(tester);
await tester.enterText(find.byType(TextField).at(1), 'soup');
await tester.enterText(find.byType(TextField).at(2), '120');
await tester.enterText(find.byType(TextField).at(3), '120');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
@ -150,11 +149,115 @@ void main() {
await tester.tap(logMealButton);
await settle(tester);
final entry =
(await LogStorageService.instance.todayEntries()).single;
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.imagePath, isNotNull);
expect(entry.imagePath, startsWith('${tempDir.path}/images/'));
});
},
);
testWidgets(
'logged meal uses provided name when name field is non-empty (line 73)',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
// Enter a meal name in field at(0), item in at(1), kcal in at(3).
await tester.enterText(find.byType(TextField).at(0), 'Sunday dinner');
await tester.enterText(find.byType(TextField).at(1), 'pasta');
await tester.enterText(find.byType(TextField).at(3), '400');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.desc, equals('Sunday dinner'));
});
},
);
testWidgets(
'Add item with empty description shows error status (line 46)',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
// Tap "Add item" without entering any description.
await tester.tap(addItemButton);
await settle(tester);
expect(
find.text('Type the item first, then add it.'),
findsOneWidget,
);
});
},
);
testWidgets(
'logging a meal with empty name field defaults name to "meal" (line 73)',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
// Add one item (name field intentionally left empty).
await tester.enterText(find.byType(TextField).at(1), 'rice');
await tester.enterText(find.byType(TextField).at(3), '200');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
// Leave meal name field empty, then log name defaults to 'meal'.
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.desc, equals('meal'));
});
},
);
testWidgets(
'photo attach sheet "Take a photo" choice covers camera onTap (lines 42-43)',
(tester) async {
await tester.runAsync(() async {
final fakePhoto = File('${tempDir.path}/fake_camera.jpg')
..createSync()
..writeAsBytesSync([0xFF, 0xD8, 0xFF]);
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
XFile(fakePhoto.path),
);
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
await tester.enterText(find.byType(TextField).at(1), 'salad');
await tester.enterText(find.byType(TextField).at(3), '100');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
await tester.ensureVisible(find.text('Attach photo'));
await tester.tap(find.text('Attach photo'));
await settle(tester);
// Tap "Take a photo" triggers camera onTap (lines 42-43).
await tester.tap(find.text('Take a photo'));
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.imagePath, isNotNull);
});
},
);
}

View File

@ -0,0 +1,115 @@
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp(
'diet_guard_settings_test_',
);
AppSettingsService.resetForTesting();
});
tearDown(() async {
AppSettingsService.resetForTesting();
await tempDir.delete(recursive: true);
});
group('dailyKcalGoal static getter', () {
test('returns 2200 when singleton is uninitialised', () {
// Singleton is null after resetForTesting() exercises the ?? 2200 branch.
expect(AppSettingsService.dailyKcalGoal, 2200);
});
});
group('resetForTesting', () {
test('with testDir creates a working instance', () {
AppSettingsService.resetForTesting(testDir: tempDir);
expect(AppSettingsService.instance, isNotNull);
});
test('without testDir nulls the singleton', () {
AppSettingsService.resetForTesting(testDir: tempDir);
AppSettingsService.resetForTesting();
// instance getter throws when null verify via dailyKcalGoal fallback.
expect(AppSettingsService.dailyKcalGoal, 2200);
});
});
group('init early-return', () {
test('returns existing instance without re-initialising', () async {
AppSettingsService.resetForTesting(testDir: tempDir);
final first = AppSettingsService.instance;
// init() sees _instance != null and returns early (no platform channel).
final second = await AppSettingsService.init();
expect(identical(first, second), isTrue);
});
});
group('saveDailyKcalGoal', () {
test('updates in-memory value and persists to file', () async {
AppSettingsService.resetForTesting(testDir: tempDir);
await AppSettingsService.instance.saveDailyKcalGoal(1800);
expect(AppSettingsService.dailyKcalGoal, 1800);
final raw = await File(
'${tempDir.path}/app_settings.json',
).readAsString();
final data = jsonDecode(raw) as Map;
expect(data['daily_kcal_goal'], 1800);
});
});
group('initForTesting (_load paths)', () {
test('loads daily_kcal_goal from an existing file', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
await AppSettingsService.initForTesting(tempDir);
expect(AppSettingsService.dailyKcalGoal, 1600);
});
test('keeps default 2200 when file does not exist', () async {
await AppSettingsService.initForTesting(tempDir);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 on unparseable JSON', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString('not json at all');
await AppSettingsService.initForTesting(tempDir);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 when JSON root is not a Map', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode([1, 2, 3]));
await AppSettingsService.initForTesting(tempDir);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
test('keeps default 2200 when daily_kcal_goal is not an int', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 'two thousand'}));
await AppSettingsService.initForTesting(tempDir);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
});
}

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/meal_component.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
@ -178,4 +179,216 @@ void main() {
expect(results.first.name, 'common');
});
});
// ---------------------------------------------------------------------------
// Manual bank addManualEntry / mergedEntries
// ---------------------------------------------------------------------------
group('FoodBankService manual bank', () {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_manual_');
FoodBankService.resetForTesting(testDir: tempDir);
LogStorageService.resetForTesting(testDir: tempDir);
});
tearDown(() async {
FoodBankService.resetForTesting();
LogStorageService.resetForTesting();
await tempDir.delete(recursive: true);
});
test('addManualEntry persists and mergedEntries returns it', () async {
const record = FoodBankRecord(
desc: 'Manual oat',
kcal: 370,
proteinG: 13,
carbsG: 66,
fatG: 7,
grams: 100,
count: 0,
);
await FoodBankService.instance.addManualEntry(record);
final merged = await FoodBankService.instance.mergedEntries();
expect(merged.any((r) => r.desc == 'Manual oat'), isTrue);
});
test(
'mergedEntries: log-derived entry wins over manual on collision',
() async {
// Seed log with 'oat' (count=1, kcal=100).
final log = {
'2026-06-22': [
_entry(id: '1', time: '2026-06-22T08:00:00+02:00', desc: 'oat'),
],
};
await FoodBankService.instance.rebuildAndPersist(log);
// Add manual entry with same normalized key but different kcal.
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'oat',
kcal: 999,
proteinG: 0,
carbsG: 0,
fatG: 0,
grams: 100,
count: 0,
),
);
final merged = await FoodBankService.instance.mergedEntries();
final oat = merged.firstWhere((r) => r.desc == 'oat');
// Log-derived entry (kcal=100) should win over manual (kcal=999).
expect(oat.kcal, 100);
},
);
test(
'mergedEntries includes both log-derived and manual entries',
() async {
final log = {
'2026-06-22': [
_entry(
id: '1',
time: '2026-06-22T08:00:00+02:00',
desc: 'toast',
),
],
};
await FoodBankService.instance.rebuildAndPersist(log);
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Quinoa',
kcal: 370,
proteinG: 14,
carbsG: 64,
fatG: 6,
grams: 100,
count: 0,
),
);
final merged = await FoodBankService.instance.mergedEntries();
final descs = merged.map((r) => r.desc).toSet();
expect(descs, containsAll(['toast', 'Quinoa']));
},
);
test('addManualEntry upserts by normalized key', () async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Oat',
kcal: 370,
proteinG: 13,
carbsG: 66,
fatG: 7,
grams: 100,
count: 0,
),
);
// Upsert same food with updated kcal.
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'oat',
kcal: 400,
proteinG: 14,
carbsG: 68,
fatG: 8,
grams: 100,
count: 0,
),
);
final merged = await FoodBankService.instance.mergedEntries();
final oats = merged.where((r) => r.desc.toLowerCase() == 'oat').toList();
expect(oats.length, 1);
expect(oats.single.kcal, 400);
});
test('search includes manual entries', () async {
await FoodBankService.instance.addManualEntry(
const FoodBankRecord(
desc: 'Rare ingredient',
kcal: 50,
proteinG: 1,
carbsG: 10,
fatG: 0.5,
grams: 100,
count: 0,
),
);
final results = await FoodBankService.instance.search('Rare');
expect(results.any((r) => r.name == 'Rare ingredient'), isTrue);
});
});
// ---------------------------------------------------------------------------
// IO error paths FileSystemException and FormatException handlers
// ---------------------------------------------------------------------------
group('FoodBankService IO error paths', () {
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_fb_err_');
FoodBankService.resetForTesting(testDir: tempDir);
LogStorageService.resetForTesting(testDir: tempDir);
});
tearDown(() async {
FoodBankService.resetForTesting();
LogStorageService.resetForTesting();
await tempDir.delete(recursive: true);
});
test('readBank returns empty on invalid JSON (FormatException)', () async {
await File(
'${tempDir.path}/food_bank.json',
).writeAsString('not valid json {{{');
expect(await FoodBankService.instance.readBank(), isEmpty);
});
test(
'readBank returns empty when file is unreadable (FileSystemException)',
() async {
final bankPath = '${tempDir.path}/food_bank.json';
await File(bankPath).writeAsString('{}');
await Process.run('chmod', ['000', bankPath]);
expect(await FoodBankService.instance.readBank(), isEmpty);
await Process.run('chmod', ['644', bankPath]);
},
);
test(
'mergedEntries handles invalid JSON in manual bank (FormatException)',
() async {
await File(
'${tempDir.path}/food_bank_manual.json',
).writeAsString('not valid json {{{');
expect(await FoodBankService.instance.mergedEntries(), isEmpty);
},
);
test(
'mergedEntries handles unreadable manual bank (FileSystemException)',
() async {
final manualPath = '${tempDir.path}/food_bank_manual.json';
await File(manualPath).writeAsString('{}');
await Process.run('chmod', ['000', manualPath]);
expect(await FoodBankService.instance.mergedEntries(), isEmpty);
await Process.run('chmod', ['644', manualPath]);
},
);
});
}

View File

@ -13,10 +13,12 @@ void main() {
expect(substring, greaterThan(typo));
});
test('scores an empty query against a name as the fallback token score',
test(
'scores an empty query against a name as the fallback token score',
() {
expect(matchScore('', 'banana'), greaterThanOrEqualTo(0));
});
},
);
test('scores a clear mismatch low', () {
expect(matchScore('xyz', 'banana'), lessThan(0.6));

View File

@ -41,7 +41,12 @@ void main() {
(_) async => http.Response(
jsonEncode([
{'type': 'dir', 'name': 'pc', 'path': 'devices/pc', 'sha': 's1'},
{'type': 'dir', 'name': 'phone', 'path': 'devices/phone', 'sha': 's2'},
{
'type': 'dir',
'name': 'phone',
'path': 'devices/phone',
'sha': 's2',
},
]),
200,
),

View File

@ -132,7 +132,9 @@ void main() {
fatG: 3,
source: 'manual',
);
await LogStorageService.instance.writeLog({yesterdayKey: [yesterday]});
await LogStorageService.instance.writeLog({
yesterdayKey: [yesterday],
});
await LogStorageService.instance.logMeal('today', _manual);
expect(await LogStorageService.instance.undoLastToday(), isNotNull);
@ -141,15 +143,20 @@ void main() {
expect(log[yesterdayKey]!.single.deleted, isFalse);
});
test('skips an already-tombstoned entry and undoes the one before it',
test(
'skips an already-tombstoned entry and undoes the one before it',
() async {
final first = await LogStorageService.instance.logMeal('first', _manual);
final first = await LogStorageService.instance.logMeal(
'first',
_manual,
);
await LogStorageService.instance.logMeal('second', _manual);
await LogStorageService.instance.undoLastToday();
final undoneAgain = await LogStorageService.instance.undoLastToday();
expect(undoneAgain!.id, first.id);
expect(await LogStorageService.instance.undoLastToday(), isNull);
});
},
);
});
group('todayTotalKcal', () {
@ -211,7 +218,8 @@ void main() {
deleted: true,
);
test('sorts entries across days newest-first and drops tombstones',
test(
'sorts entries across days newest-first and drops tombstones',
() async {
await LogStorageService.instance.writeLog({
'2026-06-01': [oldest],
@ -222,10 +230,155 @@ void main() {
final result = await LogStorageService.instance.allEntriesNewestFirst();
expect(result.map((e) => e.id), ['newest', 'oldest']);
});
},
);
test('returns empty for an empty log', () async {
expect(await LogStorageService.instance.allEntriesNewestFirst(), isEmpty);
});
});
group('deleteEntry', () {
const entry = FoodEntry(
id: 'del-1',
time: '2026-06-22T12:00:00+02:00',
desc: 'to delete',
grams: 100,
kcal: 300,
proteinG: 10,
carbsG: 30,
fatG: 5,
source: 'manual',
);
test('tombstones the matching entry', () async {
await LogStorageService.instance.writeLog({
'2026-06-22': [entry],
});
await LogStorageService.instance.deleteEntry('del-1');
final log = await LogStorageService.instance.readLog();
expect(log['2026-06-22']!.first.deleted, isTrue);
});
test('silently ignores an unknown id', () async {
await LogStorageService.instance.writeLog({
'2026-06-22': [entry],
});
await LogStorageService.instance.deleteEntry('no-such-id');
final log = await LogStorageService.instance.readLog();
expect(log['2026-06-22']!.first.deleted, isFalse);
});
test('does not re-tombstone an already-deleted entry', () async {
const deleted = FoodEntry(
id: 'del-1',
time: '2026-06-22T12:00:00+02:00',
desc: 'to delete',
grams: 100,
kcal: 300,
proteinG: 10,
carbsG: 30,
fatG: 5,
source: 'manual',
deleted: true,
);
await LogStorageService.instance.writeLog({
'2026-06-22': [deleted],
});
await LogStorageService.instance.deleteEntry('del-1');
// Still deleted, no error thrown.
final log = await LogStorageService.instance.readLog();
expect(log['2026-06-22']!.first.deleted, isTrue);
});
});
group('updateEntry', () {
const original = FoodEntry(
id: 'upd-1',
time: '2026-06-22T12:00:00+02:00',
desc: 'original desc',
grams: 100,
kcal: 300,
proteinG: 10,
carbsG: 30,
fatG: 5,
source: 'manual',
);
const updated = FoodEntry(
id: 'upd-1',
time: '2026-06-22T12:00:00+02:00',
desc: 'edited desc',
grams: 200,
kcal: 600,
proteinG: 20,
carbsG: 60,
fatG: 10,
source: 'manual',
);
test('replaces the entry by id', () async {
await LogStorageService.instance.writeLog({
'2026-06-22': [original],
});
await LogStorageService.instance.updateEntry(original, updated);
final log = await LogStorageService.instance.readLog();
final e = log['2026-06-22']!.first;
expect(e.desc, 'edited desc');
expect(e.kcal, 600);
expect(e.proteinG, 20);
});
test('replaces legacy null-id entry by time+desc', () async {
const legacy = FoodEntry(
time: '2026-06-22T12:00:00+02:00',
desc: 'legacy entry',
grams: 100,
kcal: 300,
proteinG: 10,
carbsG: 30,
fatG: 5,
source: 'food bank',
);
const legacyUpdated = FoodEntry(
id: 'new-uuid',
time: '2026-06-22T12:00:00+02:00',
desc: 'legacy entry',
grams: 150,
kcal: 450,
proteinG: 15,
carbsG: 45,
fatG: 8,
source: 'food bank',
);
await LogStorageService.instance.writeLog({
'2026-06-22': [legacy],
});
await LogStorageService.instance.updateEntry(legacy, legacyUpdated);
final log = await LogStorageService.instance.readLog();
final e = log['2026-06-22']!.first;
expect(e.id, 'new-uuid');
expect(e.kcal, 450);
});
test('silently does nothing when no match is found', () async {
await LogStorageService.instance.writeLog({
'2026-06-22': [original],
});
const ghost = FoodEntry(
id: 'ghost',
time: '2026-06-22T12:00:00+02:00',
desc: 'ghost',
grams: 0,
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'manual',
);
await LogStorageService.instance.updateEntry(ghost, updated);
final log = await LogStorageService.instance.readLog();
expect(log['2026-06-22']!.first.desc, 'original desc');
});
});
}

View File

@ -35,7 +35,9 @@ void main() {
await tempDir.delete(recursive: true);
});
test('copies the picked file into <documents>/images with a new name', () async {
test(
'copies the picked file into <documents>/images with a new name',
() async {
final source = File('${tempDir.path}/source.jpg')
..writeAsBytesSync([1, 2, 3, 4]);
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
@ -50,7 +52,8 @@ void main() {
expect(result, startsWith('${tempDir.path}/images/'));
expect(result, endsWith('.jpg'));
expect(File(result!).readAsBytesSync(), [1, 2, 3, 4]);
});
},
);
test('returns null when the picker is cancelled', () async {
ImagePickerPlatform.instance = _FakeImagePickerPlatform(null);

View File

@ -39,9 +39,14 @@ void main() {
test('same id in both logs is not duplicated', () {
final shared = _entry(id: 'shared');
final merged = mergeLogs({
final merged = mergeLogs(
{
'2026-06-22': [shared],
}, {'2026-06-22': [shared]});
},
{
'2026-06-22': [shared],
},
);
expect(merged['2026-06-22'], hasLength(1));
});
@ -56,9 +61,14 @@ void main() {
time: '2026-06-20T08:00:00',
desc: 'toast',
);
final merged = mergeLogs({
final merged = mergeLogs(
{
'2026-06-20': [legacyA],
}, {'2026-06-20': [legacyB]});
},
{
'2026-06-20': [legacyB],
},
);
expect(merged['2026-06-20'], hasLength(1));
});
@ -69,9 +79,14 @@ void main() {
desc: 'toast',
);
final withId = _entry(id: 'x', time: '2026-06-20T09:00:00', desc: 'eggs');
final merged = mergeLogs({
final merged = mergeLogs(
{
'2026-06-20': [legacy],
}, {'2026-06-20': [withId]});
},
{
'2026-06-20': [withId],
},
);
expect(merged['2026-06-20'], hasLength(2));
});
});
@ -81,12 +96,22 @@ void main() {
final normal = _entry(id: 'x');
final tombstoned = _entry(id: 'x', deleted: true);
final forward = mergeLogs({
final forward = mergeLogs(
{
'2026-06-22': [normal],
}, {'2026-06-22': [tombstoned]});
final backward = mergeLogs({
},
{
'2026-06-22': [tombstoned],
}, {'2026-06-22': [normal]});
},
);
final backward = mergeLogs(
{
'2026-06-22': [tombstoned],
},
{
'2026-06-22': [normal],
},
);
expect(forward['2026-06-22']!.single.deleted, isTrue);
expect(backward['2026-06-22']!.single.deleted, isTrue);
@ -94,9 +119,14 @@ void main() {
test('two tombstoned copies stay tombstoned', () {
final tombstoned = _entry(id: 'x', deleted: true);
final merged = mergeLogs({
final merged = mergeLogs(
{
'2026-06-22': [tombstoned],
}, {'2026-06-22': [_entry(id: 'x', deleted: true)]});
},
{
'2026-06-22': [_entry(id: 'x', deleted: true)],
},
);
expect(merged['2026-06-22']!.single.deleted, isTrue);
});
});
@ -106,7 +136,9 @@ void main() {
"entry is filed under its own time's date, not the arrival bucket",
() {
final misfiled = _entry(id: 'x', time: '2026-06-21T23:00:00');
final merged = mergeLogs({'2026-06-22': [misfiled]}, {});
final merged = mergeLogs({
'2026-06-22': [misfiled],
}, {});
expect(merged.keys, ['2026-06-21']);
expect(merged['2026-06-21']!.single.id, 'x');
},
@ -119,7 +151,9 @@ void main() {
// Dart's substring throws past the string length, unlike Python's
// forgiving slice -- this only matters for malformed/legacy data.
final short = _entry(id: 'x', time: '2026');
final merged = mergeLogs({'2026-06-22': [short]}, {});
final merged = mergeLogs({
'2026-06-22': [short],
}, {});
expect(merged.keys, ['2026']);
},
);
@ -127,9 +161,14 @@ void main() {
test("a day's entries are sorted oldest-first", () {
final late = _entry(id: 'late', time: '2026-06-22T20:00:00');
final early = _entry(id: 'early', time: '2026-06-22T08:00:00');
final merged = mergeLogs({
final merged = mergeLogs(
{
'2026-06-22': [late],
}, {'2026-06-22': [early]});
},
{
'2026-06-22': [early],
},
);
expect(merged['2026-06-22']!.map((e) => e.id).toList(), [
'early',
'late',
@ -139,7 +178,9 @@ void main() {
group('algebraic properties', () {
test('merge is commutative', () {
final a = {'2026-06-22': [_entry(id: 'a')]};
final a = {
'2026-06-22': [_entry(id: 'a')],
};
final b = {
'2026-06-22': [_entry(id: 'b', time: '2026-06-22T09:00:00')],
};
@ -152,13 +193,17 @@ void main() {
});
test('merge is idempotent', () {
final canonical = {'2026-06-22': [_entry(id: 'a')]};
final canonical = {
'2026-06-22': [_entry(id: 'a')],
};
final merged = mergeLogs(canonical, canonical);
expect(merged['2026-06-22']!.map((e) => e.id).toList(), ['a']);
});
test('merging with an empty log is a no-op', () {
final log = {'2026-06-22': [_entry(id: 'a')]};
final log = {
'2026-06-22': [_entry(id: 'a')],
};
expect(mergeLogs(log, {}).keys, log.keys);
expect(mergeLogs({}, log).keys, log.keys);
});

View File

@ -20,8 +20,9 @@ void main() {
await tempDir.delete(recursive: true);
});
testWidgets('app launches straight into the meal-logging screen',
(tester) async {
testWidgets('app launches straight into the meal-logging screen', (
tester,
) async {
// LogMealScreen's initState does real dart:io file I/O; pumpAndSettle()
// alone never lets that resolve (see log_meal_screen_test.dart).
await tester.runAsync(() async {