Add Flutter companion app skeleton with local meal logging
Milestone 1 of the diet-app-as-wise-balloon plan: a phone-native way to
log meals away from the PC, sharing the exact on-disk JSON shape
diet_guard already uses (same field names, no translation layer).
- lib/models/: 1:1 Dart mirrors of the Python dataclasses (Nutrition,
FoodEntry, MealItem, FoodBankRecord, Slot), including the per-100g/
amount-eaten portion scaling that matches _resolve.resolve_nutrition's
semantics exactly.
- lib/services/log_storage_service.dart: plain-JSON persistence to
food_log.json's exact shape (no sqflite -- the canonical format
already is this JSON).
- lib/services/foodbank_service.dart: ports _foodbank.py's upsert/fuzzy
search logic for autocomplete.
- lib/screens/: log_meal_screen.dart (single-item logging) and
meal_builder_screen.dart (composite multi-item meals, logging full
per-component macros via the new components field).
Verified end-to-end on a physical device (BL9000): built, installed,
logged a real meal through the UI. 77 Flutter tests passing, `flutter
analyze` clean against very_good_analysis.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
2026-06-22 18:22:42 +02:00
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
import 'package:diet_guard_app/models/food_entry.dart';
|
|
|
|
|
import 'package:diet_guard_app/models/local_time.dart';
|
|
|
|
|
import 'package:diet_guard_app/models/meal_component.dart';
|
|
|
|
|
import 'package:diet_guard_app/models/nutrition.dart';
|
|
|
|
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
|
|
|
|
const _manual = Nutrition(
|
|
|
|
|
kcal: 150,
|
|
|
|
|
proteinG: 5,
|
|
|
|
|
carbsG: 20,
|
|
|
|
|
fatG: 3,
|
|
|
|
|
grams: 50,
|
|
|
|
|
source: 'manual',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
late Directory tempDir;
|
|
|
|
|
|
|
|
|
|
setUp(() async {
|
|
|
|
|
tempDir = await Directory.systemTemp.createTemp('diet_guard_test_');
|
|
|
|
|
LogStorageService.resetForTesting(testDir: tempDir);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tearDown(() async {
|
|
|
|
|
LogStorageService.resetForTesting();
|
|
|
|
|
await tempDir.delete(recursive: true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('readLog', () {
|
|
|
|
|
test('returns an empty log when no file exists yet', () async {
|
|
|
|
|
expect(await LogStorageService.instance.readLog(), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns an empty log for unparsable JSON', () async {
|
|
|
|
|
final file = File('${tempDir.path}/food_log.json');
|
|
|
|
|
await file.writeAsString('not json');
|
|
|
|
|
expect(await LogStorageService.instance.readLog(), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns an empty log when the JSON root is not a map', () async {
|
|
|
|
|
final file = File('${tempDir.path}/food_log.json');
|
|
|
|
|
await file.writeAsString('[]');
|
|
|
|
|
expect(await LogStorageService.instance.readLog(), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('skips a date key whose value is not a list', () async {
|
|
|
|
|
final file = File('${tempDir.path}/food_log.json');
|
|
|
|
|
await file.writeAsString('{"2026-06-22": "not a list"}');
|
|
|
|
|
expect(await LogStorageService.instance.readLog(), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('logMeal', () {
|
|
|
|
|
test('assigns a fresh id and never an hmac', () async {
|
|
|
|
|
final entry = await LogStorageService.instance.logMeal(
|
|
|
|
|
'toast',
|
|
|
|
|
_manual,
|
|
|
|
|
slot: 8,
|
|
|
|
|
);
|
|
|
|
|
expect(entry.id, isNotEmpty);
|
|
|
|
|
expect(entry.hmac, isNull);
|
|
|
|
|
expect(entry.slot, 8);
|
|
|
|
|
expect(entry.desc, 'toast');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('persists components when given', () async {
|
|
|
|
|
const components = [
|
|
|
|
|
MealComponent(
|
|
|
|
|
name: 'bread',
|
|
|
|
|
kcal: 100,
|
|
|
|
|
proteinG: 3,
|
|
|
|
|
carbsG: 18,
|
|
|
|
|
fatG: 1,
|
|
|
|
|
grams: 40,
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
final entry = await LogStorageService.instance.logMeal(
|
|
|
|
|
'toast meal',
|
|
|
|
|
_manual,
|
|
|
|
|
components: components,
|
|
|
|
|
);
|
|
|
|
|
expect(entry.components, hasLength(1));
|
|
|
|
|
final reloaded = await LogStorageService.instance.todayEntries();
|
|
|
|
|
expect(reloaded.single.components!.single.name, 'bread');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('two logged meals both persist under today\'s date key', () async {
|
|
|
|
|
await LogStorageService.instance.logMeal('a', _manual);
|
|
|
|
|
await LogStorageService.instance.logMeal('b', _manual);
|
|
|
|
|
final entries = await LogStorageService.instance.todayEntries();
|
|
|
|
|
expect(entries, hasLength(2));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('undoLastToday', () {
|
|
|
|
|
test('returns null when today has no entries', () async {
|
|
|
|
|
expect(await LogStorageService.instance.undoLastToday(), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tombstones the most recent entry in place', () async {
|
|
|
|
|
await LogStorageService.instance.logMeal('first', _manual);
|
|
|
|
|
final second = await LogStorageService.instance.logMeal(
|
|
|
|
|
'second',
|
|
|
|
|
_manual,
|
|
|
|
|
);
|
|
|
|
|
final undone = await LogStorageService.instance.undoLastToday();
|
|
|
|
|
expect(undone!.id, second.id);
|
|
|
|
|
expect(undone.deleted, isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('tombstoned entries are excluded from todayEntries', () async {
|
|
|
|
|
await LogStorageService.instance.logMeal('only', _manual);
|
|
|
|
|
await LogStorageService.instance.undoLastToday();
|
|
|
|
|
expect(await LogStorageService.instance.todayEntries(), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('never touches a previous day\'s entries', () async {
|
|
|
|
|
final yesterdayKey = localDateKey(
|
|
|
|
|
DateTime.now().subtract(const Duration(days: 1)),
|
|
|
|
|
);
|
|
|
|
|
final yesterday = FoodEntry(
|
|
|
|
|
id: 'yesterday-1',
|
|
|
|
|
time: '${yesterdayKey}T08:00:00+02:00',
|
|
|
|
|
desc: 'yesterday meal',
|
|
|
|
|
grams: 50,
|
|
|
|
|
kcal: 150,
|
|
|
|
|
proteinG: 5,
|
|
|
|
|
carbsG: 20,
|
|
|
|
|
fatG: 3,
|
|
|
|
|
source: 'manual',
|
|
|
|
|
);
|
|
|
|
|
await LogStorageService.instance.writeLog({yesterdayKey: [yesterday]});
|
|
|
|
|
await LogStorageService.instance.logMeal('today', _manual);
|
|
|
|
|
|
|
|
|
|
expect(await LogStorageService.instance.undoLastToday(), isNotNull);
|
|
|
|
|
|
|
|
|
|
final log = await LogStorageService.instance.readLog();
|
|
|
|
|
expect(log[yesterdayKey]!.single.deleted, isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('skips an already-tombstoned entry and undoes the one before it',
|
|
|
|
|
() async {
|
|
|
|
|
final first = await LogStorageService.instance.logMeal('first', _manual);
|
|
|
|
|
await LogStorageService.instance.logMeal('second', _manual);
|
|
|
|
|
await LogStorageService.instance.undoLastToday();
|
|
|
|
|
final undoneAgain = await LogStorageService.instance.undoLastToday();
|
|
|
|
|
expect(undoneAgain!.id, first.id);
|
|
|
|
|
expect(await LogStorageService.instance.undoLastToday(), isNull);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('todayTotalKcal', () {
|
|
|
|
|
test('sums kcal across today\'s non-tombstoned entries', () async {
|
|
|
|
|
await LogStorageService.instance.logMeal('a', _manual);
|
|
|
|
|
await LogStorageService.instance.logMeal('b', _manual);
|
|
|
|
|
expect(await LogStorageService.instance.todayTotalKcal(), 300.0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('excludes a tombstoned entry from the total', () async {
|
|
|
|
|
await LogStorageService.instance.logMeal('a', _manual);
|
|
|
|
|
await LogStorageService.instance.logMeal('b', _manual);
|
|
|
|
|
await LogStorageService.instance.undoLastToday();
|
|
|
|
|
expect(await LogStorageService.instance.todayTotalKcal(), 150.0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('loggedSlotsToday', () {
|
|
|
|
|
test('returns only the slots with a logged entry', () async {
|
|
|
|
|
await LogStorageService.instance.logMeal('a', _manual, slot: 8);
|
|
|
|
|
await LogStorageService.instance.logMeal('b', _manual);
|
|
|
|
|
expect(await LogStorageService.instance.loggedSlotsToday(), {8});
|
|
|
|
|
});
|
|
|
|
|
});
|
Add photo attach, full-size viewer, and a minimal history screen
Milestone 2 of the diet-app-as-wise-balloon plan, plus feedback from
manually testing it on-device:
- PhotoAttachService wraps image_picker and copies the picked photo into
<app documents>/images/<uuid>.<ext>, so the file survives after the
picker's own (possibly cache-cleared) temp copy is gone. Phone-local
only, per the sync plan: imagePath is never synced.
- PhotoAttachField is a shared attach/preview/remove widget, used
identically by both the single-item and composite-meal logging
screens, so logging a multi-item meal can now carry a photo too.
- PhotoViewerScreen gives a full-screen, pinch-to-zoom view of an
attached photo -- the 64x64 inline thumbnail was too small to
actually check the photo.
- HistoryScreen lists every logged entry across all days, newest first,
with a thumbnail when one is attached. There was previously no way to
confirm what got logged (or whether a photo actually attached) short
of inspecting food_log.json directly.
Verified on a physical device (BL9000): built, installed, and the user
confirmed the photo-attach flow logs a real entry with a real photo,
visible afterward in the new history list. 88 Flutter tests passing,
`flutter analyze` clean against very_good_analysis.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
2026-06-22 18:57:58 +02:00
|
|
|
|
|
|
|
|
group('allEntriesNewestFirst', () {
|
|
|
|
|
const oldest = FoodEntry(
|
|
|
|
|
id: 'oldest',
|
|
|
|
|
time: '2026-06-01T08:00:00+02:00',
|
|
|
|
|
desc: 'oldest',
|
|
|
|
|
grams: 100,
|
|
|
|
|
kcal: 100,
|
|
|
|
|
proteinG: 5,
|
|
|
|
|
carbsG: 10,
|
|
|
|
|
fatG: 2,
|
|
|
|
|
source: 'manual',
|
|
|
|
|
);
|
|
|
|
|
const newest = FoodEntry(
|
|
|
|
|
id: 'newest',
|
|
|
|
|
time: '2026-06-22T20:00:00+02:00',
|
|
|
|
|
desc: 'newest',
|
|
|
|
|
grams: 100,
|
|
|
|
|
kcal: 200,
|
|
|
|
|
proteinG: 10,
|
|
|
|
|
carbsG: 20,
|
|
|
|
|
fatG: 4,
|
|
|
|
|
source: 'manual',
|
|
|
|
|
);
|
|
|
|
|
const tombstoned = FoodEntry(
|
|
|
|
|
id: 'gone',
|
|
|
|
|
time: '2026-06-15T12:00:00+02:00',
|
|
|
|
|
desc: 'undone',
|
|
|
|
|
grams: 100,
|
|
|
|
|
kcal: 300,
|
|
|
|
|
proteinG: 1,
|
|
|
|
|
carbsG: 1,
|
|
|
|
|
fatG: 1,
|
|
|
|
|
source: 'manual',
|
|
|
|
|
deleted: true,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('sorts entries across days newest-first and drops tombstones',
|
|
|
|
|
() async {
|
|
|
|
|
await LogStorageService.instance.writeLog({
|
|
|
|
|
'2026-06-01': [oldest],
|
|
|
|
|
'2026-06-15': [tombstoned],
|
|
|
|
|
'2026-06-22': [newest],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final result = await LogStorageService.instance.allEntriesNewestFirst();
|
|
|
|
|
|
|
|
|
|
expect(result.map((e) => e.id), ['newest', 'oldest']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns empty for an empty log', () async {
|
|
|
|
|
expect(await LogStorageService.instance.allEntriesNewestFirst(), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
});
|
Add Flutter companion app skeleton with local meal logging
Milestone 1 of the diet-app-as-wise-balloon plan: a phone-native way to
log meals away from the PC, sharing the exact on-disk JSON shape
diet_guard already uses (same field names, no translation layer).
- lib/models/: 1:1 Dart mirrors of the Python dataclasses (Nutrition,
FoodEntry, MealItem, FoodBankRecord, Slot), including the per-100g/
amount-eaten portion scaling that matches _resolve.resolve_nutrition's
semantics exactly.
- lib/services/log_storage_service.dart: plain-JSON persistence to
food_log.json's exact shape (no sqflite -- the canonical format
already is this JSON).
- lib/services/foodbank_service.dart: ports _foodbank.py's upsert/fuzzy
search logic for autocomplete.
- lib/screens/: log_meal_screen.dart (single-item logging) and
meal_builder_screen.dart (composite multi-item meals, logging full
per-component macros via the new components field).
Verified end-to-end on a physical device (BL9000): built, installed,
logged a real meal through the UI. 77 Flutter tests passing, `flutter
analyze` clean against very_good_analysis.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
2026-06-22 18:22:42 +02:00
|
|
|
}
|