mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 13:23:11 +02:00
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:
parent
7d421c1d8b
commit
4c6083b768
@ -5,6 +5,7 @@ library;
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:diet_guard_app/screens/log_meal_screen.dart';
|
import 'package:diet_guard_app/screens/log_meal_screen.dart';
|
||||||
|
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||||
import 'package:diet_guard_app/services/background_check_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/foodbank_service.dart';
|
||||||
import 'package:diet_guard_app/services/log_storage_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 {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await LogStorageService.init();
|
await LogStorageService.init();
|
||||||
|
await AppSettingsService.init();
|
||||||
await FoodBankService.init();
|
await FoodBankService.init();
|
||||||
final notifications = await NotificationService.init();
|
final notifications = await NotificationService.init();
|
||||||
await notifications.requestPermission();
|
await notifications.requestPermission();
|
||||||
|
|||||||
@ -23,17 +23,16 @@ class FoodBankRecord {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Builds a [FoodBankRecord] from its JSON map representation.
|
/// Builds a [FoodBankRecord] from its JSON map representation.
|
||||||
factory FoodBankRecord.fromJson(Map<String, dynamic> json) =>
|
factory FoodBankRecord.fromJson(Map<String, dynamic> json) => FoodBankRecord(
|
||||||
FoodBankRecord(
|
desc: json['desc'] as String? ?? '',
|
||||||
desc: json['desc'] as String? ?? '',
|
kcal: (json['kcal'] as num?)?.toDouble() ?? 0,
|
||||||
kcal: (json['kcal'] as num?)?.toDouble() ?? 0,
|
proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0,
|
||||||
proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0,
|
carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0,
|
||||||
carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0,
|
fatG: (json['fat_g'] as num?)?.toDouble() ?? 0,
|
||||||
fatG: (json['fat_g'] as num?)?.toDouble() ?? 0,
|
grams: (json['grams'] as num?)?.toDouble() ?? 0,
|
||||||
grams: (json['grams'] as num?)?.toDouble() ?? 0,
|
count: (json['count'] as num?)?.toDouble() ?? 0,
|
||||||
count: (json['count'] as num?)?.toDouble() ?? 0,
|
components: (json['components'] as List?)?.cast<String>(),
|
||||||
components: (json['components'] as List?)?.cast<String>(),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
/// The food or meal's display name, as the user typed it.
|
/// The food or meal's display name, as the user typed it.
|
||||||
final String desc;
|
final String desc;
|
||||||
|
|||||||
@ -23,8 +23,11 @@ const int gateEatingEndHour = 22;
|
|||||||
/// Mirrors `_slots.day_slots`.
|
/// Mirrors `_slots.day_slots`.
|
||||||
List<int> daySlots() {
|
List<int> daySlots() {
|
||||||
final slots = <int>[];
|
final slots = <int>[];
|
||||||
for (var hour = gateDayStartHour; hour < gateEatingEndHour;
|
for (
|
||||||
hour += gateSlotIntervalHours) {
|
var hour = gateDayStartHour;
|
||||||
|
hour < gateEatingEndHour;
|
||||||
|
hour += gateSlotIntervalHours
|
||||||
|
) {
|
||||||
slots.add(hour);
|
slots.add(hour);
|
||||||
}
|
}
|
||||||
return slots;
|
return slots;
|
||||||
|
|||||||
182
app/lib/screens/edit_entry_screen.dart
Normal file
182
app/lib/screens/edit_entry_screen.dart
Normal 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!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
712
app/lib/screens/food_bank_screen.dart
Normal file
712
app/lib/screens/food_bank_screen.dart
Normal 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
@ -9,6 +9,7 @@ library;
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:diet_guard_app/screens/log_meal_screen.dart';
|
import 'package:diet_guard_app/screens/log_meal_screen.dart';
|
||||||
|
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||||
import 'package:diet_guard_app/services/github_client.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/github_device_auth.dart';
|
||||||
import 'package:diet_guard_app/services/sync_service.dart';
|
import 'package:diet_guard_app/services/sync_service.dart';
|
||||||
@ -41,6 +42,7 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
final _kcalGoalController = TextEditingController();
|
||||||
final _ownerController = TextEditingController();
|
final _ownerController = TextEditingController();
|
||||||
final _repoController = TextEditingController();
|
final _repoController = TextEditingController();
|
||||||
final _tokenController = TextEditingController();
|
final _tokenController = TextEditingController();
|
||||||
@ -66,6 +68,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
settings = const SyncSettings(owner: '', repo: '', token: '');
|
settings = const SyncSettings(owner: '', repo: '', token: '');
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
_kcalGoalController.text = AppSettingsService.dailyKcalGoal.toString();
|
||||||
_ownerController.text = settings.owner;
|
_ownerController.text = settings.owner;
|
||||||
_repoController.text = settings.repo;
|
_repoController.text = settings.repo;
|
||||||
_tokenController.text = settings.token;
|
_tokenController.text = settings.token;
|
||||||
@ -75,6 +78,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_kcalGoalController.dispose();
|
||||||
_ownerController.dispose();
|
_ownerController.dispose();
|
||||||
_repoController.dispose();
|
_repoController.dispose();
|
||||||
_tokenController.dispose();
|
_tokenController.dispose();
|
||||||
@ -227,10 +231,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
}
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Sync settings')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
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(
|
Text(
|
||||||
'Authorize in your browser — no token to paste. Syncs to '
|
'Authorize in your browser — no token to paste. Syncs to '
|
||||||
'kuhyx/diet-guard-sync by default.',
|
'kuhyx/diet-guard-sync by default.',
|
||||||
|
|||||||
87
app/lib/services/app_settings_service.dart
Normal file
87
app/lib/services/app_settings_service.dart
Normal 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}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -111,14 +111,15 @@ class FoodBankService {
|
|||||||
/// `_foodbank.remember_food`/`remember_meal`'s upsert semantics: latest
|
/// `_foodbank.remember_food`/`remember_meal`'s upsert semantics: latest
|
||||||
/// macros win per normalized name, `count` increments per occurrence.
|
/// macros win per normalized name, `count` increments per occurrence.
|
||||||
static Map<String, FoodBankRecord> rebuild(DayLog log) {
|
static Map<String, FoodBankRecord> rebuild(DayLog log) {
|
||||||
final entries = log.values
|
final entries =
|
||||||
.expand((entries) => entries)
|
log.values
|
||||||
.where((entry) => !entry.deleted)
|
.expand((entries) => entries)
|
||||||
.toList()
|
.where((entry) => !entry.deleted)
|
||||||
..sort((a, b) {
|
.toList()
|
||||||
final byTime = a.time.compareTo(b.time);
|
..sort((a, b) {
|
||||||
return byTime != 0 ? byTime : (a.id ?? '').compareTo(b.id ?? '');
|
final byTime = a.time.compareTo(b.time);
|
||||||
});
|
return byTime != 0 ? byTime : (a.id ?? '').compareTo(b.id ?? '');
|
||||||
|
});
|
||||||
final bank = <String, FoodBankRecord>{};
|
final bank = <String, FoodBankRecord>{};
|
||||||
for (final entry in entries) {
|
for (final entry in entries) {
|
||||||
final components = entry.components;
|
final components = entry.components;
|
||||||
@ -199,15 +200,86 @@ class FoodBankService {
|
|||||||
return bank;
|
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.
|
/// Returns banked foods matching [query], best match first.
|
||||||
///
|
///
|
||||||
/// An empty query returns the most-logged foods. Mirrors
|
/// 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(
|
Future<List<FoodSuggestion>> search(
|
||||||
String query, {
|
String query, {
|
||||||
int limit = defaultSuggestions,
|
int limit = defaultSuggestions,
|
||||||
}) async {
|
}) async {
|
||||||
final bank = await readBank();
|
final logBank = await readBank();
|
||||||
|
final manualBank = await _readManualBank();
|
||||||
|
final bank = <String, FoodBankRecord>{...manualBank, ...logBank};
|
||||||
final normalized = _normalize(query);
|
final normalized = _normalize(query);
|
||||||
if (normalized.isEmpty) return _rankedAll(bank, limit);
|
if (normalized.isEmpty) return _rankedAll(bank, limit);
|
||||||
|
|
||||||
|
|||||||
@ -58,7 +58,7 @@ class GitHubClient {
|
|||||||
// formal; assign it explicitly.
|
// formal; assign it explicitly.
|
||||||
// ignore: prefer_initializing_formals
|
// ignore: prefer_initializing_formals
|
||||||
: _token = token,
|
: _token = token,
|
||||||
_http = httpClient ?? http.Client();
|
_http = httpClient ?? http.Client();
|
||||||
|
|
||||||
/// The repo owner/org (e.g. `"kuhyx"`).
|
/// The repo owner/org (e.g. `"kuhyx"`).
|
||||||
final String owner;
|
final String owner;
|
||||||
|
|||||||
@ -165,15 +165,16 @@ class LogStorageService {
|
|||||||
/// "today".
|
/// "today".
|
||||||
Future<List<FoodEntry>> allEntriesNewestFirst() async {
|
Future<List<FoodEntry>> allEntriesNewestFirst() async {
|
||||||
final log = await readLog();
|
final log = await readLog();
|
||||||
final entries = [
|
final entries =
|
||||||
for (final dayEntries in log.values)
|
[
|
||||||
...dayEntries.where((e) => !e.deleted),
|
for (final dayEntries in log.values)
|
||||||
]..sort((a, b) {
|
...dayEntries.where((e) => !e.deleted),
|
||||||
final aTime = DateTime.tryParse(a.time);
|
]..sort((a, b) {
|
||||||
final bTime = DateTime.tryParse(b.time);
|
final aTime = DateTime.tryParse(a.time);
|
||||||
if (aTime == null || bTime == null) return 0;
|
final bTime = DateTime.tryParse(b.time);
|
||||||
return bTime.compareTo(aTime);
|
if (aTime == null || bTime == null) return 0;
|
||||||
});
|
return bTime.compareTo(aTime);
|
||||||
|
});
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,13 +188,49 @@ class LogStorageService {
|
|||||||
return double.parse(total.toStringAsFixed(1));
|
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
|
/// Returns the slot hours already satisfied today, mirrors
|
||||||
/// `_state.logged_slots_today`.
|
/// `_state.logged_slots_today`.
|
||||||
Future<Set<int>> loggedSlotsToday() async {
|
Future<Set<int>> loggedSlotsToday() async {
|
||||||
final entries = await todayEntries();
|
final entries = await todayEntries();
|
||||||
return entries
|
return entries.where((e) => e.slot != null).map((e) => e.slot!).toSet();
|
||||||
.where((e) => e.slot != null)
|
|
||||||
.map((e) => e.slot!)
|
|
||||||
.toSet();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,8 @@ const _devicesDir = 'devices';
|
|||||||
/// phone is the only other device in this design.
|
/// phone is the only other device in this design.
|
||||||
const phoneDeviceId = 'phone';
|
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.
|
/// Runs one full sync tick: pull, merge, preserve photos, persist, push.
|
||||||
///
|
///
|
||||||
|
|||||||
268
app/test/screens/edit_entry_screen_test.dart
Normal file
268
app/test/screens/edit_entry_screen_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
613
app/test/screens/food_bank_screen_test.dart
Normal file
613
app/test/screens/food_bank_screen_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -75,11 +75,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
// Field order: [0] meal name, [1] item name, [2] kcal,
|
// Field order: [0] meal name, [1] item name, [2] per (g),
|
||||||
// [3] per (g), [4] protein, [5] carbs, [6] fat, [7] ate (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(1), 'rice');
|
||||||
await tester.enterText(find.byType(TextField).at(2), '200');
|
await tester.enterText(find.byType(TextField).at(2), '100');
|
||||||
await tester.enterText(find.byType(TextField).at(3), '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(4), '4');
|
||||||
await tester.enterText(find.byType(TextField).at(5), '44');
|
await tester.enterText(find.byType(TextField).at(5), '44');
|
||||||
await tester.enterText(find.byType(TextField).at(6), '1');
|
await tester.enterText(find.byType(TextField).at(6), '1');
|
||||||
@ -92,7 +92,7 @@ void main() {
|
|||||||
expect(find.textContaining('300 kcal'), findsOneWidget);
|
expect(find.textContaining('300 kcal'), findsOneWidget);
|
||||||
|
|
||||||
await tester.enterText(find.byType(TextField).at(1), 'chicken');
|
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(4), '31');
|
||||||
await tester.enterText(find.byType(TextField).at(5), '0');
|
await tester.enterText(find.byType(TextField).at(5), '0');
|
||||||
await tester.enterText(find.byType(TextField).at(6), '4');
|
await tester.enterText(find.byType(TextField).at(6), '4');
|
||||||
@ -103,8 +103,7 @@ void main() {
|
|||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
final entry =
|
final entry = (await LogStorageService.instance.todayEntries()).single;
|
||||||
(await LogStorageService.instance.todayEntries()).single;
|
|
||||||
expect(entry.source, 'meal');
|
expect(entry.source, 'meal');
|
||||||
expect(entry.kcal, 465); // 300 (scaled rice) + 165 (chicken)
|
expect(entry.kcal, 465); // 300 (scaled rice) + 165 (chicken)
|
||||||
expect(entry.components, hasLength(2));
|
expect(entry.components, hasLength(2));
|
||||||
@ -135,7 +134,7 @@ void main() {
|
|||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
await tester.enterText(find.byType(TextField).at(1), 'soup');
|
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 settle(tester);
|
||||||
await tester.tap(addItemButton);
|
await tester.tap(addItemButton);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
@ -150,11 +149,115 @@ void main() {
|
|||||||
await tester.tap(logMealButton);
|
await tester.tap(logMealButton);
|
||||||
await settle(tester);
|
await settle(tester);
|
||||||
|
|
||||||
final entry =
|
final entry = (await LogStorageService.instance.todayEntries()).single;
|
||||||
(await LogStorageService.instance.todayEntries()).single;
|
|
||||||
expect(entry.imagePath, isNotNull);
|
expect(entry.imagePath, isNotNull);
|
||||||
expect(entry.imagePath, startsWith('${tempDir.path}/images/'));
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
115
app/test/services/app_settings_service_test.dart
Normal file
115
app/test/services/app_settings_service_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
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/food_entry.dart';
|
||||||
import 'package:diet_guard_app/models/meal_component.dart';
|
import 'package:diet_guard_app/models/meal_component.dart';
|
||||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||||
@ -178,4 +179,216 @@ void main() {
|
|||||||
expect(results.first.name, 'common');
|
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]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,10 +13,12 @@ void main() {
|
|||||||
expect(substring, greaterThan(typo));
|
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));
|
() {
|
||||||
});
|
expect(matchScore('', 'banana'), greaterThanOrEqualTo(0));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('scores a clear mismatch low', () {
|
test('scores a clear mismatch low', () {
|
||||||
expect(matchScore('xyz', 'banana'), lessThan(0.6));
|
expect(matchScore('xyz', 'banana'), lessThan(0.6));
|
||||||
|
|||||||
@ -41,7 +41,12 @@ void main() {
|
|||||||
(_) async => http.Response(
|
(_) async => http.Response(
|
||||||
jsonEncode([
|
jsonEncode([
|
||||||
{'type': 'dir', 'name': 'pc', 'path': 'devices/pc', 'sha': 's1'},
|
{'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,
|
200,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -132,7 +132,9 @@ void main() {
|
|||||||
fatG: 3,
|
fatG: 3,
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
);
|
);
|
||||||
await LogStorageService.instance.writeLog({yesterdayKey: [yesterday]});
|
await LogStorageService.instance.writeLog({
|
||||||
|
yesterdayKey: [yesterday],
|
||||||
|
});
|
||||||
await LogStorageService.instance.logMeal('today', _manual);
|
await LogStorageService.instance.logMeal('today', _manual);
|
||||||
|
|
||||||
expect(await LogStorageService.instance.undoLastToday(), isNotNull);
|
expect(await LogStorageService.instance.undoLastToday(), isNotNull);
|
||||||
@ -141,15 +143,20 @@ void main() {
|
|||||||
expect(log[yesterdayKey]!.single.deleted, isFalse);
|
expect(log[yesterdayKey]!.single.deleted, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skips an already-tombstoned entry and undoes the one before it',
|
test(
|
||||||
() async {
|
'skips an already-tombstoned entry and undoes the one before it',
|
||||||
final first = await LogStorageService.instance.logMeal('first', _manual);
|
() async {
|
||||||
await LogStorageService.instance.logMeal('second', _manual);
|
final first = await LogStorageService.instance.logMeal(
|
||||||
await LogStorageService.instance.undoLastToday();
|
'first',
|
||||||
final undoneAgain = await LogStorageService.instance.undoLastToday();
|
_manual,
|
||||||
expect(undoneAgain!.id, first.id);
|
);
|
||||||
expect(await LogStorageService.instance.undoLastToday(), isNull);
|
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', () {
|
group('todayTotalKcal', () {
|
||||||
@ -211,21 +218,167 @@ void main() {
|
|||||||
deleted: true,
|
deleted: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
test('sorts entries across days newest-first and drops tombstones',
|
test(
|
||||||
() async {
|
'sorts entries across days newest-first and drops tombstones',
|
||||||
await LogStorageService.instance.writeLog({
|
() async {
|
||||||
'2026-06-01': [oldest],
|
await LogStorageService.instance.writeLog({
|
||||||
'2026-06-15': [tombstoned],
|
'2026-06-01': [oldest],
|
||||||
'2026-06-22': [newest],
|
'2026-06-15': [tombstoned],
|
||||||
});
|
'2026-06-22': [newest],
|
||||||
|
});
|
||||||
|
|
||||||
final result = await LogStorageService.instance.allEntriesNewestFirst();
|
final result = await LogStorageService.instance.allEntriesNewestFirst();
|
||||||
|
|
||||||
expect(result.map((e) => e.id), ['newest', 'oldest']);
|
expect(result.map((e) => e.id), ['newest', 'oldest']);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('returns empty for an empty log', () async {
|
test('returns empty for an empty log', () async {
|
||||||
expect(await LogStorageService.instance.allEntriesNewestFirst(), isEmpty);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,22 +35,25 @@ void main() {
|
|||||||
await tempDir.delete(recursive: true);
|
await tempDir.delete(recursive: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('copies the picked file into <documents>/images with a new name', () async {
|
test(
|
||||||
final source = File('${tempDir.path}/source.jpg')
|
'copies the picked file into <documents>/images with a new name',
|
||||||
..writeAsBytesSync([1, 2, 3, 4]);
|
() async {
|
||||||
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
|
final source = File('${tempDir.path}/source.jpg')
|
||||||
XFile(source.path),
|
..writeAsBytesSync([1, 2, 3, 4]);
|
||||||
);
|
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
|
||||||
|
XFile(source.path),
|
||||||
|
);
|
||||||
|
|
||||||
final result = await PhotoAttachService.instance.pickAndStore(
|
final result = await PhotoAttachService.instance.pickAndStore(
|
||||||
ImageSource.gallery,
|
ImageSource.gallery,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result, isNotNull);
|
expect(result, isNotNull);
|
||||||
expect(result, startsWith('${tempDir.path}/images/'));
|
expect(result, startsWith('${tempDir.path}/images/'));
|
||||||
expect(result, endsWith('.jpg'));
|
expect(result, endsWith('.jpg'));
|
||||||
expect(File(result!).readAsBytesSync(), [1, 2, 3, 4]);
|
expect(File(result!).readAsBytesSync(), [1, 2, 3, 4]);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('returns null when the picker is cancelled', () async {
|
test('returns null when the picker is cancelled', () async {
|
||||||
ImagePickerPlatform.instance = _FakeImagePickerPlatform(null);
|
ImagePickerPlatform.instance = _FakeImagePickerPlatform(null);
|
||||||
|
|||||||
@ -39,9 +39,14 @@ void main() {
|
|||||||
|
|
||||||
test('same id in both logs is not duplicated', () {
|
test('same id in both logs is not duplicated', () {
|
||||||
final shared = _entry(id: 'shared');
|
final shared = _entry(id: 'shared');
|
||||||
final merged = mergeLogs({
|
final merged = mergeLogs(
|
||||||
'2026-06-22': [shared],
|
{
|
||||||
}, {'2026-06-22': [shared]});
|
'2026-06-22': [shared],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'2026-06-22': [shared],
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(merged['2026-06-22'], hasLength(1));
|
expect(merged['2026-06-22'], hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -56,9 +61,14 @@ void main() {
|
|||||||
time: '2026-06-20T08:00:00',
|
time: '2026-06-20T08:00:00',
|
||||||
desc: 'toast',
|
desc: 'toast',
|
||||||
);
|
);
|
||||||
final merged = mergeLogs({
|
final merged = mergeLogs(
|
||||||
'2026-06-20': [legacyA],
|
{
|
||||||
}, {'2026-06-20': [legacyB]});
|
'2026-06-20': [legacyA],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'2026-06-20': [legacyB],
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(merged['2026-06-20'], hasLength(1));
|
expect(merged['2026-06-20'], hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -69,9 +79,14 @@ void main() {
|
|||||||
desc: 'toast',
|
desc: 'toast',
|
||||||
);
|
);
|
||||||
final withId = _entry(id: 'x', time: '2026-06-20T09:00:00', desc: 'eggs');
|
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': [legacy],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'2026-06-20': [withId],
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(merged['2026-06-20'], hasLength(2));
|
expect(merged['2026-06-20'], hasLength(2));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -81,12 +96,22 @@ void main() {
|
|||||||
final normal = _entry(id: 'x');
|
final normal = _entry(id: 'x');
|
||||||
final tombstoned = _entry(id: 'x', deleted: true);
|
final tombstoned = _entry(id: 'x', deleted: true);
|
||||||
|
|
||||||
final forward = mergeLogs({
|
final forward = mergeLogs(
|
||||||
'2026-06-22': [normal],
|
{
|
||||||
}, {'2026-06-22': [tombstoned]});
|
'2026-06-22': [normal],
|
||||||
final backward = mergeLogs({
|
},
|
||||||
'2026-06-22': [tombstoned],
|
{
|
||||||
}, {'2026-06-22': [normal]});
|
'2026-06-22': [tombstoned],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final backward = mergeLogs(
|
||||||
|
{
|
||||||
|
'2026-06-22': [tombstoned],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'2026-06-22': [normal],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(forward['2026-06-22']!.single.deleted, isTrue);
|
expect(forward['2026-06-22']!.single.deleted, isTrue);
|
||||||
expect(backward['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', () {
|
test('two tombstoned copies stay tombstoned', () {
|
||||||
final tombstoned = _entry(id: 'x', deleted: true);
|
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': [tombstoned],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'2026-06-22': [_entry(id: 'x', deleted: true)],
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(merged['2026-06-22']!.single.deleted, isTrue);
|
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",
|
"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 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.keys, ['2026-06-21']);
|
||||||
expect(merged['2026-06-21']!.single.id, 'x');
|
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
|
// Dart's substring throws past the string length, unlike Python's
|
||||||
// forgiving slice -- this only matters for malformed/legacy data.
|
// forgiving slice -- this only matters for malformed/legacy data.
|
||||||
final short = _entry(id: 'x', time: '2026');
|
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']);
|
expect(merged.keys, ['2026']);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -127,9 +161,14 @@ void main() {
|
|||||||
test("a day's entries are sorted oldest-first", () {
|
test("a day's entries are sorted oldest-first", () {
|
||||||
final late = _entry(id: 'late', time: '2026-06-22T20:00:00');
|
final late = _entry(id: 'late', time: '2026-06-22T20:00:00');
|
||||||
final early = _entry(id: 'early', time: '2026-06-22T08: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': [late],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'2026-06-22': [early],
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(merged['2026-06-22']!.map((e) => e.id).toList(), [
|
expect(merged['2026-06-22']!.map((e) => e.id).toList(), [
|
||||||
'early',
|
'early',
|
||||||
'late',
|
'late',
|
||||||
@ -139,7 +178,9 @@ void main() {
|
|||||||
|
|
||||||
group('algebraic properties', () {
|
group('algebraic properties', () {
|
||||||
test('merge is commutative', () {
|
test('merge is commutative', () {
|
||||||
final a = {'2026-06-22': [_entry(id: 'a')]};
|
final a = {
|
||||||
|
'2026-06-22': [_entry(id: 'a')],
|
||||||
|
};
|
||||||
final b = {
|
final b = {
|
||||||
'2026-06-22': [_entry(id: 'b', time: '2026-06-22T09:00:00')],
|
'2026-06-22': [_entry(id: 'b', time: '2026-06-22T09:00:00')],
|
||||||
};
|
};
|
||||||
@ -152,13 +193,17 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('merge is idempotent', () {
|
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);
|
final merged = mergeLogs(canonical, canonical);
|
||||||
expect(merged['2026-06-22']!.map((e) => e.id).toList(), ['a']);
|
expect(merged['2026-06-22']!.map((e) => e.id).toList(), ['a']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('merging with an empty log is a no-op', () {
|
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);
|
||||||
expect(mergeLogs({}, log).keys, log.keys);
|
expect(mergeLogs({}, log).keys, log.keys);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,8 +20,9 @@ void main() {
|
|||||||
await tempDir.delete(recursive: true);
|
await tempDir.delete(recursive: true);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('app launches straight into the meal-logging screen',
|
testWidgets('app launches straight into the meal-logging screen', (
|
||||||
(tester) async {
|
tester,
|
||||||
|
) async {
|
||||||
// LogMealScreen's initState does real dart:io file I/O; pumpAndSettle()
|
// LogMealScreen's initState does real dart:io file I/O; pumpAndSettle()
|
||||||
// alone never lets that resolve (see log_meal_screen_test.dart).
|
// alone never lets that resolve (see log_meal_screen_test.dart).
|
||||||
await tester.runAsync(() async {
|
await tester.runAsync(() async {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user