Remove unused features; fix reminder false alarms; share all state
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled

Four asks, plus the fixes two review passes turned up.

Removals (all unused):
- "Repeat last meal" button, and the reward/temptation-bundling prompt it
  was the sole trigger for. Replaced with a post-log progress card showing
  kcal against budget, macros, and the adherence streak.
- The multi-item meal builder, on both the gate and the phone. The stored
  `components` field stays: nothing writes it now, but historical composite
  entries and the food bank still read it and it is part of the wire format.
- Meal photos entirely: 13 files, `imagePath`, the photo filter, the
  wrapper's /blobs/ route, image_picker, and the CAMERA permission.

Reminder false alarms had three real causes, all fixed:
- Logging never reconciled notifications, so a reminder sat on screen up to
  15 minutes after the meal was logged -- on the phone itself.
- The background check never pulled, so a PC-logged meal nagged forever
  until the app was opened. Now mirrors _cli_gate._should_lock: cheap local
  check, network only when a slot looks due, then re-check.
- Meals before 08:00 / after 22:00 were attributed to no slot at all. New
  slot_for_log/slotForLog clamps them to the first/last slot, byte-identical
  across Python and Dart. Deliberately separate from elapsed_slots, which
  would otherwise make every slot fall due at 23:00.

Budget history, so lowering the budget stops rewriting the past:
- Forward-only (effective_from, kcal) entries; each day is judged against
  the budget in force then. Verified live: setting 2000 left a 36-day
  adherence streak and the whole calendar pixel-identical.
- Rides as hist:<date> fields on the existing budget record. crdt_sync
  merges per-field over the union of field names and both devices push the
  merged log, so a device that predates the feature relays them untouched --
  no coordinated rollout, no second document.
- The seed guard tests emptiness, not file presence: _sync can write an
  empty history after merging with a pre-feature peer, and a presence check
  would then never seed again, silently reclassifying every past day.

One shared source of truth for every piece of data:
- Body weight now travels as its own `weight` CRDT field. Inside `value` it
  was collateral damage of whole-map LWW -- any phone budget edit deleted it,
  and the protein target with it.
- Both halves of the food bank sync. The curated half (foods added without
  eating them) was the last device-local state. The derived half merges with
  count as the clock, so LWW means max-count-wins; the log stays
  authoritative for membership, because a CRDT union never shrinks and a
  peer's stale copy would otherwise resurrect a deleted food permanently.
- Bank adapters honour tombstones, and Dart parses bank records defensively:
  a cast failure there is an Error, not an Exception, so one malformed
  remote record would have broken phone sync permanently.

_test_guard.py blocks any pathlib write to real user state while pytest is
in the process, whatever conftest is loaded. A throwaway test run outside
the package conftest destroyed a real GitHub token during this work; this
makes that class of accident impossible rather than merely discouraged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-26 16:34:15 +02:00
parent 94081233f2
commit f7f06aee53
97 changed files with 4462 additions and 3273 deletions

View File

@ -17,7 +17,13 @@ tally, both derived from the same day-status classification
(`_daystatus.py` / `day_status_service.dart`).
See `docs/design.md` for the original feature spec (meal-slot timing logic,
the Tue/Wed/Thu "filled most of the day" catch-up rule, multi-item meals).
the Tue/Wed/Thu "filled most of the day" catch-up rule). Note that the
multi-item **meal builder** described there was removed on 2026-07-26 as
unused, on both the gate and the phone; only its `components` *storage*
remains, so already-logged composite meals keep their per-item macros. Nothing
creates a new one. The phone's "repeat last meal" button and the
temptation-bundling reward prompt were removed in the same pass (the reward
was replaced by a post-log progress card).
## Scheduling
@ -58,10 +64,24 @@ fine-grained GitHub PAT scoped to `syncs`'s contents (read/write),
then save it to `~/.config/diet_guard/sync_token`, mode 600. Until that file
exists, every sync tick is a harmless no-op that logs `sync not configured`.
The food bank stays *derived*, never synced: only `food_log.json` round-trips
through GitHub, and each device rebuilds its own `food_bank.json` locally by
replaying the merged log (`_foodbank.rebuild_food_bank`) — this is what avoids
needing CRDT counter-merge logic for a food's `count`.
**Both halves of the food bank sync.** The derived bank
(`food_bank.json`) is still rebuilt locally from the merged log
(`_foodbank.rebuild_food_bank`), but it is then merged and pushed too, so the
two devices agree *immediately* instead of only after each has replayed the
log. Its merge clock is the record's own **`count`**, not a wall time: LWW
therefore means *max-count-wins*, which is the correct merge for a derived
counter (the device that has seen more of the log has the higher count) and
is idempotent, because the count only moves when the log does. That is what
sidesteps needing a real counter CRDT.
The **hand-curated** bank also syncs
(`food_bank_manual.json`, `_foodbank_manual.py` / the manual half of
`foodbank_service.dart`). Those are foods the user added without ever eating
them, so they are not derivable from any log; before they synced they were
the last piece of device-local state in the app. One CRDT record per
normalized name, last-writer-wins by an `editedAt` stamp, union across
devices. `lookup_food`/`search_foods` merge them under the log-derived
records, so a food curated on the phone autocompletes in the PC gate.
## Production dependency installation — read this before adding any dependency
@ -103,9 +123,29 @@ silently does **not** reach the running service.
timestamp so `diet-guard-sync.timer`'s `budget.json` sync (mirroring the
food-log sync) resolves concurrent edits last-writer-wins. Do not
reintroduce the seal as a "fix" — the removal is the feature.
- **Past days are judged against the budget that applied then.**
`~/.local/share/diet_guard/.budget_history` (`diet_guard/_budget_history.py`,
mirrored by `app/lib/services/budget_schedule.dart`) is a forward-only list
of `(effective_from, kcal)` entries; `status_map`/`statusMap` resolve each
day through it, so lowering the budget never retroactively breaks the
adherence streak or the YTD tally. It syncs as extra `hist:<YYYY-MM-DD>`
*fields* on the existing `budget` CRDT record rather than as a second
document: `merge_record` is per-field LWW over the **union** of field names
and both devices push the *merged* log, so a device that predates the
feature relays those fields untouched. `write_budget` /
`saveDailyKcalGoal` must seed the pre-write value to `1970-01-01` **before**
recording the new one for today — reversing that makes every past day adopt
the new budget, which is the exact bug this exists to prevent.
- **Biometrics are used once and discarded.** `init` asks for biometrics to
compute the daily budget, then the only persisted output is the computed
budget number — never the biometrics themselves.
- **PC and phone share one source of truth for every piece of data.** The
food log, the budget, the budget history, body weight (`w`, its own
`weight` CRDT field), and the curated food bank all round-trip through
`kuhyx/syncs`. The derived food bank is a local cache of the synced log,
not separate state. There is deliberately nothing left that only one
device knows — if you add a new stored field, sync it or explain in a
comment why it physically cannot be shared.
- **State lives entirely under `~/.local/share/diet_guard/`** — no
cross-repo file coupling (unlike wake_alarm, which reads
`~/screen-locker/screen_locker/workout_log.json`). Safe to reason about in
@ -162,9 +202,21 @@ Consequences worth knowing before touching `app/`:
## Do NOT
- Don't relax the meal-slot/macro logic without re-reading `docs/design.md` —
the Tue/Wed/Thu catch-up rule and multi-item meal summing are deliberate,
not accidental complexity.
- Don't relax the meal-slot logic without re-reading `docs/design.md` — the
Tue/Wed/Thu catch-up rule is deliberate, not accidental complexity. The
off-hours **clamp** in `slot_for_log`/`slotForLog` is likewise deliberate
(a meal before 08:00 counts toward 08:00, after 22:00 toward 20:00) and
must stay byte-identical across Python and Dart; do NOT "fix" it by
widening `elapsed_slots`, which would make every slot fall due at 23:00.
- Don't re-add the meal builder, the "repeat last meal" button, the reward
prompt, or **meal photos** — all four were removed deliberately as unused.
The photo removal took `image_picker`, the blob stores, the wrapper's
`/blobs/` route and the `CAMERA` permission with it; re-adding photos means
re-adding all of that *plus* syncing image blobs, since a device-local
attachment would violate the shared-source-of-truth rule above. Do not strip the
`components` field either: nothing writes it any more, but historical
composite entries and the food bank still read it, and it is part of the
sync wire format.
- Don't add a dependency without doing the production install-path check
above.
- Don't reintroduce a seal/lock/`chattr +i` on the budget file as a "fix" —

View File

@ -1,8 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- image_picker's camera source delegates to the device's camera app via
intent, but declaring this avoids picker failures on OEMs that check
for it before honoring the intent. -->
<uses-permission android:name="android.permission.CAMERA"/>
<!-- Sync (github_client.dart) needs raw socket access. -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Android 13+ requires runtime opt-in to post notifications; requested

View File

@ -7,9 +7,8 @@
/// half of the desktop app:
///
/// * it serves the built web assets;
/// * it owns an on-disk copy of every document and photo the app stores, so a
/// wiped Chrome profile is not a total-loss event (photos in particular
/// never sync, so nothing else could restore them);
/// * it owns an on-disk copy of every document the app stores, so a wiped
/// Chrome profile is not a total-loss event;
/// * it fronts GitHub, holding the sync token and running the CORS-less
/// device flow the page cannot (see `github_proxy.dart`).
///
@ -38,7 +37,7 @@ class WrapperServer {
/// Directory holding the built Flutter web assets.
final String webRoot;
/// Directory holding the on-disk copies of documents and photo blobs.
/// Directory holding the on-disk copies of the app's documents.
final String dataDir;
/// GitHub half of the wrapper.
@ -94,13 +93,6 @@ class WrapperServer {
path.substring(WrapperPaths.documents.length),
);
}
if (path.startsWith(WrapperPaths.blobs)) {
return _storedFile(
request,
'blobs',
path.substring(WrapperPaths.blobs.length),
);
}
return _static(request, path);
}

View File

@ -7,6 +7,7 @@ import 'dart:async';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/background_tasks.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/frame_stats.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
@ -18,6 +19,7 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await LogStorageService.init();
await AppSettingsService.init();
await BudgetHistoryService.init();
await FoodBankService.init();
final notifications = await NotificationService.init();
// Deliberately *not* awaited before the first frame: the browser's

View File

@ -2,7 +2,7 @@
/// diet_guard's `_foodbank.BankRecord`.
library;
/// A previously-logged food's remembered macros and use count.
/// A previously-logged (or hand-curated) food's macros and use count.
///
/// Mirrors `_foodbank.py`'s on-disk shape: `{desc, kcal, protein_g,
/// carbs_g, fat_g, grams, count, components?}`. Unlike [FoodEntry], a
@ -20,19 +20,35 @@ class FoodBankRecord {
required this.grams,
required this.count,
this.components,
this.editedAt,
});
/// Builds a [FoodBankRecord] from its JSON map representation.
factory FoodBankRecord.fromJson(Map<String, dynamic> json) => FoodBankRecord(
desc: json['desc'] as String? ?? '',
kcal: (json['kcal'] as num?)?.toDouble() ?? 0,
proteinG: (json['protein_g'] as num?)?.toDouble() ?? 0,
carbsG: (json['carbs_g'] as num?)?.toDouble() ?? 0,
fatG: (json['fat_g'] as num?)?.toDouble() ?? 0,
grams: (json['grams'] as num?)?.toDouble() ?? 0,
count: (json['count'] as num?)?.toDouble() ?? 0,
components: (json['components'] as List?)?.cast<String>(),
);
///
/// Every field is type-*checked* rather than cast. These maps now arrive
/// from another device over the network, and a bad cast here throws a
/// Dart `TypeError` -- an `Error`, not an `Exception` -- which sails
/// straight past the `on Exception` guards in `background_sync_service`
/// and `settings_screen`. One malformed record in the shared repo would
/// otherwise break this device's sync permanently. Mirrors the `isinstance`
/// checks the Python side already uses.
factory FoodBankRecord.fromJson(Map<String, dynamic> json) {
double number(Object? value) => value is num ? value.toDouble() : 0;
final components = json['components'];
return FoodBankRecord(
desc: json['desc'] is String ? json['desc'] as String : '',
kcal: number(json['kcal']),
proteinG: number(json['protein_g']),
carbsG: number(json['carbs_g']),
fatG: number(json['fat_g']),
grams: number(json['grams']),
count: number(json['count']),
components: components is List
? components.whereType<String>().toList()
: null,
editedAt: json['t'] is String ? json['t'] as String : null,
);
}
/// The food or meal's display name, as the user typed it.
final String desc;
@ -58,6 +74,13 @@ class FoodBankRecord {
/// Component names, for a composite meal record only.
final List<String>? components;
/// Local ISO-8601 stamp of the last hand-edit, for curated entries only.
///
/// Null on log-derived records: those are rebuilt from the (already synced)
/// log and need no clock of their own. On a curated entry it is what the
/// cross-device merge orders by -- see `foodbank_manual_sync`.
final String? editedAt;
/// Returns this record as a JSON-ready map with snake_case keys.
Map<String, Object?> toJson() => {
'desc': desc,
@ -68,5 +91,6 @@ class FoodBankRecord {
'grams': grams,
'count': count,
if (components != null) 'components': components,
if (editedAt != null) 't': editedAt,
};
}

View File

@ -24,7 +24,6 @@ class FoodEntry {
this.hmac,
this.components,
this.deleted = false,
this.imagePath,
});
/// Builds a [FoodEntry] from its JSON map representation.
@ -48,7 +47,6 @@ class FoodEntry {
.map(MealComponent.fromJson)
.toList(),
deleted: json['deleted'] as bool? ?? false,
imagePath: json['imagePath'] as String?,
);
/// Stable identity for sync merge (UUID v4). Null only for legacy entries
@ -96,21 +94,17 @@ class FoodEntry {
/// physically removed) so a sync merge can't resurrect a stale copy.
final bool deleted;
/// Local file path to an attached photo, if any. Phone-local only --
/// never read from a pulled remote copy and stripped before push.
final String? imagePath;
/// Returns the full local-storage representation, including [imagePath].
Map<String, Object?> toLocalJson() => {
...toSyncJson(),
if (imagePath != null) 'imagePath': imagePath,
};
/// Returns the full local-storage representation.
///
/// Identical to [toSyncJson] now that nothing is device-local: every field
/// a device stores is a field it shares.
Map<String, Object?> toLocalJson() => toSyncJson();
/// Returns what gets pushed to this device's sync snapshot.
///
/// Excludes [imagePath] (meaningless on another device) and [hmac] (the
/// phone never computes one; the PC re-signs on merge regardless of
/// origin, so an inbound signature would only be stripped there anyway).
/// Excludes [hmac] (the phone never computes one; the PC re-signs on merge
/// regardless of origin, so an inbound signature would only be stripped
/// there anyway).
Map<String, Object?> toSyncJson() => {
if (id != null) 'id': id,
'time': time,
@ -127,24 +121,6 @@ class FoodEntry {
if (deleted) 'deleted': true,
};
/// Returns a copy of this entry with [imagePath] replaced.
FoodEntry copyWithImagePath(String? imagePath) => FoodEntry(
id: id,
time: time,
desc: desc,
grams: grams,
kcal: kcal,
proteinG: proteinG,
carbsG: carbsG,
fatG: fatG,
source: source,
slot: slot,
hmac: hmac,
components: components,
deleted: deleted,
imagePath: imagePath,
);
/// Returns a copy of this entry tombstoned (`deleted: true`).
FoodEntry copyWithDeleted() => FoodEntry(
id: id,
@ -160,6 +136,5 @@ class FoodEntry {
hmac: hmac,
components: components,
deleted: true,
imagePath: imagePath,
);
}

View File

@ -1,61 +0,0 @@
/// Composite "meal" support, mirroring diet_guard's `_meal.py`.
library;
import 'package:diet_guard_app/models/meal_component.dart';
import 'package:diet_guard_app/models/nutrition.dart';
/// Provenance stamped on a summed meal, matching `_meal.MEAL_SOURCE`.
const String mealSource = 'meal';
/// One named component of a composite meal, with its own nutrition.
///
/// Mirrors `_meal.MealItem`.
class MealItem {
/// Creates a [MealItem] from a component's name and resolved macros.
const MealItem({required this.name, required this.nutrition});
/// The component's food name (e.g. `"chicken"`).
final String name;
/// The component's resolved macros for the amount eaten.
final Nutrition nutrition;
}
/// Returns the summed nutrition of a meal's [items].
///
/// Every macro and the portion weight are added across the items and
/// rounded to 0.1, and the result is stamped `source: mealSource` so it is
/// distinguishable from a single food. Mirrors `_meal.meal_total`.
Nutrition mealTotal(List<MealItem> items) {
double sumOf(double Function(MealItem) field) {
var total = 0.0;
for (final item in items) {
total += field(item);
}
return double.parse(total.toStringAsFixed(1));
}
return Nutrition(
kcal: sumOf((item) => item.nutrition.kcal),
proteinG: sumOf((item) => item.nutrition.proteinG),
carbsG: sumOf((item) => item.nutrition.carbsG),
fatG: sumOf((item) => item.nutrition.fatG),
grams: sumOf((item) => item.nutrition.grams),
source: mealSource,
);
}
/// Returns a composite meal's per-component log record for [item].
///
/// Carries the component's full macros (not just its name) so a food bank
/// rebuilt purely by replaying the log can recover each component's
/// standalone nutrition, not just the composite's summed total. Mirrors
/// `_meal.item_to_component`.
MealComponent itemToComponent(MealItem item) => MealComponent(
name: item.name,
kcal: item.nutrition.kcal,
proteinG: item.nutrition.proteinG,
carbsG: item.nutrition.carbsG,
fatG: item.nutrition.fatG,
grams: item.nutrition.grams,
);

View File

@ -61,6 +61,23 @@ int? currentSlot(DateTime now) {
return elapsed.isEmpty ? null : elapsed.last;
}
/// Returns the slot a meal logged at [now] should be attributed to.
///
/// CLAMP RULE (keep byte-identical with `_slots.slot_for_log`): before the
/// first window, clamp to the first slot; after the last window, clamp to the
/// last slot; behaviour inside a window is unchanged.
///
/// Unlike [currentSlot] this never returns null, which is the point: an
/// off-hours meal used to satisfy no slot at all, so eating at 07:30 or
/// 22:30 still produced a "you haven't logged your meal" reminder. Attribution
/// is deliberately separate from [elapsedSlots]/[missingSlots] -- widening
/// *those* would instead make every slot fall due at 23:00.
int slotForLog(DateTime now) {
final slots = daySlots();
if (now.hour < gateDayStartHour) return slots.first;
return currentSlot(now) ?? slots.last;
}
/// Returns a human `HH:00` label for [slot], e.g. `"08:00"`.
///
/// Mirrors `_slots.slot_label`.

View File

@ -12,6 +12,7 @@ import 'dart:async';
import 'package:diet_guard_app/models/day_status.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/day_status_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
@ -56,14 +57,21 @@ class _CalendarScreenState extends State<CalendarScreen> {
super.dispose();
}
/// Loads the log, recomputes status/streaks/tally against the current
/// budget, and refreshes the (read-only) budget field -- unless the user
/// is mid-edit, in which case their unsaved text is left untouched.
/// Loads the log, recomputes status/streaks/tally, and refreshes the
/// (read-only) budget field -- unless the user is mid-edit, in which case
/// their unsaved text is left untouched.
///
/// Each past day is judged against the budget that applied *then*, so
/// changing the budget today leaves the grid, the streaks, and the tally
/// for earlier days alone.
Future<void> _load() async {
final log = await LogStorageService.instance.readLog();
if (!mounted) return;
final budget = AppSettingsService.dailyKcalGoal;
final statusByDate = statusMap(log, budget: budget);
final statusByDate = statusMap(
log,
schedule: BudgetHistoryService.schedule,
);
setState(() {
_statusByDate = statusByDate;
_loggingStreak = loggingStreak(statusByDate);

View File

@ -16,7 +16,7 @@ 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
/// and [FoodEntry.deleted]. All nutritional fields and
/// the description are editable.
class EditEntryScreen extends StatefulWidget {
/// Creates an [EditEntryScreen] pre-filled with [entry].
@ -129,7 +129,6 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
fatG: nutrition.fatG,
source: _source,
slot: e.slot,
imagePath: e.imagePath,
);
await LogStorageService.instance.updateEntry(e, updated);
final log = await LogStorageService.instance.readLog();

View File

@ -5,11 +5,9 @@ import 'dart:async';
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/photo_viewer_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/day_status_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/widgets/attached_image.dart';
import 'package:flutter/material.dart';
// ---------------------------------------------------------------------------
@ -51,7 +49,6 @@ class HistoryFilter {
this.maxCarbs,
this.minFat,
this.maxFat,
this.hasPhoto,
this.source,
});
@ -85,9 +82,6 @@ class HistoryFilter {
/// Maximum fat (g).
double? maxFat;
/// null = all, true = with photo, false = without.
bool? hasPhoto;
/// null = all, or a source string from the log.
String? source;
@ -103,7 +97,6 @@ class HistoryFilter {
maxCarbs != null ||
minFat != null ||
maxFat != null ||
hasPhoto != null ||
source != null;
}
@ -186,13 +179,6 @@ List<FoodEntry> applyHistoryFilter(
if (filter.maxFat != null) {
result = result.where((e) => e.fatG <= filter.maxFat!).toList();
}
if (filter.hasPhoto != null) {
result = result
.where(
(e) => filter.hasPhoto! ? e.imagePath != null : e.imagePath == null,
)
.toList();
}
if (filter.source != null) {
result = result.where((e) => e.source == filter.source).toList();
}
@ -372,7 +358,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
maxCarbs: _filter.maxCarbs,
minFat: _filter.minFat,
maxFat: _filter.maxFat,
hasPhoto: _filter.hasPhoto,
source: _filter.source,
);
var draftSortField = _sortField;
@ -512,7 +497,9 @@ class _DayHeaderTile extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final goal = AppSettingsService.dailyKcalGoal;
// The budget that applied on *that* day, not today's -- a later budget
// change must not repaint months of history red.
final goal = BudgetHistoryService.schedule.forDay(header.dateKey);
final kcalColor = header.totalKcal > goal
? colorScheme.error
: colorScheme.primary;
@ -576,7 +563,7 @@ class _EntryTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
leading: _Thumbnail(imagePath: entry.imagePath),
leading: const Icon(Icons.restaurant),
title: Text(entry.desc),
subtitle: Text('${entry.time} • ${entry.source}'),
trailing: Text('${entry.kcal.toStringAsFixed(0)} kcal'),
@ -615,38 +602,6 @@ class _EntryTile extends StatelessWidget {
}
}
class _Thumbnail extends StatelessWidget {
const _Thumbnail({required this.imagePath});
final String? imagePath;
@override
Widget build(BuildContext context) {
final path = imagePath;
if (path == null) {
return const SizedBox(
width: 40,
height: 40,
child: Icon(Icons.restaurant),
);
}
return GestureDetector(
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => PhotoViewerScreen(path: path)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: AttachedImage(
path: path,
width: 40,
height: 40,
fit: BoxFit.cover,
),
),
);
}
}
// ---------------------------------------------------------------------------
// Filter sheet
// ---------------------------------------------------------------------------
@ -905,43 +860,6 @@ class _FilterSheet extends StatelessWidget {
const SizedBox(height: 8),
],
// Photo filter
Text(
'Photo',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('Any'),
selected: filter.hasPhoto == null,
onSelected: (_) {
filter.hasPhoto = null;
onFilterChanged(filter);
},
),
FilterChip(
label: const Text('With photo'),
selected: filter.hasPhoto == true,
onSelected: (_) {
filter.hasPhoto = true;
onFilterChanged(filter);
},
),
FilterChip(
label: const Text('Without photo'),
selected: filter.hasPhoto == false,
onSelected: (_) {
filter.hasPhoto = false;
onFilterChanged(filter);
},
),
],
),
const SizedBox(height: 16),
// Source filter
Text(
'Source',
@ -959,7 +877,7 @@ class _FilterSheet extends StatelessWidget {
onFilterChanged(filter);
},
),
for (final src in ['manual', 'food bank', 'meal'])
for (final src in ['manual', 'food bank'])
FilterChip(
label: Text(src),
selected: filter.source == src,

View File

@ -7,31 +7,33 @@ import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/food_suggestion.dart';
import 'package:diet_guard_app/models/local_time.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/screens/calendar_screen.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/background_tasks.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/day_status_service.dart';
import 'package:diet_guard_app/services/due_slot_check.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/sync_service.dart';
import 'package:diet_guard_app/services/sync_settings.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
import 'package:diet_guard_app/widgets/macro_input_row.dart';
import 'package:diet_guard_app/widgets/photo_attach_field.dart';
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
import 'package:diet_guard_app/widgets/today_progress_card.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
/// Lets the user log one food item, with food-bank autocomplete and
/// today's slot status, or hop into [MealBuilderScreen] for a composite
/// multi-item meal.
/// today's slot status.
class LogMealScreen extends StatefulWidget {
/// Creates a [LogMealScreen].
const LogMealScreen({super.key, this.httpClient});
@ -53,9 +55,7 @@ class _LogMealScreenState extends State<LogMealScreen>
int? _selectedSlot;
String _source = 'manual';
String? _status;
String? _imagePath;
FoodEntry? _lastEntry;
bool _showRewardPrompt = false;
TodayProgress? _progress;
/// Single-flight guard so a launch sync and a lifecycle sync never overlap.
bool _autoSyncing = false;
@ -75,11 +75,10 @@ class _LogMealScreenState extends State<LogMealScreen>
]) {
controller.addListener(_onMacroEdited);
}
_selectedSlot = currentSlot(DateTime.now());
_selectedSlot = slotForLog(DateTime.now());
unawaited(_refreshSlots());
unawaited(_onDescChanged());
unawaited(_autoSync());
unawaited(_refreshLastEntry());
}
@override
@ -155,15 +154,6 @@ class _LogMealScreenState extends State<LogMealScreen>
setState(() => _loggedSlots = logged);
}
/// Refreshes the entry "repeat last meal" would log, and its enabled
/// state. Called after every log (manual, built, or repeated) so the
/// button always repeats the most recent entry, not a stale one.
Future<void> _refreshLastEntry() async {
final entry = await LogStorageService.instance.lastLoggedEntry();
if (!mounted) return;
setState(() => _lastEntry = entry);
}
void _onMacroEdited() {
if (_source == 'food bank') {
setState(() => _source = 'manual');
@ -177,9 +167,13 @@ class _LogMealScreenState extends State<LogMealScreen>
if (!mounted) return;
setState(() {
_suggestions = matches;
// Starting to fill in the manual form dismisses a stale reward prompt
// left over from a prior one-tap "repeat last meal" log.
_showRewardPrompt = false;
// Typing the *next* meal dismisses the previous log's progress card.
// Guarded on non-empty text because _onLogMeal's own
// `_descController.clear()` also fires this listener: without the
// guard the two async chains race and the clear can null the card
// right after _onLogMeal set it, so the card never appears on device
// (in-memory test stores resolve fast enough to hide this).
if (_descController.text.isNotEmpty) _progress = null;
});
}
@ -203,7 +197,10 @@ class _LogMealScreenState extends State<LogMealScreen>
Future<void> _onLogMeal() async {
final desc = _descController.text.trim();
if (desc.isEmpty) {
setState(() => _status = 'Type what you ate first.');
setState(() {
_status = 'Type what you ate first.';
_progress = null;
});
return;
}
final nutrition = nutritionForPortion(
@ -219,7 +216,6 @@ class _LogMealScreenState extends State<LogMealScreen>
desc,
nutrition,
slot: _selectedSlot,
imagePath: _imagePath,
);
final log = await LogStorageService.instance.readLog();
await FoodBankService.instance.rebuildAndPersist(log);
@ -230,72 +226,56 @@ class _LogMealScreenState extends State<LogMealScreen>
// Offline backstop: if the push above fails (no connectivity), a
// connectivity-gated WorkManager task uploads the meal on reconnect.
unawaited(_enqueueSyncBackstop());
await _dismissStaleReminder();
if (!mounted) return;
_descController.clear();
_macros.clear();
setState(() {
_source = 'manual';
_imagePath = null;
_selectedSlot = currentSlot(DateTime.now());
_selectedSlot = slotForLog(DateTime.now());
});
await _refreshSlots();
if (!mounted) return;
setState(() => _status = 'Logged "$desc".');
await _refreshLastEntry();
}
/// One-tap "repeat last meal": re-logs [_lastEntry] verbatim (minus any
/// photo) against today's current slot. Mirrors [_onLogMeal]'s post-write
/// sequence exactly so offline/sync semantics stay identical.
Future<void> _onLogLastMeal() async {
final last = _lastEntry;
if (last == null) return;
final nutrition = Nutrition(
kcal: last.kcal,
proteinG: last.proteinG,
carbsG: last.carbsG,
fatG: last.fatG,
grams: last.grams,
source: last.source,
);
await LogStorageService.instance.logMeal(
last.desc,
nutrition,
slot: currentSlot(DateTime.now()),
components: last.components,
// No imagePath: a repeated meal shouldn't drag forward a photo of a
// specific past occasion.
);
final log = await LogStorageService.instance.readLog();
await FoodBankService.instance.rebuildAndPersist(log);
unawaited(_autoSync());
unawaited(_enqueueSyncBackstop());
if (!mounted) return;
await _refreshSlots();
if (!mounted) return;
setState(() {
_status = 'Logged "${last.desc}" again.';
_showRewardPrompt = AppSettingsService.rewardUrl?.isNotEmpty ?? false;
_status = null;
_progress = _buildProgress(log, desc);
});
await _refreshLastEntry();
}
Future<void> _openReward() async {
final url = AppSettingsService.rewardUrl;
if (url == null || url.isEmpty) return;
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
/// Clears any reminder the meal just logged has now satisfied.
///
/// Without this a notification already on screen survives until the next
/// background tick (up to 15 minutes), which reads as a false alarm even
/// though the meal was logged on this very device. Passes
/// `pullWhenDue: false` because [_autoSync] above already owns the network
/// here and the local log is by definition the freshest copy.
///
/// [checkAndNotify] already swallows notification-platform failures, so
/// the meal -- written before this runs -- can never be lost to one.
Future<void> _dismissStaleReminder() => checkAndNotify(pullWhenDue: false);
Future<void> _onBuildMeal() async {
await Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const MealBuilderScreen()),
/// Summarises today from the log just written.
///
/// Takes the already-read [log] rather than re-reading, so the card can
/// never disagree with the write that produced it.
TodayProgress _buildProgress(DayLog log, String desc) {
final budget = AppSettingsService.dailyKcalGoal;
final today = localDateKey(DateTime.now());
final entries = (log[today] ?? const <FoodEntry>[])
.where((entry) => !entry.deleted)
.toList();
final macros = sumMacros(entries);
return TodayProgress(
justLogged: desc,
consumedKcal: sumKcal(entries),
budgetKcal: budget,
proteinG: macros.proteinG,
carbsG: macros.carbsG,
fatG: macros.fatG,
adherenceStreak: adherenceStreak(
statusMap(log, schedule: BudgetHistoryService.schedule),
),
);
await _refreshSlots();
await _refreshLastEntry();
// A meal built and logged in the builder should push right away too,
// with the same offline backstop.
unawaited(_autoSync());
unawaited(_enqueueSyncBackstop());
}
void _onOpenHistory() {
@ -360,18 +340,8 @@ class _LogMealScreenState extends State<LogMealScreen>
),
],
),
floatingActionButton: Tooltip(
message: _lastEntry == null
? 'No previous meal to repeat'
: 'Repeat "${_lastEntry!.desc}"',
child: FloatingActionButton.extended(
onPressed: _lastEntry == null ? null : _onLogLastMeal,
icon: const Icon(Icons.repeat),
label: const Text('Repeat last meal'),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -396,20 +366,7 @@ class _LogMealScreenState extends State<LogMealScreen>
const SizedBox(height: 8),
Row(
children: [
PhotoAttachField(
imagePath: _imagePath,
onChanged: (path) => setState(() => _imagePath = path),
compact: true,
),
const Spacer(),
Tooltip(
message: 'Build a multi-item meal',
child: OutlinedButton(
onPressed: _onBuildMeal,
child: const Icon(Icons.playlist_add),
),
),
const SizedBox(width: 8),
Tooltip(
message: 'Log meal',
child: FilledButton(
@ -419,20 +376,16 @@ class _LogMealScreenState extends State<LogMealScreen>
),
],
),
// Mutually exclusive: _status carries a validation complaint,
// _progress the post-log summary. A successful log clears one
// and sets the other, so the two never stack.
if (_status != null) ...[
const SizedBox(height: 12),
const SizedBox(height: AppSpacing.sm),
Text(_status!),
],
if (_showRewardPrompt &&
(AppSettingsService.rewardUrl?.isNotEmpty ?? false)) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => unawaited(_openReward()),
icon: const Icon(Icons.card_giftcard),
label: Text(
'Open ${AppSettingsService.rewardLabel ?? "reward"}',
),
),
if (_progress != null) ...[
const SizedBox(height: AppSpacing.sm),
TodayProgressCard(progress: _progress!),
],
],
),

View File

@ -1,152 +0,0 @@
/// Composite multi-item meal flow, mirroring `_gatelock_mealflow.py`'s
/// add-item/log-meal loop.
library;
import 'package:diet_guard_app/models/meal_item.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/models/slot.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/macro_input_row.dart';
import 'package:diet_guard_app/widgets/photo_attach_field.dart';
import 'package:flutter/material.dart';
/// A screen for building and logging a multi-item meal as one composite
/// entry, e.g. a dinner of soup + chicken + rice.
class MealBuilderScreen extends StatefulWidget {
/// Creates a [MealBuilderScreen].
const MealBuilderScreen({super.key});
@override
State<MealBuilderScreen> createState() => _MealBuilderScreenState();
}
class _MealBuilderScreenState extends State<MealBuilderScreen> {
final TextEditingController _itemDescController = TextEditingController();
final TextEditingController _mealNameController = TextEditingController();
final MacroControllers _macros = MacroControllers();
final List<MealItem> _items = [];
String? _status;
String? _imagePath;
@override
void dispose() {
_itemDescController.dispose();
_mealNameController.dispose();
_macros.dispose();
super.dispose();
}
double _parse(TextEditingController controller) =>
double.tryParse(controller.text.trim()) ?? 0;
void _onAddItem() {
final desc = _itemDescController.text.trim();
if (desc.isEmpty) {
setState(() => _status = 'Type the item first, then add it.');
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: 'manual',
);
setState(() {
_items.add(MealItem(name: desc, nutrition: nutrition));
_itemDescController.clear();
_macros.clear();
_status = 'Added $desc. Add another, or log the meal.';
});
}
Future<void> _onLogMeal() async {
if (_items.isEmpty) {
setState(() => _status = 'Add at least one item first.');
return;
}
final name = _mealNameController.text.trim().isEmpty
? 'meal'
: _mealNameController.text.trim();
final total = mealTotal(_items);
final components = _items.map(itemToComponent).toList();
final slot = currentSlot(DateTime.now());
await LogStorageService.instance.logMeal(
name,
total,
slot: slot,
components: components,
imagePath: _imagePath,
);
final log = await LogStorageService.instance.readLog();
await FoodBankService.instance.rebuildAndPersist(log);
if (!mounted) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final total = mealTotal(_items);
return Scaffold(
appBar: AppBar(title: const Text('Build a meal')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _mealNameController,
decoration: const InputDecoration(
labelText: 'Meal name (optional)',
),
),
const SizedBox(height: 12),
if (_items.isNotEmpty) ...[
Text(
'So far (${_items.length}): '
'${_items.map((i) => i.name).join(', ')} -> '
'${total.kcal.toStringAsFixed(0)} kcal '
'P${total.proteinG.toStringAsFixed(0)} '
'C${total.carbsG.toStringAsFixed(0)} '
'F${total.fatG.toStringAsFixed(0)}',
),
const SizedBox(height: 12),
],
TextField(
controller: _itemDescController,
decoration: const InputDecoration(labelText: 'Item name'),
),
const SizedBox(height: 8),
MacroInputRow(controllers: _macros),
const SizedBox(height: 12),
PhotoAttachField(
imagePath: _imagePath,
onChanged: (path) => setState(() => _imagePath = path),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: _onAddItem,
child: const Text('Add item'),
),
ElevatedButton(
onPressed: _onLogMeal,
child: const Text('Log meal'),
),
],
),
if (_status != null) ...[
const SizedBox(height: 12),
Text(_status!),
],
],
),
),
);
}
}

View File

@ -1,37 +0,0 @@
/// Full-screen, pinch-to-zoom view of a locally attached meal photo.
library;
import 'package:diet_guard_app/widgets/attached_image.dart';
import 'package:flutter/material.dart';
/// Shows the image at [path] full-screen, with pinch-to-zoom and a back
/// button to dismiss.
class PhotoViewerScreen extends StatelessWidget {
/// Creates a [PhotoViewerScreen] for the photo at [path].
const PhotoViewerScreen({required this.path, super.key});
/// Blob key of the image to display (a file path on Android, an IndexedDB
/// key in the desktop web build).
final String path;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: scheme.surface,
appBar: AppBar(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
),
body: Center(
child: InteractiveViewer(
child: AttachedImage(
path: path,
errorIconSize: 64,
errorIconColor: scheme.onSurface,
),
),
),
);
}
}

View File

@ -44,8 +44,6 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
final _kcalGoalController = TextEditingController();
final _rewardLabelController = TextEditingController();
final _rewardUrlController = TextEditingController();
final _ownerController = TextEditingController();
final _repoController = TextEditingController();
final _tokenController = TextEditingController();
@ -72,8 +70,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
if (!mounted) return;
_kcalGoalController.text = AppSettingsService.dailyKcalGoal.toString();
_rewardLabelController.text = AppSettingsService.rewardLabel ?? '';
_rewardUrlController.text = AppSettingsService.rewardUrl ?? '';
_ownerController.text = settings.owner;
_repoController.text = settings.repo;
// On web the stored "token" is only a stand-in for one the desktop
@ -89,9 +85,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
void dispose() {
_kcalGoalDebounce?.cancel();
// Leaving the screen inside the debounce window must not silently drop
// the edit the user just typed.
_flushKcalGoal();
_kcalGoalController.dispose();
_rewardLabelController.dispose();
_rewardUrlController.dispose();
_ownerController.dispose();
_repoController.dispose();
_tokenController.dispose();
@ -103,6 +101,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// round-trips it instead of blanking it on the next save.
String _storedToken = '';
Timer? _kcalGoalDebounce;
int? _pendingKcalGoal;
/// Persists the pending goal, if any, and clears it.
void _flushKcalGoal() {
final goal = _pendingKcalGoal;
if (goal == null) return;
_pendingKcalGoal = null;
unawaited(AppSettingsService.instance.saveDailyKcalGoal(goal));
}
/// Saves the typed goal once typing settles.
///
/// Debounced because every keystroke would otherwise be a real edit:
/// typing "2000" saved 2, 20, 200, 2000 in turn, and a sync tick landing
/// between keystrokes would push a nonsense budget to the other device.
void _onKcalGoalChanged(String value) {
_kcalGoalDebounce?.cancel();
final goal = int.tryParse(value);
if (goal == null || goal <= 0) return;
_pendingKcalGoal = goal;
_kcalGoalDebounce = Timer(
const Duration(milliseconds: 600),
_flushKcalGoal,
);
}
SyncSettings _currentSettings() {
final typed = _tokenController.text.trim();
return SyncSettings(
@ -118,16 +143,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
/// Persists the temptation-bundling reward fields, blank -> null.
Future<void> _saveReward() async {
final label = _rewardLabelController.text.trim();
final url = _rewardUrlController.text.trim();
await AppSettingsService.instance.saveReward(
label: label.isEmpty ? null : label,
url: url.isEmpty ? null : url,
);
}
void _showMessage(String message) {
if (!mounted) return;
setState(() => _status = message);
@ -273,37 +288,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
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('Reward', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Shown after a one-tap "repeat last meal" log, as a temptation '
'bundle. Leave blank to disable. Device-local, not synced.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
TextField(
controller: _rewardLabelController,
decoration: const InputDecoration(
labelText: 'Reward label',
helperText: 'e.g. "Podcast episode"',
),
onChanged: (v) => unawaited(_saveReward()),
),
const SizedBox(height: 8),
TextField(
controller: _rewardUrlController,
decoration: const InputDecoration(labelText: 'Reward URL'),
onChanged: (v) => unawaited(_saveReward()),
onChanged: _onKcalGoalChanged,
),
const SizedBox(height: 24),
const Divider(),

View File

@ -3,6 +3,8 @@ library;
import 'dart:convert';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/document_store.dart';
import 'package:diet_guard_app/services/document_store_factory.dart';
import 'package:flutter/foundation.dart';
@ -29,29 +31,19 @@ class AppSettingsService {
static AppSettingsService get instance => _instance!;
final DocumentStore _store;
int _dailyKcalGoal = 2200;
int _dailyKcalGoal = kDefaultDailyKcalGoal;
DateTime? _dailyKcalGoalUpdatedAt;
String? _rewardLabel;
String? _rewardUrl;
/// Returns the configured daily kcal goal, or 2200 when uninitialised.
static int get dailyKcalGoal => _instance?._dailyKcalGoal ?? 2200;
/// Returns the configured daily kcal goal, or the default when
/// uninitialised. This is the budget in force *today*; asking what applied
/// on a past day is [BudgetHistoryService.schedule].
static int get dailyKcalGoal =>
_instance?._dailyKcalGoal ?? kDefaultDailyKcalGoal;
/// Returns when the goal was last set on this device, or null if never.
static DateTime? get dailyKcalGoalUpdatedAt =>
_instance?._dailyKcalGoalUpdatedAt;
/// Returns the configured temptation-bundling reward label, or null.
///
/// Device-local only -- no `updatedAt`/sync merge wiring, unlike the kcal
/// goal. Adding a sync-ready timestamp without real merge logic in
/// `sync_merge.dart`/`sync_service.dart` would imply cross-device behavior
/// that doesn't exist yet.
static String? get rewardLabel => _instance?._rewardLabel;
/// Returns the configured temptation-bundling reward URL, or null.
static String? get rewardUrl => _instance?._rewardUrl;
/// Initialises the singleton against the platform document store (files on
/// Android, IndexedDB in the browser-hosted desktop app).
static Future<AppSettingsService> init() async {
@ -86,6 +78,20 @@ class AppSettingsService {
return svc;
}
/// Grandfathers the loaded goal to the epoch when no history exists yet.
///
/// Mirrors `_budget.current_schedule`'s seed-on-read: without this the only
/// seeding path was an explicit edit, so an upgraded device that had merely
/// *synced* a goal classified its whole history against the fallback instead
/// of that goal.
Future<void> _seedHistoryIfEmpty() async {
if (!BudgetHistoryService.isInitialized) return;
await BudgetHistoryService.instance.seedIfEmpty(
_dailyKcalGoal,
_dailyKcalGoalUpdatedAt,
);
}
Future<void> _load() async {
final raw = await _store.read(documentName);
if (raw == null) return;
@ -99,21 +105,30 @@ class AppSettingsService {
data['daily_kcal_goal_updated_at'] as String,
);
}
if (data is Map && data['reward_label'] is String) {
_rewardLabel = data['reward_label'] as String;
}
if (data is Map && data['reward_url'] is String) {
_rewardUrl = data['reward_url'] as String;
}
} on Exception {
// Ignore parse errors and keep defaults.
}
await _seedHistoryIfEmpty();
}
/// Updates the in-memory value and persists [goal] to disk, stamping the
/// edit with the current time -- the timestamp a sync merge compares
/// against another device's edit to resolve last-writer-wins.
Future<void> saveDailyKcalGoal(int goal) => _persist(goal, DateTime.now());
///
/// Also the single funnel that maintains the effective-from history, so
/// past days keep being judged against the budget that applied to them.
///
/// The ordering below is load-bearing and must not be rearranged: the
/// *pre-write* value is grandfathered to the epoch **before** the new one
/// is recorded for today. Seeding afterwards would leave a history of only
/// `{today: <new value>}`, which makes every past day resolve to the new
/// value -- precisely the retroactive reclassification the history exists
/// to prevent.
Future<void> saveDailyKcalGoal(int goal) async {
await _seedHistoryIfEmpty();
await _persist(goal, DateTime.now());
await BudgetHistoryService.instance.recordChange(goal);
}
/// Applies a synced budget value without stamping a fresh edit time.
///
@ -122,18 +137,13 @@ class AppSettingsService {
/// "now", so re-syncing an unchanged value stays idempotent instead of
/// making this device's copy look newer than it actually is on every
/// tick.
///
/// Deliberately records no history change: this is a merge write-back, not
/// a user edit, and the history has its own merged entries applied
/// alongside it by the sync layer.
Future<void> applySyncedBudget(int goal, {DateTime? updatedAt}) =>
_persist(goal, updatedAt);
/// Updates and persists the temptation-bundling reward shown after a
/// one-tap "repeat last meal" log. Pass `null` for either field to clear
/// it.
Future<void> saveReward({String? label, String? url}) async {
_rewardLabel = label;
_rewardUrl = url;
await _writeToDisk();
}
Future<void> _persist(int goal, DateTime? updatedAt) async {
_dailyKcalGoal = goal;
_dailyKcalGoalUpdatedAt = updatedAt;
@ -150,8 +160,6 @@ class AppSettingsService {
'daily_kcal_goal': _dailyKcalGoal,
'daily_kcal_goal_updated_at': _dailyKcalGoalUpdatedAt
?.toIso8601String(),
'reward_label': _rewardLabel,
'reward_url': _rewardUrl,
}),
);
}

View File

@ -6,6 +6,7 @@
library;
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
@ -34,6 +35,8 @@ Future<bool> backgroundSyncPush({http.Client? httpClient}) async {
await LogStorageService.init();
await FoodBankService.init();
await AppSettingsService.init();
// runSync also merges the budget history through this singleton.
await BudgetHistoryService.init();
final SyncSettings settings;
try {
settings = await SyncSettings.load();

View File

@ -1,22 +0,0 @@
/// Byte-blob persistence for attached meal photos.
library;
import 'dart:typed_data';
/// Stores and retrieves photo bytes under an opaque key.
///
/// The key is what ends up in [FoodEntry.imagePath]: an absolute file path on
/// Android (unchanged from before the web build existed, so installed phones
/// keep resolving their existing photos) and an IndexedDB key in the browser.
/// Callers must treat it as opaque and never join, split or stat it.
///
/// Deliberately free of `dart:io` -- see `document_store.dart` for why that
/// matters for the web build.
abstract class BlobStore {
/// Stores [bytes] as a new blob whose key ends with [extension] (`.jpg`),
/// returning the key.
Future<String> put(String extension, Uint8List bytes);
/// Returns the bytes stored under [key], or null when it is gone.
Future<Uint8List?> get(String key);
}

View File

@ -1,4 +0,0 @@
/// Platform entry point for opening the photo blob store.
library;
export 'blob_store_io.dart' if (dart.library.js_interop) 'blob_store_web.dart';

View File

@ -1,84 +0,0 @@
/// IndexedDB-backed [BlobStore], independent of how the database was opened.
///
/// Split from `blob_store_web.dart` so a plain VM test can drive it against
/// `idb_shim`'s in-memory backend: the browser factory
/// (`idb_browser.dart`) cannot be imported outside a web compile, and photos
/// have no other copy anywhere -- they never sync.
library;
import 'dart:typed_data';
import 'package:diet_guard_app/services/blob_store.dart';
import 'package:diet_guard_app/services/desktop_wrapper.dart';
import 'package:http/http.dart' as http;
import 'package:idb_shim/idb_shim.dart';
import 'package:uuid/uuid.dart';
/// [BlobStore] keeping photo bytes in IndexedDB, mirrored to the desktop
/// wrapper's on-disk copy.
///
/// Photos never sync (`imagePath` is stripped before push and ignored on
/// pull), so the wrapper mirror is the *only* thing standing between a wiped
/// Chrome profile and permanently losing them -- unlike the food log, which
/// GitHub would eventually restore.
class IndexedDbBlobStore implements BlobStore {
/// Creates a store over an already-open [database], mirroring to [baseUrl].
IndexedDbBlobStore(
this._database, {
this.baseUrl = desktopWrapperOrigin,
http.Client? httpClient,
}) : _client = httpClient ?? http.Client();
final Database _database;
final http.Client _client;
/// Origin of the desktop wrapper.
final String baseUrl;
/// Object store holding one entry per photo.
static const storeName = 'blobs';
/// IndexedDB database name.
static const databaseName = 'diet_guard_blobs';
@override
Future<String> put(String extension, Uint8List bytes) async {
final key = '${const Uuid().v4()}$extension';
final txn = _database.transaction(storeName, idbModeReadWrite);
await txn.objectStore(storeName).put(bytes, key);
await txn.completed;
await _writeMirror(key, bytes);
return key;
}
@override
Future<Uint8List?> get(String key) async {
final txn = _database.transaction(storeName, idbModeReadOnly);
final value = await txn.objectStore(storeName).getObject(key);
await txn.completed;
if (value is Uint8List) return value;
if (value is List<int>) return Uint8List.fromList(value);
// Missing locally: recover from the wrapper's disk copy (cleared profile).
return _readMirror(key);
}
Future<Uint8List?> _readMirror(String key) async {
try {
final response = await _client.get(_uriFor(key));
if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null;
return response.bodyBytes;
} on Exception {
return null;
}
}
Future<void> _writeMirror(String key, Uint8List bytes) async {
try {
await _client.post(_uriFor(key), body: bytes);
} on Exception {
// Best-effort by design, like the document mirror.
}
}
Uri _uriFor(String key) => Uri.parse('$baseUrl${WrapperPaths.blobs}$key');
}

View File

@ -1,49 +0,0 @@
/// File-backed [BlobStore] used on Android.
library;
import 'dart:io';
import 'dart:typed_data';
import 'package:diet_guard_app/services/blob_store.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
/// [BlobStore] keeping one file per photo under `<documents>/images/`.
///
/// Keys are absolute paths, exactly as they were before photos went through
/// a store, so an already-installed phone keeps resolving the `imagePath` of
/// every photo it has ever attached.
class FileBlobStore implements BlobStore {
/// Creates a store keeping its blobs under `<[documentsDirectory]>/images`.
FileBlobStore(this.documentsDirectory);
/// Directory whose `images/` subdirectory holds the photos.
final Directory documentsDirectory;
@override
Future<String> put(String extension, Uint8List bytes) async {
final imagesDir = Directory(p.join(documentsDirectory.path, 'images'));
await imagesDir.create(recursive: true);
final dest = File(p.join(imagesDir.path, '${const Uuid().v4()}$extension'));
await dest.writeAsBytes(bytes);
return dest.path;
}
@override
Future<Uint8List?> get(String key) async {
final file = File(key);
if (!file.existsSync()) return null;
try {
return await file.readAsBytes();
} on FileSystemException {
return null;
}
}
}
/// Opens the platform blob store (the app's documents directory).
// coverage:ignore-start
Future<BlobStore> openBlobStore() async =>
FileBlobStore(await getApplicationDocumentsDirectory());
// coverage:ignore-end

View File

@ -1,24 +0,0 @@
/// Opens the IndexedDB-backed [BlobStore] in the browser.
library;
import 'package:diet_guard_app/services/blob_store.dart';
import 'package:diet_guard_app/services/blob_store_indexeddb.dart';
import 'package:idb_shim/idb_browser.dart';
/// Opens (creating if needed) the IndexedDB-backed blob store.
// coverage:ignore-start
Future<BlobStore> openBlobStore() async {
final database = await idbFactoryBrowser.open(
IndexedDbBlobStore.databaseName,
version: 1,
onUpgradeNeeded: (event) {
final db = event.database;
if (!db.objectStoreNames.contains(IndexedDbBlobStore.storeName)) {
db.createObjectStore(IndexedDbBlobStore.storeName);
}
},
);
return IndexedDbBlobStore(database);
}
// coverage:ignore-end

View File

@ -0,0 +1,133 @@
/// Persistence for the effective-from budget history.
///
/// Shaped exactly like [AppSettingsService]: a singleton over the platform
/// [DocumentStore], with a static getter that degrades to
/// [BudgetSchedule.empty] when uninitialised so widget tests that never call
/// [init] still render.
///
/// Mirrors the storage half of `diet_guard/_budget_history.py`. Kept in its
/// own document rather than folded into `app_settings.json` so the two have
/// independent write paths and the settings schema is untouched.
library;
import 'dart:convert';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/document_store.dart';
import 'package:diet_guard_app/services/document_store_factory.dart';
import 'package:flutter/foundation.dart';
/// Singleton owning the budget history document.
class BudgetHistoryService {
BudgetHistoryService._(this._store);
/// Document name this service owns.
static const documentName = 'budget_history.json';
static BudgetHistoryService? _instance;
/// Returns the initialized singleton; throws if [init] was not called.
static BudgetHistoryService get instance => _instance!;
/// True when [init]/[initForTesting]/[resetForTesting] has run.
static bool get isInitialized => _instance != null;
/// Returns the stored schedule, or an empty one when uninitialised.
///
/// The fallback tracks the *current* goal rather than a fixed constant, so
/// a device whose history is empty classifies days the same way the PC does
/// (`_budget.current_schedule(default=daily_budget())`). A hardcoded 2200
/// here would make the two devices disagree about the same log.
static BudgetSchedule get schedule => BudgetSchedule(
_instance?._schedule.entries ?? const [],
fallback: AppSettingsService.dailyKcalGoal,
);
final DocumentStore _store;
BudgetSchedule _schedule = BudgetSchedule.empty;
/// Initialises the singleton against the platform document store.
static Future<BudgetHistoryService> init() async {
if (_instance != null) return _instance!;
// Resolving the platform store is a plugin call (path_provider /
// IndexedDB), not reachable from `flutter test`.
// coverage:ignore-start
final svc = BudgetHistoryService._(await openDocumentStore());
// coverage:ignore-end
await svc._load();
_instance = svc;
return svc;
}
/// Resets the singleton so [init] can be called again in tests.
@visibleForTesting
static void resetForTesting({DocumentStore? store}) {
_instance = store == null ? null : BudgetHistoryService._(store);
}
/// Initialises from [store], calling the loader, for use in unit tests.
@visibleForTesting
static Future<BudgetHistoryService> initForTesting(
DocumentStore store,
) async {
final svc = BudgetHistoryService._(store);
await svc._load();
_instance = svc;
return svc;
}
Future<void> _load() async {
final raw = await _store.read(documentName);
if (raw == null) return;
try {
_schedule = BudgetSchedule(BudgetSchedule.parse(jsonDecode(raw)));
} on Exception {
// Ignore parse errors: no history means "fall back to the current
// scalar budget", which is exactly the pre-history behaviour.
}
}
/// Grandfathers [kcal] to the beginning of time, if no history exists yet.
///
/// Does nothing when [editedAt] is null: that means this device has never
/// explicitly set a budget, so there is nothing of its own to grandfather
/// and the [kDefaultDailyKcalGoal] fallback correctly covers every day.
/// Also never overwrites a history already pulled from another device.
Future<void> seedIfEmpty(int kcal, DateTime? editedAt) async {
if (_schedule.entries.isNotEmpty || editedAt == null) return;
_schedule = BudgetSchedule(BudgetSchedule.seed(kcal, editedAt));
await _writeToDisk();
}
/// Records a budget edit, effective from the day it was made.
Future<void> recordChange(int kcal, {DateTime? when}) async {
_schedule = _schedule.upsert(kcal, when: when);
await _writeToDisk();
}
/// Replaces the stored history with a merge result from the sync layer.
///
/// An empty merge result is ignored rather than persisted: a pre-feature
/// peer contributes no `hist:` fields, and writing the empty document back
/// would discard this device's own history. Mirrors the same guard in
/// `_sync._sync_budget`.
Future<void> applyMerged(List<BudgetEntry> entries) async {
if (entries.isEmpty) return;
_schedule = BudgetSchedule(entries);
await _writeToDisk();
}
/// Writes the whole in-memory schedule to the store.
///
/// Deliberately *not* a read-modify-write. Edits arrive in bursts (a
/// debounced settings field, a calendar save, and a sync write-back can
/// overlap), and re-reading a date-keyed map per write would race and could
/// drop entries; serializing the full in-memory state cannot.
Future<void> _writeToDisk() async {
await _store.write(
documentName,
jsonEncode(BudgetSchedule.encode(_schedule.entries)),
);
}
}

View File

@ -0,0 +1,167 @@
/// Effective-from history of the daily kcal budget.
///
/// Pure mirror of `diet_guard/_budget_history.py` -- keep the two in sync, a
/// divergence means the PC and the phone classify the same day differently.
///
/// The budget is a single freely-editable number, but *classifying a past day*
/// must use the budget that applied on that day; otherwise lowering the budget
/// silently reclassifies months of history, breaking the adherence streak and
/// the year-to-date tally for days that were adherent at the time.
library;
import 'package:flutter/foundation.dart';
/// The budget assumed when nothing has ever been set on this device.
const int kDefaultDailyKcalGoal = 2200;
/// The effective-from date the migration seeds, so the pre-history budget
/// covers every day that was ever logged.
const String kEpochDay = '1970-01-01';
const String _epochIso = '1970-01-01T00:00:00.000Z';
const int _historyVersion = 1;
/// One budget value and the date it started applying.
@immutable
class BudgetEntry {
/// Creates a [BudgetEntry].
const BudgetEntry({
required this.effectiveFrom,
required this.kcal,
required this.editedAt,
});
/// Parses one stored entry, or null when it is malformed.
static BudgetEntry? tryFromJson(String day, Object? raw) {
if (raw is! Map) return null;
final kcal = raw['b'];
if (kcal is! int) return null;
final editedAt = raw['t'];
return BudgetEntry(
effectiveFrom: day,
kcal: kcal,
editedAt: editedAt is String ? editedAt : _epochIso,
);
}
/// `YYYY-MM-DD`; the first day this value applies to.
final String effectiveFrom;
/// The daily budget in kcal from [effectiveFrom] onward.
final int kcal;
/// Local ISO-8601 timestamp of the edit that created this entry, used to
/// derive a deterministic Hlc for the sync merge.
final String editedAt;
/// Serializes to the stored entry shape.
Map<String, dynamic> toJson() => {'b': kcal, 't': editedAt};
@override
bool operator ==(Object other) =>
other is BudgetEntry &&
other.effectiveFrom == effectiveFrom &&
other.kcal == kcal &&
other.editedAt == editedAt;
@override
int get hashCode => Object.hash(effectiveFrom, kcal, editedAt);
@override
String toString() => 'BudgetEntry($effectiveFrom, $kcal, $editedAt)';
}
/// The budget as a function of the day, with a fallback for empty history.
class BudgetSchedule {
/// Creates a [BudgetSchedule] from [entries], ascending by effective-from.
const BudgetSchedule(this.entries, {this.fallback = kDefaultDailyKcalGoal});
/// A schedule with no history at all; every day resolves to [fallback].
static const empty = BudgetSchedule(<BudgetEntry>[]);
/// Ascending by [BudgetEntry.effectiveFrom].
final List<BudgetEntry> entries;
/// Used when no entry applies -- an unset device, or a day before the
/// earliest entry. The second case does not arise in practice, since the
/// migration always seeds [kEpochDay].
final int fallback;
/// Returns the budget that applied on [day] (a `YYYY-MM-DD` key).
int forDay(String day) {
var result = fallback;
for (final entry in entries) {
if (entry.effectiveFrom.compareTo(day) > 0) break;
result = entry.kcal;
}
return result;
}
/// Returns the budget in force today.
int get current => forDay(_dateKey(DateTime.now()));
/// Returns this schedule with [when]'s date set to [kcal].
///
/// Keyed on the date, so editing the budget twice in one day replaces that
/// day's entry rather than accumulating duplicates.
BudgetSchedule upsert(int kcal, {DateTime? when}) {
final moment = when ?? DateTime.now();
final day = _dateKey(moment);
final kept = entries.where((e) => e.effectiveFrom != day).toList()
..add(
BudgetEntry(
effectiveFrom: day,
kcal: kcal,
editedAt: moment.toIso8601String(),
),
)
..sort((a, b) => a.effectiveFrom.compareTo(b.effectiveFrom));
return BudgetSchedule(kept, fallback: fallback);
}
/// Parses a stored history document, tolerating anything malformed.
///
/// A corrupt or unrecognised history degrades to "no history", which
/// callers already handle by falling back to the current scalar budget.
static List<BudgetEntry> parse(Object? raw) {
if (raw is! Map || raw['v'] != _historyVersion) return const [];
final stored = raw['e'];
if (stored is! Map) return const [];
final entries = <BudgetEntry>[];
for (final day in stored.keys) {
final entry = BudgetEntry.tryFromJson('$day', stored[day]);
if (entry != null) entries.add(entry);
}
entries.sort((a, b) => a.effectiveFrom.compareTo(b.effectiveFrom));
return entries;
}
/// Serializes [entries] to the stored document shape.
static Map<String, dynamic> encode(List<BudgetEntry> entries) => {
'v': _historyVersion,
'e': {for (final e in entries) e.effectiveFrom: e.toJson()},
};
/// Returns the one-entry history that grandfathers an existing budget.
///
/// The pre-history budget is treated as having applied since [kEpochDay],
/// so every already-logged day keeps the value it was actually judged
/// against. Reuses the caller's own [editedAt] rather than "now", which is
/// what lets two devices seed independently and still converge on the same
/// value at the same Hlc wall time.
static List<BudgetEntry> seed(int kcal, DateTime? editedAt) => [
BudgetEntry(
effectiveFrom: kEpochDay,
kcal: kcal,
editedAt: editedAt?.toIso8601String() ?? _epochIso,
),
];
}
String _dateKey(DateTime d) {
final y = d.year.toString().padLeft(4, '0');
final m = d.month.toString().padLeft(2, '0');
final day = d.day.toString().padLeft(2, '0');
return '$y-$m-$day';
}

View File

@ -19,6 +19,7 @@ library;
import 'package:diet_guard_app/models/day_status.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
/// The multiplier defining the top of the "yellow" band above budget: a 20%
@ -49,6 +50,24 @@ double sumKcal(Iterable<FoodEntry> entries) {
return total;
}
/// Returns the summed protein/carbs/fat across [entries].
///
/// The macro counterpart to [sumKcal], so the three folds live in one place
/// rather than being re-inlined per caller.
({double proteinG, double carbsG, double fatG}) sumMacros(
Iterable<FoodEntry> entries,
) {
var proteinG = 0.0;
var carbsG = 0.0;
var fatG = 0.0;
for (final entry in entries) {
proteinG += entry.proteinG;
carbsG += entry.carbsG;
fatG += entry.fatG;
}
return (proteinG: proteinG, carbsG: carbsG, fatG: fatG);
}
/// Returns [log]'s entries for [day] with tombstoned (deleted) ones
/// removed -- `readLog()` returns the raw log including tombstones, so
/// every consumer that classifies a day (rather than just displaying an
@ -77,10 +96,18 @@ DayStatus dayStatus(DayLog log, String day, int budget) {
/// Returns a [DayStatus] for every date key present in [log].
///
/// Each day is judged against the budget that applied *on that day*, so a
/// later budget change leaves earlier days classified exactly as before.
///
/// Days with no log entries are simply absent from the result; a caller
/// rendering a full calendar treats a missing key as [DayStatus.notLogged].
Map<String, DayStatus> statusMap(DayLog log, {required int budget}) {
return {for (final day in log.keys) day: dayStatus(log, day, budget)};
Map<String, DayStatus> statusMap(
DayLog log, {
required BudgetSchedule schedule,
}) {
return {
for (final day in log.keys) day: dayStatus(log, day, schedule.forDay(day)),
};
}
DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);

View File

@ -4,7 +4,7 @@ library;
/// The fixed port the desktop wrapper serves on.
///
/// **Do not change this casually.** IndexedDB (the food log, the food bank,
/// the settings and the photo blobs) is keyed by origin, so a different port
/// the settings) is keyed by origin, so a different port
/// looks like a different app with no history at all. The launcher, the
/// packaging and `bin/diet_guard_desktop.dart` must all use this value, and
/// the Chrome `--user-data-dir` must stay equally fixed for the same reason.
@ -24,9 +24,6 @@ abstract final class WrapperPaths {
/// Prefix for document reads/writes: `/documents/<name>`.
static const documents = '/documents/';
/// Prefix for photo-blob reads/writes: `/blobs/<key>`.
static const blobs = '/blobs/';
/// Prefix for the GitHub proxy (see `wrapper_server.dart`).
static const github = '/github/';
}

View File

@ -10,18 +10,65 @@
library;
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/services/background_sync_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/notification_service.dart';
import 'package:http/http.dart' as http;
/// Reads the local log, computes today's due-but-unlogged slots as of
/// [now] (defaults to the real clock), and syncs notifications to match.
///
/// Pulls from GitHub *only* when a slot looks due, then re-checks -- the
/// local log alone cannot see a meal logged on the PC, so without this the
/// phone re-fires "you haven't logged your 12:00 meal" every background tick
/// until the app is next opened. Deliberately mirrors `_cli_gate._should_lock`
/// on the Python side: cheap local check first, network only when it would
/// change the answer, then decide on the merged result.
///
/// Set [pullWhenDue] false when the caller has just written to the local log
/// itself (the log screen). There the local copy is by definition the freshest
/// thing in existence, so a pull would only duplicate the screen's own
/// auto-sync and delay dismissing the notification.
///
/// [now] is injectable so a test does not depend on the wall-clock hour it
/// happens to run at, the same reason `slot.dart`'s functions are clock-free.
Future<void> checkAndNotify({DateTime? now}) async {
/// [httpClient] is injectable for the same reason [backgroundSyncPush] takes
/// one: the real plugin path only exists in a background isolate on-device.
Future<void> checkAndNotify({
DateTime? now,
http.Client? httpClient,
bool pullWhenDue = true,
}) async {
await LogStorageService.init();
await NotificationService.init();
final logged = await LogStorageService.instance.loggedSlotsToday();
final due = missingSlots(now ?? DateTime.now(), logged);
await NotificationService.instance.syncToSlots(due);
final at = now ?? DateTime.now();
var due = missingSlots(
at,
await LogStorageService.instance.loggedSlotsToday(),
);
if (pullWhenDue && due.isNotEmpty) {
// Reuses backgroundSyncPush's settings/isConfigured/try-catch guard rather
// than reimplementing it: that path is already proven to work from a
// WorkManager isolate, including the token read and singleton init.
await backgroundSyncPush(httpClient: httpClient);
due = missingSlots(at, await LogStorageService.instance.loggedSlotsToday());
}
await _reconcileNotifications(due);
}
/// Posts/cancels notifications for [due], swallowing platform failures.
///
/// Best-effort by design, and for both callers: from the log screen the meal
/// is already written by this point and must not be lost to a notification
/// hiccup, and from the WorkManager tick a throw would put the task into a
/// retry-with-backoff loop over something purely cosmetic. `on Object`
/// rather than `on Exception` because `flutter_local_notifications` reports
/// an absent platform channel as a `LateInitializationError`, which is an
/// `Error`.
Future<void> _reconcileNotifications(List<int> due) async {
try {
await NotificationService.init();
await NotificationService.instance.syncToSlots(due);
} on Object {
// Notifications are a nicety; nothing above depends on them.
}
}

View File

@ -220,10 +220,29 @@ class FoodBankService {
/// previous entry.
Future<void> addManualEntry(FoodBankRecord record) async {
final bank = await _readManualBank();
bank[_normalize(record.desc)] = record;
bank[_normalize(record.desc)] = FoodBankRecord(
desc: record.desc,
kcal: record.kcal,
proteinG: record.proteinG,
carbsG: record.carbsG,
fatG: record.fatG,
grams: record.grams,
count: record.count,
components: record.components,
// Stamped here, not by callers: this is what the cross-device merge
// orders by, so it must be set on every write without exception.
editedAt: DateTime.now().toIso8601String(),
);
await _writeManualBank(bank);
}
/// Returns the hand-curated bank, for the sync layer.
Future<Map<String, FoodBankRecord>> readManualBank() => _readManualBank();
/// Replaces the hand-curated bank with a merge result from the sync layer.
Future<void> applyMergedManualBank(Map<String, FoodBankRecord> bank) =>
_writeManualBank(bank);
/// All known food records: log-derived entries merged with manually-added
/// ones, sorted by count descending.
///

View File

@ -106,7 +106,6 @@ class LogStorageService {
Nutrition nutrition, {
int? slot,
List<MealComponent>? components,
String? imagePath,
}) async {
final now = DateTime.now();
final entry = FoodEntry(
@ -121,7 +120,6 @@ class LogStorageService {
source: nutrition.source,
slot: slot,
components: components,
imagePath: imagePath,
);
final log = await readLog();
log.putIfAbsent(localDateKey(now), () => []).add(entry);
@ -231,15 +229,4 @@ class LogStorageService {
final entries = await todayEntries();
return entries.where((e) => e.slot != null).map((e) => e.slot!).toSet();
}
/// Returns the single most recently logged, non-deleted entry across all
/// days, or `null` if nothing has ever been logged.
///
/// Backs the "repeat last meal" one-tap action -- deliberately not scoped
/// to today or to the currently-due slot, since the point is "log the same
/// thing I just ate," which should work identically at any time of day.
Future<FoodEntry?> lastLoggedEntry() async {
final entries = await allEntriesNewestFirst();
return entries.isEmpty ? null : entries.first;
}
}

View File

@ -1,56 +0,0 @@
/// Picks a photo and copies it into permanent app-local storage.
library;
import 'package:diet_guard_app/services/blob_store.dart';
import 'package:diet_guard_app/services/blob_store_factory.dart';
import 'package:flutter/foundation.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path/path.dart' as p;
/// Wraps [ImagePicker] and persists the result in a [BlobStore], so the
/// returned key survives after the picker's own (possibly cache-cleared)
/// temp file is gone.
///
/// Photos are device-local only: per the sync plan (Milestone 3), a logged
/// entry's `imagePath` is stripped before push and never read from a pulled
/// remote copy, so nothing here needs to be sync-aware. That also means a
/// photo attached on the desktop is never visible on the phone, and vice
/// versa -- by design, not by omission.
class PhotoAttachService {
PhotoAttachService._(this._store);
static PhotoAttachService _instance = PhotoAttachService._(null);
/// The singleton instance.
static PhotoAttachService get instance => _instance;
BlobStore? _store;
/// Redirects where picked photos are stored, so a test never touches the
/// real store. Pass null to restore default behavior.
@visibleForTesting
static void resetForTesting({BlobStore? store}) {
_instance = PhotoAttachService._(store);
}
Future<BlobStore> _resolveStore() async => _store ??= await openBlobStore();
/// Opens [source] (camera or gallery) and stores the picked image.
///
/// Returns the new blob key -- an absolute path on Android, an IndexedDB
/// key in the browser -- or null if the user cancelled the picker.
Future<String?> pickAndStore(ImageSource source) async {
final picked = await ImagePicker().pickImage(source: source);
if (picked == null) return null;
final store = await _resolveStore();
// `readAsBytes` rather than a file copy: the browser's picker hands back
// a blob URL with no filesystem behind it.
return store.put(p.extension(picked.path), await picked.readAsBytes());
}
/// Returns the bytes stored under [key], or null when the photo is gone.
///
/// Backs the web image widget, which cannot render from a path.
Future<Uint8List?> readBytes(String key) async =>
(await _resolveStore()).get(key);
}

View File

@ -8,10 +8,9 @@
/// this way uses.
///
/// Each [FoodEntry] maps to one [Record] with a single opaque `body` field
/// holding [FoodEntry.toSyncJson] (already excludes `imagePath` and `hmac`
/// -- neither belongs in the synced Record: `imagePath` is phone-local and
/// reattached by `sync_service.dart` as a separate post-merge step; `hmac`
/// is never computed here, since the phone never holds the shared key).
/// holding [FoodEntry.toSyncJson] (which excludes `hmac`: it is never
/// computed here, since the phone never holds the shared key, and the PC
/// re-signs every entry on merge regardless of origin).
/// Entries are immutable after creation (only `deleted` ever changes
/// post-write), so there is no benefit to crdt_sync's per-field LWW
/// granularity here -- the whole body shares one derived [Hlc].
@ -34,7 +33,9 @@ import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crypto/crypto.dart';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
const _syncDeviceId = 'phone';
@ -186,6 +187,141 @@ String encodeLogForPush(Log log) {
return jsonEncode(encoded);
}
/// Converts the log-derived food bank into a crdt_sync [Log].
///
/// The bank is *derived* -- both devices replay the same synced log and
/// compute the same records -- so this exists to make them agree
/// **immediately** rather than only after each has replayed.
///
/// The clock is the record's own `count`, not a wall time, so
/// last-writer-wins means *max-count-wins*: the device that has seen more of
/// the log has the higher count, and re-merging is idempotent because the
/// count does not move unless the log did. Mirrors
/// `_sync_merge.food_bank_to_log`.
Log foodBankToLog(Map<String, FoodBankRecord> bank) {
final log = <String, Record>{};
for (final mapEntry in bank.entries) {
log[mapEntry.key] = Record(
id: mapEntry.key,
fields: {
'body': (
mapEntry.value.toJson(),
Hlc.newTick(
_syncDeviceId,
wallTimeMsOverride: mapEntry.value.count.toInt(),
),
),
},
);
}
return log;
}
/// Converts a merged food-bank [Log] back into bank shape.
Map<String, FoodBankRecord> logToFoodBank(Log log) {
final bank = <String, FoodBankRecord>{};
for (final mapEntry in log.entries) {
if (mapEntry.value.deleted) continue;
final body = mapEntry.value.fields['body']?.$1;
if (body is! Map) continue;
bank[mapEntry.key] = FoodBankRecord.fromJson(
Map<String, dynamic>.from(body.cast<String, dynamic>()),
);
}
return bank;
}
/// Parses one device's pushed `food_bank.json` into a crdt_sync [Log].
Log parseRemoteFoodBank(String text) {
final raw = jsonDecode(text);
if (raw is! Map) {
throw const FormatException(
'top-level food-bank payload is not a JSON object',
);
}
return raw.cast<String, dynamic>().map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
}
/// Serializes a merged food-bank [Log] for push.
String encodeFoodBankForPush(Log log) => jsonEncode({
for (final entry in log.entries) entry.key: entry.value.toJson(),
});
/// Converts the hand-curated food bank into a crdt_sync [Log].
///
/// One record per normalized food name, carrying the whole bank record as an
/// opaque `body` -- the same shape food-log entries use. Unlike an entry a
/// curated food is editable, so its [Hlc] comes from the record's own
/// `editedAt` stamp rather than a fixed birth time. Mirrors
/// `_sync_merge.manual_bank_to_log`.
Log manualBankToLog(Map<String, FoodBankRecord> bank) {
final log = <String, Record>{};
for (final mapEntry in bank.entries) {
final wallTimeMs =
DateTime.tryParse(
mapEntry.value.editedAt ?? '',
)?.toUtc().millisecondsSinceEpoch ??
0;
final body = Map<String, dynamic>.from(mapEntry.value.toJson())
..remove('t');
log[mapEntry.key] = Record(
id: mapEntry.key,
fields: {
'body': (
body,
Hlc.newTick(_syncDeviceId, wallTimeMsOverride: wallTimeMs),
),
},
);
}
return log;
}
/// Converts a merged curated-bank [Log] back into bank shape.
///
/// Each record's `editedAt` is reconstructed from its field [Hlc], so the
/// stored stamp and the clock the merge compared can never drift apart.
/// Mirrors `_sync_merge.log_to_manual_bank`.
Map<String, FoodBankRecord> logToManualBank(Log log) {
final bank = <String, FoodBankRecord>{};
for (final mapEntry in log.entries) {
if (mapEntry.value.deleted) continue;
final field = mapEntry.value.fields['body'];
final body = field?.$1;
if (body is! Map) continue;
final json = Map<String, dynamic>.from(body.cast<String, dynamic>());
final hlc = field?.$2;
if (hlc != null) {
json['t'] = DateTime.fromMillisecondsSinceEpoch(
hlc.wallTimeMs,
isUtc: true,
).toLocal().toIso8601String();
}
bank[mapEntry.key] = FoodBankRecord.fromJson(json);
}
return bank;
}
/// Parses one device's pushed curated-bank text into a crdt_sync [Log].
Log parseRemoteManualBank(String text) {
final raw = jsonDecode(text);
if (raw is! Map) {
throw const FormatException(
'top-level curated-bank payload is not a JSON object',
);
}
return raw.cast<String, dynamic>().map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
}
/// Serializes a merged curated-bank [Log] for push.
String encodeManualBankForPush(Log log) => jsonEncode({
for (final entry in log.entries) entry.key: entry.value.toJson(),
});
/// Stable id: exactly one budget record per device-pushed `budget.json`.
const budgetRecordId = 'budget';
@ -205,18 +341,88 @@ Hlc budgetHlc(Map<String, dynamic> record) {
return Hlc.newTick(_syncDeviceId, wallTimeMsOverride: wallTimeMs);
}
/// Converts a raw local/remote budget record into a single-record [Log].
/// Field-name prefix for the effective-from budget history, one field per
/// date. A separate *field* rather than a separate document, because
/// `mergeRecord` unions field names -- see [budgetToLog].
const budgetHistoryFieldPrefix = 'hist:';
/// Derives a deterministic [Hlc] for one history entry from its edit time.
///
/// Same trick as [budgetHlc]: identical inputs always yield the same clock,
/// so re-syncing unchanged history is a no-op. Two devices that seed the
/// history independently derive the same wall time and the same value and
/// differ only in node id, so whichever side wins the field-level LWW, the
/// value is identical and the merge converges in one round.
Hlc historyHlc(BudgetEntry entry) {
final wallTimeMs =
DateTime.tryParse(entry.editedAt)?.toUtc().millisecondsSinceEpoch ?? 0;
return Hlc.newTick(_syncDeviceId, wallTimeMsOverride: wallTimeMs);
}
/// Converts a raw local budget record plus its history into a [Log].
///
/// Returns an empty [Log] when [record] is null (this device has never
/// explicitly set a budget), so it contributes nothing to the merge rather
/// than clobbering another device's real value with the unset default.
Log budgetToLog(Map<String, dynamic>? record) {
///
/// The history rides along as one `hist:<YYYY-MM-DD>` field per entry on the
/// *same* record. crdt_sync's `mergeRecord` is per-field LWW over the union
/// of field names, so a device that predates the history neither clobbers
/// those fields nor blocks them: it merges them in from the remote and
/// pushes them straight back out (both sides push the *merged* log). That is
/// what makes this safe to roll out without a coordinated release.
///
/// `w` (body weight) is stripped from `value` for the same reason the
/// history is not stored there: inside the shared map it was collateral
/// damage of whole-map LWW, since this device rebuilds that map without it.
/// Python carries it as its own `weight` field instead, and this device
/// relays that field untouched through the merged log even though it has no
/// weight of its own to contribute -- so both devices still see one value.
Log budgetToLog(
Map<String, dynamic>? record, [
List<BudgetEntry> entries = const [],
]) {
if (record == null) return {};
final hlc = budgetHlc(record);
final value = Map<String, dynamic>.from(record)..remove('t');
return {
budgetRecordId: Record(id: budgetRecordId, fields: {'value': (value, hlc)}),
};
final value = Map<String, dynamic>.from(record)
..remove('t')
..remove('w');
final fields = <String, (dynamic, Hlc)>{'value': (value, hlc)};
for (final entry in entries) {
fields['$budgetHistoryFieldPrefix${entry.effectiveFrom}'] = (
entry.kcal,
historyHlc(entry),
);
}
return {budgetRecordId: Record(id: budgetRecordId, fields: fields)};
}
/// Extracts the effective-from history from a merged budget [Log].
///
/// Each entry's `editedAt` is reconstructed from its field Hlc rather than
/// carried separately, so the stored timestamp and the clock the merge
/// compared can never drift apart.
List<BudgetEntry> logToHistory(Log log) {
final record = log[budgetRecordId];
if (record == null) return const [];
final entries = <BudgetEntry>[];
for (final field in record.fields.entries) {
if (!field.key.startsWith(budgetHistoryFieldPrefix)) continue;
final kcal = field.value.$1;
if (kcal is! int) continue;
entries.add(
BudgetEntry(
effectiveFrom: field.key.substring(budgetHistoryFieldPrefix.length),
kcal: kcal,
editedAt: DateTime.fromMillisecondsSinceEpoch(
field.value.$2.wallTimeMs,
isUtc: true,
).toLocal().toIso8601String(),
),
);
}
entries.sort((a, b) => a.effectiveFrom.compareTo(b.effectiveFrom));
return entries;
}
/// Converts a merged budget [Log] back into a raw budget record.

View File

@ -4,11 +4,7 @@
/// crdt_sync's shared transport ([GitHubClient]/[syncLog]), merges with the
/// local log via crdt_sync's shared CRDT scheme (`sync_merge.dart` adapts
/// [FoodEntry]s to/from [Record]), rebuilds the food bank, and pushes this
/// device's own merged log back up in the new Record-based wire format. One
/// phone-specific step, with no PC-side equivalent: a pulled copy of an
/// entry never carries `imagePath` (stripped before push, meaningless on
/// another device), so it must not null out a local photo attachment for
/// the same `id` (plan decision 10).
/// device's own merged log back up in the new Record-based wire format.
///
/// The daily budget syncs alongside the food log in the same tick (see
/// [_syncBudget]): a sibling `budget.json` per device, merged
@ -19,6 +15,7 @@ library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/github_client_factory.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
@ -26,7 +23,7 @@ import 'package:diet_guard_app/services/sync_merge.dart';
const _devicesDir = 'diet-guard-sync/devices';
/// Runs one full sync tick: pull, merge, preserve photos, persist, push.
/// Runs one full sync tick: pull, merge, persist, push.
///
/// Returns the merged log as it now sits on disk locally. Propagates any
/// [GitHubSyncError] from the client for the caller (auto-sync / the manual
@ -34,7 +31,6 @@ const _devicesDir = 'diet-guard-sync/devices';
Future<DayLog> runSync(GitHubClient client) async {
final logService = LogStorageService.instance;
final local = await logService.readLog();
final localImagePaths = _imagePathsById(local);
final mergedLog = await syncLog(
client: client,
@ -47,15 +43,56 @@ Future<DayLog> runSync(GitHubClient client) async {
commitMessage: 'diet_guard_app sync',
);
var merged = logToDayLog(mergedLog);
merged = _preserveLocalImagePaths(merged, localImagePaths);
final merged = logToDayLog(mergedLog);
await logService.writeLog(merged);
await FoodBankService.instance.rebuildAndPersist(merged);
await _syncBudget(client);
await _syncFoodBank(client);
await _syncManualBank(client);
return merged;
}
/// Pulls, merges, persists and pushes the log-derived food bank.
///
/// Runs after the local rebuild above, so this device's records already
/// reflect the merged log; the merge then unions in whatever another device
/// knows, max-count winning per food. Mirrors `_sync._sync_food_bank`.
Future<void> _syncFoodBank(GitHubClient client) async {
final merged = await syncLog(
client: client,
deviceId: syncDeviceId,
pathPrefix: _devicesDir,
localLog: foodBankToLog(await FoodBankService.instance.readBank()),
encode: encodeFoodBankForPush,
decode: parseRemoteFoodBank,
filename: 'food_bank.json',
commitMessage: 'diet_guard_app sync',
);
await FoodBankService.instance.writeBank(logToFoodBank(merged));
}
/// Pulls, merges, persists and pushes the hand-curated food bank.
///
/// Curated entries are the one part of the bank that is not derivable from
/// the food log, so unlike `food_bank.json` they need a real merge:
/// last-writer-wins per food name by edit time, union across devices. Mirrors
/// `_sync._sync_manual_bank`.
Future<void> _syncManualBank(GitHubClient client) async {
final local = await FoodBankService.instance.readManualBank();
final merged = await syncLog(
client: client,
deviceId: syncDeviceId,
pathPrefix: _devicesDir,
localLog: manualBankToLog(local),
encode: encodeManualBankForPush,
decode: parseRemoteManualBank,
filename: 'food_bank_manual.json',
commitMessage: 'diet_guard_app sync',
);
await FoodBankService.instance.applyMergedManualBank(logToManualBank(merged));
}
/// Pulls other devices' budgets, merges, applies the winner locally, pushes.
///
/// Reuses [syncLog] (it always pushes the merged result, even an empty
@ -79,13 +116,22 @@ Future<void> _syncBudget(GitHubClient client) async {
client: client,
deviceId: syncDeviceId,
pathPrefix: _devicesDir,
localLog: budgetToLog(localRecord),
localLog: budgetToLog(
localRecord,
BudgetHistoryService.schedule.entries,
),
encode: encodeBudgetForPush,
decode: parseRemoteBudget,
filename: 'budget.json',
commitMessage: 'diet_guard_app sync',
);
// The history merges by field union, so it is applied unconditionally --
// independently of whether the scalar budget resolved to anything.
await BudgetHistoryService.instance.applyMerged(
logToHistory(mergedBudgetLog),
);
final merged = logToBudget(mergedBudgetLog);
final mergedKcal = merged?['b'];
if (merged == null || mergedKcal is! int) return;
@ -94,39 +140,3 @@ Future<void> _syncBudget(GitHubClient client) async {
updatedAt: DateTime.tryParse(merged['t'] as String? ?? ''),
);
}
Map<String, String> _imagePathsById(DayLog log) {
final result = <String, String>{};
for (final entries in log.values) {
for (final entry in entries) {
final id = entry.id;
final imagePath = entry.imagePath;
if (id != null && imagePath != null) result[id] = imagePath;
}
}
return result;
}
DayLog _preserveLocalImagePaths(
DayLog log,
Map<String, String> imagePathsById,
) {
if (imagePathsById.isEmpty) return log;
return {
for (final mapEntry in log.entries)
mapEntry.key: [
for (final entry in mapEntry.value)
_withPreservedImagePath(entry, imagePathsById),
],
};
}
FoodEntry _withPreservedImagePath(
FoodEntry entry,
Map<String, String> imagePathsById,
) {
if (entry.imagePath != null) return entry;
final preserved = imagePathsById[entry.id];
if (preserved == null) return entry;
return entry.copyWithImagePath(preserved);
}

View File

@ -1,10 +0,0 @@
/// Platform entry point for rendering an attached meal photo.
///
/// Conditional export because the two platforms resolve a photo differently:
/// Android has a real file at the stored path, while the browser-hosted
/// desktop app has to read the bytes back out of IndexedDB before it can show
/// anything (`Image.file` is a `dart:io` stub there and would throw).
library;
export 'attached_image_io.dart'
if (dart.library.js_interop) 'attached_image_web.dart';

View File

@ -1,58 +0,0 @@
/// File-backed attached-photo widget (Android).
library;
import 'dart:io';
import 'package:flutter/material.dart';
/// Renders the photo stored under [path], falling back to a broken-image
/// icon when it cannot be read.
class AttachedImage extends StatelessWidget {
/// Creates an [AttachedImage] for the blob key [path].
const AttachedImage({
required this.path,
this.width,
this.height,
this.fit,
this.errorIconSize,
this.errorIconColor,
super.key,
});
/// Blob key of the photo -- an absolute file path on this platform.
final String path;
/// Optional fixed width; null renders at the image's own size.
final double? width;
/// Optional fixed height; null renders at the image's own size.
final double? height;
/// How to inscribe the image into its box.
final BoxFit? fit;
/// Size of the fallback broken-image icon.
final double? errorIconSize;
/// Colour of the fallback broken-image icon.
final Color? errorIconColor;
@override
Widget build(BuildContext context) {
return Image.file(
File(path),
width: width,
height: height,
fit: fit,
errorBuilder: (context, error, stackTrace) => SizedBox(
width: width,
height: height,
child: Icon(
Icons.broken_image,
size: errorIconSize,
color: errorIconColor,
),
),
);
}
}

View File

@ -1,92 +0,0 @@
/// IndexedDB-backed attached-photo widget (desktop web build).
library;
import 'dart:typed_data';
import 'package:diet_guard_app/services/photo_attach_service.dart';
import 'package:flutter/material.dart';
/// Renders the photo stored under [path], falling back to a broken-image
/// icon when it cannot be read.
///
/// Unlike the Android build this is inherently asynchronous: the bytes come
/// from IndexedDB (or, for a cleared profile, the desktop wrapper's disk
/// mirror), not from a path the rasteriser can open itself.
class AttachedImage extends StatefulWidget {
/// Creates an [AttachedImage] for the blob key [path].
const AttachedImage({
required this.path,
this.width,
this.height,
this.fit,
this.errorIconSize,
this.errorIconColor,
super.key,
});
/// Blob key of the photo -- an IndexedDB key on this platform.
final String path;
/// Optional fixed width; null renders at the image's own size.
final double? width;
/// Optional fixed height; null renders at the image's own size.
final double? height;
/// How to inscribe the image into its box.
final BoxFit? fit;
/// Size of the fallback broken-image icon.
final double? errorIconSize;
/// Colour of the fallback broken-image icon.
final Color? errorIconColor;
@override
State<AttachedImage> createState() => _AttachedImageState();
}
class _AttachedImageState extends State<AttachedImage> {
late Future<Uint8List?> _bytes = _load();
Future<Uint8List?> _load() => PhotoAttachService.instance.readBytes(
widget.path,
);
@override
void didUpdateWidget(AttachedImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.path != widget.path) _bytes = _load();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: _bytes,
builder: (context, snapshot) {
final bytes = snapshot.data;
if (bytes == null) {
// Covers both "still loading" and "gone": a spinner that flashes
// for one frame per thumbnail would be worse than an empty box.
return SizedBox(
width: widget.width,
height: widget.height,
child: snapshot.connectionState == ConnectionState.done
? Icon(
Icons.broken_image,
size: widget.errorIconSize,
color: widget.errorIconColor,
)
: null,
);
}
return Image.memory(
bytes,
width: widget.width,
height: widget.height,
fit: widget.fit,
);
},
);
}
}

View File

@ -1,138 +0,0 @@
/// Shared attach/preview/remove control for a meal entry's optional photo.
library;
import 'package:diet_guard_app/screens/photo_viewer_screen.dart';
import 'package:diet_guard_app/services/photo_attach_service.dart';
import 'package:diet_guard_app/widgets/attached_image.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
/// Shows an "Attach photo" button when [imagePath] is null, or a tappable
/// thumbnail (opens [PhotoViewerScreen] full-screen) plus a "Remove photo"
/// action once one is set.
///
/// Used identically by the single-item and composite-meal logging screens,
/// so the attach/preview/remove behavior only needs to be implemented once.
class PhotoAttachField extends StatelessWidget {
/// Creates a [PhotoAttachField].
const PhotoAttachField({
required this.imagePath,
required this.onChanged,
this.compact = false,
super.key,
});
/// The currently attached photo's local path, or null if none.
final String? imagePath;
/// Called with the new path after a successful pick, or null after the
/// user removes the current photo.
final ValueChanged<String?> onChanged;
/// Whether to render an icon-only button and a small thumbnail badge
/// instead of the default text button and 64x64 preview.
final bool compact;
Future<void> _attach(BuildContext context) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// No camera row on web: the browser's picker is a file input,
// and `ImageSource.camera` silently falls back to the same file
// dialog -- an option that lies about what it does is worse than
// no option.
if (!kIsWeb)
ListTile(
leading: const Icon(Icons.photo_camera),
title: const Text('Take a photo'),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text(
kIsWeb ? 'Choose a file' : 'Choose from gallery',
),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
final path = await PhotoAttachService.instance.pickAndStore(source);
if (path != null) onChanged(path);
}
@override
Widget build(BuildContext context) {
final path = imagePath;
if (path == null) {
if (compact) {
return Tooltip(
message: 'Attach photo',
child: IconButton(
onPressed: () => _attach(context),
icon: const Icon(Icons.add_a_photo),
),
);
}
return OutlinedButton.icon(
onPressed: () => _attach(context),
icon: const Icon(Icons.add_a_photo),
label: const Text('Attach photo'),
);
}
final thumbnailSize = compact ? 32.0 : 64.0;
final thumbnail = GestureDetector(
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => PhotoViewerScreen(path: path)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: AttachedImage(
path: path,
width: thumbnailSize,
height: thumbnailSize,
fit: BoxFit.cover,
),
),
);
if (compact) {
return Stack(
clipBehavior: Clip.none,
children: [
thumbnail,
Positioned(
top: -6,
right: -6,
child: Tooltip(
message: 'Remove photo',
child: InkWell(
onTap: () => onChanged(null),
customBorder: const CircleBorder(),
child: const CircleAvatar(
radius: 9,
child: Icon(Icons.close, size: 12),
),
),
),
),
],
);
}
return Row(
children: [
thumbnail,
const SizedBox(width: 8),
TextButton(
onPressed: () => onChanged(null),
child: const Text('Remove photo'),
),
],
);
}
}

View File

@ -0,0 +1,142 @@
/// Post-log feedback card for the log screen: what was just logged, plus
/// today's standing against the daily budget.
library;
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// Everything [TodayProgressCard] renders, computed by the caller.
///
/// A plain value object rather than a service lookup so the card stays a
/// pure function of its inputs -- the log screen already holds the freshly
/// written log when it builds one of these, so a second read would be both
/// redundant and a chance to race.
@immutable
class TodayProgress {
/// Creates a [TodayProgress].
const TodayProgress({
required this.justLogged,
required this.consumedKcal,
required this.budgetKcal,
required this.proteinG,
required this.carbsG,
required this.fatG,
required this.adherenceStreak,
});
/// Description of the meal that was just logged.
final String justLogged;
/// Today's total calories, including the meal just logged.
final double consumedKcal;
/// The budget in effect *today* (not the schedule -- the card is only ever
/// about the current day).
final int budgetKcal;
/// Today's total protein, grams.
final double proteinG;
/// Today's total carbohydrate, grams.
final double carbsG;
/// Today's total fat, grams.
final double fatG;
/// Consecutive-day budget-adherence streak, as of today.
final int adherenceStreak;
/// Calories still available today; negative once over budget.
double get remainingKcal => budgetKcal - consumedKcal;
/// Whether today's total has passed the budget.
bool get isOverBudget => remainingKcal < 0;
}
/// Shows the just-logged meal and today's running budget position.
///
/// Replaces the old one-tap-reward prompt: the same slot in the log screen,
/// but showing information the user actually acts on instead of an
/// externally-configured URL. Deliberately has no protein *target* -- that
/// derives from body weight, which only the PC stores (see
/// `diet_guard/_budget.py`'s `protein_target_g`), so the card reports
/// protein consumed and leaves targets to the gate's dashboard.
class TodayProgressCard extends StatelessWidget {
/// Creates a [TodayProgressCard].
const TodayProgressCard({required this.progress, super.key});
/// The values to display.
final TodayProgress progress;
static String _plural(int count) => count == 1 ? 'day' : 'days';
static String _grams(double value) => '${value.round()}g';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final remaining = progress.remainingKcal.abs().round();
final remainingLabel = progress.isOverBudget
? '$remaining over'
: '$remaining left';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
// Fill-only elevation: the shared design system forbids shadows in
// dark UI, and mixing a border with a fill on the same surface.
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Logged "${progress.justLogged}".',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.sm),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'${progress.consumedKcal.round()} / ${progress.budgetKcal}',
style: theme.textTheme.titleMedium?.copyWith(
color: progress.isOverBudget
? colors.error
: colors.onSurface,
),
),
const SizedBox(width: AppSpacing.xs),
Text('kcal', style: theme.textTheme.labelSmall),
const Spacer(),
Text(
remainingLabel,
style: theme.textTheme.labelMedium?.copyWith(
color: progress.isOverBudget
? colors.error
: colors.onSurfaceVariant,
),
),
],
),
const SizedBox(height: AppSpacing.xs),
Text(
'P ${_grams(progress.proteinG)} · '
'C ${_grams(progress.carbsG)} · '
'F ${_grams(progress.fatG)}',
style: theme.textTheme.labelSmall,
),
const SizedBox(height: AppSpacing.xs),
Text(
'Adherence streak: ${progress.adherenceStreak} '
'${_plural(progress.adherenceStreak)}',
style: theme.textTheme.labelSmall,
),
],
),
);
}
}

View File

@ -90,14 +90,6 @@ packages:
url: "https://github.com/kuhyx/utils"
source: git
version: "0.3.0"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
url: "https://pub.dev"
source: hosted
version: "0.3.5+4"
crypto:
dependency: "direct main"
description:
@ -146,38 +138,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
@ -239,14 +199,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_secure_storage:
dependency: "direct main"
description:
@ -345,70 +297,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.9.1"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
url: "https://pub.dev"
source: hosted
version: "0.8.13+19"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: "direct dev"
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
jni:
dependency: transitive
description:
@ -489,14 +377,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
objective_c:
dependency: transitive
description:

View File

@ -20,7 +20,6 @@ dependencies:
flutter_secure_storage: ^10.3.1
http: ^1.6.0
idb_shim: ^2.9.6+2
image_picker: ^1.1.2
path: ^1.9.1
path_provider: ^2.1.5
permission_handler: ^12.0.3
@ -36,7 +35,6 @@ dev_dependencies:
flutter_launcher_icons: ^0.14.4
flutter_test:
sdk: flutter
image_picker_platform_interface: ^2.10.0
url_launcher_platform_interface: ^2.3.2
very_good_analysis: ^10.2.0

View File

@ -97,7 +97,10 @@ void main() {
});
test('404s an unmirrored document', () async {
expect((await http.get(url('/documents/food_bank.json'))).statusCode, 404);
expect(
(await http.get(url('/documents/food_bank.json'))).statusCode,
404,
);
});
test('rejects a traversing name instead of writing outside dataDir', () {
@ -116,17 +119,6 @@ void main() {
});
});
group('blob mirror', () {
test('round-trips photo bytes', () async {
final bytes = [1, 2, 3, 4];
final posted = await http.post(url('/blobs/photo.jpg'), body: bytes);
final fetched = await http.get(url('/blobs/photo.jpg'));
expect(posted.statusCode, 204);
expect(fetched.bodyBytes, bytes);
});
});
group('github proxy', () {
test('reports no token before one is stored', () async {
final response = await http.get(url('/github/auth/status'));
@ -146,18 +138,21 @@ void main() {
);
});
test('falls back to the PC gate token when it has none of its own', () async {
// The Python side already keeps a token for diet-guard-sync.timer;
// reusing it is what makes the desktop app need no second setup.
final fallback = File(p.join(tempDir.path, 'config', 'fallback_token'))
..createSync(recursive: true)
..writeAsStringSync('ghp_from_gate\n');
addTearDown(fallback.deleteSync);
test(
'falls back to the PC gate token when it has none of its own',
() async {
// The Python side already keeps a token for diet-guard-sync.timer;
// reusing it is what makes the desktop app need no second setup.
final fallback = File(p.join(tempDir.path, 'config', 'fallback_token'))
..createSync(recursive: true)
..writeAsStringSync('ghp_from_gate\n');
addTearDown(fallback.deleteSync);
final response = await http.get(url('/github/auth/status'));
final response = await http.get(url('/github/auth/status'));
expect(jsonDecode(response.body), {'configured': true});
});
expect(jsonDecode(response.body), {'configured': true});
},
);
test('attaches the token to a proxied API call', () async {
await http.post(url('/github/auth/token'), body: 'ghp_stored');
@ -238,8 +233,9 @@ void main() {
test('ignores an unreadable token file rather than failing', () async {
// A directory where the token file should be: readToken must fall
// through to "no token" instead of throwing out of every request.
Directory(p.join(tempDir.path, 'config', 'sync_token'))
.createSync(recursive: true);
Directory(
p.join(tempDir.path, 'config', 'sync_token'),
).createSync(recursive: true);
expect(jsonDecode((await http.get(url('/github/auth/status'))).body), {
'configured': false,

View File

@ -28,7 +28,6 @@ void main() {
},
],
'deleted': true,
'imagePath': '/tmp/photo.jpg',
});
expect(entry.id, 'abc-123');
expect(entry.desc, 'label_food');
@ -38,7 +37,6 @@ void main() {
expect(entry.components, hasLength(1));
expect(entry.components!.first.name, 'rice');
expect(entry.deleted, isTrue);
expect(entry.imagePath, '/tmp/photo.jpg');
});
test('defaults missing macro fields to 0 and source to manual', () {
@ -54,7 +52,7 @@ void main() {
});
group('toLocalJson vs toSyncJson', () {
test('toLocalJson includes imagePath; toSyncJson excludes it and hmac', () {
test('toLocalJson matches toSyncJson; both exclude hmac', () {
const entry = FoodEntry(
id: 'id-1',
time: '2026-06-22T08:00:00+02:00',
@ -66,12 +64,9 @@ void main() {
fatG: 2,
source: 'manual',
hmac: 'sig',
imagePath: '/local/photo.jpg',
);
final local = entry.toLocalJson();
final sync = entry.toSyncJson();
expect(local['imagePath'], '/local/photo.jpg');
expect(sync.containsKey('imagePath'), isFalse);
expect(sync.containsKey('hmac'), isFalse);
expect(sync['desc'], 'toast');
});
@ -133,14 +128,6 @@ void main() {
],
);
test('copyWithImagePath only changes imagePath', () {
final updated = base.copyWithImagePath('/new/path.jpg');
expect(updated.imagePath, '/new/path.jpg');
expect(updated.id, base.id);
expect(updated.deleted, isFalse);
expect(updated.components, base.components);
});
test('copyWithDeleted sets deleted true and preserves everything else', () {
final tombstoned = base.copyWithDeleted();
expect(tombstoned.deleted, isTrue);

View File

@ -1,60 +0,0 @@
import 'package:diet_guard_app/models/meal_item.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:flutter_test/flutter_test.dart';
Nutrition _n(double kcal, double protein, double carbs, double fat, double g) =>
Nutrition(
kcal: kcal,
proteinG: protein,
carbsG: carbs,
fatG: fat,
grams: g,
source: 'manual',
);
void main() {
group('mealTotal', () {
test('sums every macro and the portion weight across items', () {
final items = [
MealItem(name: 'soup', nutrition: _n(100, 5, 10, 2, 200)),
MealItem(name: 'chicken', nutrition: _n(250, 30, 0, 10, 150)),
];
final total = mealTotal(items);
expect(total.kcal, 350);
expect(total.proteinG, 35);
expect(total.carbsG, 10);
expect(total.fatG, 12);
expect(total.grams, 350);
expect(total.source, mealSource);
});
test('returns all zeros for an empty meal', () {
final total = mealTotal(const []);
expect(total.kcal, 0);
expect(total.grams, 0);
expect(total.source, mealSource);
});
test('rounds the summed values to 0.1', () {
final items = [
MealItem(name: 'a', nutrition: _n(1.05, 1.05, 1.05, 1.05, 1.05)),
MealItem(name: 'b', nutrition: _n(1.05, 1.05, 1.05, 1.05, 1.05)),
];
final total = mealTotal(items);
expect(total.kcal, 2.1);
});
});
group('itemToComponent', () {
test('carries the item\'s name and full macros', () {
final item = MealItem(name: 'rice', nutrition: _n(200, 4, 44, 1, 150));
final component = itemToComponent(item);
expect(component.name, 'rice');
expect(component.kcal, 200);
expect(component.proteinG, 4);
expect(component.carbsG, 44);
expect(component.fatG, 1);
expect(component.grams, 150);
});
});
}

View File

@ -74,6 +74,48 @@ void main() {
});
});
group('slotForLog', () {
// Keep in lockstep with `diet_guard/tests/test_slots.py`'s
// TestSlotForLog: a divergence means the PC and the phone disagree about
// which checkpoint a meal satisfied.
DateTime at(int hour) => DateTime(2026, 6, 22, hour, 0);
test('clamps to the first slot before the window opens', () {
expect(slotForLog(at(7)), 8);
});
test('clamps to the first slot in the small hours', () {
expect(slotForLog(at(0)), 8);
});
test('is unchanged exactly at the first slot', () {
expect(slotForLog(at(8)), 8);
});
test('matches currentSlot inside the window', () {
expect(slotForLog(at(13)), currentSlot(at(13)));
expect(slotForLog(at(13)), 12);
});
test('is unchanged at the last in-window hour', () {
expect(slotForLog(at(21)), 20);
});
test('clamps to the last slot once the window closes', () {
expect(slotForLog(at(22)), 20);
});
test('clamps to the last slot late in the evening', () {
expect(slotForLog(at(23)), 20);
});
test('never returns a slot outside the schedule', () {
for (var hour = 0; hour < 24; hour++) {
expect(daySlots(), contains(slotForLog(at(hour))));
}
});
});
group('slotLabel', () {
test('pads single-digit hours', () {
expect(slotLabel(8), '08:00');

View File

@ -4,6 +4,7 @@ import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/screens/calendar_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -32,11 +33,15 @@ void main() {
tempDir = await Directory.systemTemp.createTemp('diet_guard_calendar_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
});
tearDown(() async {
LogStorageService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});

View File

@ -3,7 +3,8 @@ import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/photo_viewer_screen.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -14,10 +15,12 @@ void main() {
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_history_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
});
tearDown(() async {
LogStorageService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
@ -30,6 +33,62 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets('a day header shows the budget that applied on that day', (
tester,
) async {
await tester.runAsync(() async {
// 2200 until 2026-06-15, 2000 from then on.
await BudgetHistoryService.instance.applyMerged(const [
BudgetEntry(
effectiveFrom: kEpochDay,
kcal: 2200,
editedAt: '2026-01-01T00:00:00.000Z',
),
BudgetEntry(
effectiveFrom: '2026-06-15',
kcal: 2000,
editedAt: '2026-06-15T00:00:00.000Z',
),
]);
await LogStorageService.instance.writeLog({
'2026-06-01': [
const FoodEntry(
id: 'before',
time: '2026-06-01T08:00:00+02:00',
desc: 'before the cut',
grams: 100,
kcal: 2100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
),
],
'2026-06-20': [
const FoodEntry(
id: 'after',
time: '2026-06-20T08:00:00+02:00',
desc: 'after the cut',
grams: 100,
kcal: 2100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
// Same 2100 kcal on both days, judged against different budgets --
// lowering the budget must not repaint the earlier day.
expect(find.text('2100 / 2200 kcal'), findsOneWidget);
expect(find.text('2100 / 2000 kcal'), findsOneWidget);
});
});
testWidgets('shows a message when nothing has been logged', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
@ -97,39 +156,6 @@ void main() {
});
});
testWidgets('tapping a thumbnail opens the full-screen photo viewer', (
tester,
) async {
await tester.runAsync(() async {
final imageFile = File('${tempDir.path}/photo.png')
..writeAsBytesSync([1, 2, 3]);
await LogStorageService.instance.writeLog({
'2026-06-22': [
FoodEntry(
id: 'with-photo',
time: '2026-06-22T20:00:00+02:00',
desc: 'dinner with a photo',
grams: 100,
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 4,
source: 'manual',
imagePath: imageFile.path,
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byType(Image));
await settle(tester);
expect(find.byType(PhotoViewerScreen), findsOneWidget);
});
});
testWidgets(
'initialDateRange pre-filters to just that day, matching a Calendar '
'screen day tap',
@ -209,7 +235,6 @@ void main() {
carbsG: 40,
fatG: 1,
source: 'food bank',
imagePath: '/fake/img.jpg',
),
const FoodEntry(
id: 'c',
@ -284,26 +309,6 @@ void main() {
expect(result.map((e) => e.id), ['b']); // fat=1
});
test('hasPhoto=true keeps only entries with imagePath', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(hasPhoto: true),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['b']);
});
test('hasPhoto=false keeps only entries without imagePath', () {
final result = applyHistoryFilter(
entries,
HistoryFilter(hasPhoto: false),
HistorySortField.date,
ascending: false,
);
expect(result.map((e) => e.id), ['c', 'a']);
});
test('source filter keeps only matching source', () {
final result = applyHistoryFilter(
entries,
@ -392,10 +397,6 @@ void main() {
expect(HistoryFilter(source: 'manual').isActive, isTrue);
});
test('HistoryFilter.isActive is true when hasPhoto is set', () {
expect(HistoryFilter(hasPhoto: true).isActive, isTrue);
});
test('maxProtein filters by protein', () {
final result = applyHistoryFilter(
entries,
@ -794,54 +795,6 @@ void main() {
});
});
testWidgets('filter sheet photo chips fire onSelected callbacks', (
tester,
) async {
await tester.runAsync(() async {
// Zero macros: date/photo/source sections are visible without scrolling.
await LogStorageService.instance.writeLog({
'2026-06-24': [
const FoodEntry(
id: 'ph1',
time: '2026-06-24T08:00:00+02:00',
desc: 'photo test entry',
grams: 100,
kcal: 0,
proteinG: 0,
carbsG: 0,
fatG: 0,
source: 'manual',
),
],
});
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
await settle(tester);
await tester.tap(find.byIcon(Icons.filter_list));
await settle(tester);
expect(find.text('Filter & Sort'), findsOneWidget);
// Tap 'With photo' → covers lines 769-771 (hasPhoto = true).
await tester.tap(find.widgetWithText(FilterChip, 'With photo'));
await settle(tester);
// Tap 'Without photo' → covers lines 777-779 (hasPhoto = false).
await tester.tap(find.widgetWithText(FilterChip, 'Without photo'));
await settle(tester);
// Tap 'Any' to reset → covers lines 761-763 (hasPhoto = null).
await tester.tap(find.widgetWithText(FilterChip, 'Any'));
await settle(tester);
await tester.tap(find.text('Apply'));
await settle(tester);
expect(find.text('Filter & Sort'), findsNothing);
});
});
testWidgets('filter sheet source All chip fires onSelected callback', (
tester,
) async {

View File

@ -176,50 +176,4 @@ void main() {
expect(puts, 2);
});
});
testWidgets('pushes immediately after repeating the last meal', (
tester,
) async {
SharedPreferences.setMockInitialValues({
'sync.owner': 'o',
'sync.repo': 'r',
});
installFakeSecureStorage(initial: {'sync.token': 't'});
var puts = 0;
final mock = MockClient((req) async {
if (req.method == 'PUT') puts++;
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
return http.Response('{}', 200);
}
return http.Response('', 404);
});
await tester.runAsync(() async {
await LogStorageService.instance.logMeal(
'seed meal',
const Nutrition(
kcal: 100,
proteinG: 5,
carbsG: 10,
fatG: 2,
grams: 50,
source: 'manual',
),
);
await tester.pumpWidget(
MaterialApp(home: LogMealScreen(httpClient: mock)),
);
await settle(tester);
expect(puts, 1); // launch push
final fab = find.byType(FloatingActionButton);
await tester.ensureVisible(fab);
await tester.tap(fab);
await settle(tester);
// The repeated meal is pushed right away too, same as manual logging.
expect(puts, 2);
});
});
}

View File

@ -1,6 +1,5 @@
import 'dart:io';
import 'package:diet_guard_app/services/blob_store_io.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/nutrition.dart';
@ -9,17 +8,14 @@ import 'package:diet_guard_app/screens/calendar_screen.dart';
import 'package:diet_guard_app/screens/food_bank_screen.dart';
import 'package:diet_guard_app/screens/log_meal_screen.dart';
import 'package:diet_guard_app/screens/history_screen.dart';
import 'package:diet_guard_app/screens/meal_builder_screen.dart';
import 'package:diet_guard_app/screens/photo_viewer_screen.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_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/photo_attach_service.dart';
import 'package:diet_guard_app/widgets/today_progress_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
@ -45,109 +41,28 @@ class _FakeUrlLauncher extends UrlLauncherPlatform
}
/// Returns a fixed [XFile] without touching any real platform channel.
class _FakeImagePickerPlatform extends ImagePickerPlatform {
_FakeImagePickerPlatform(this._result);
final XFile? _result;
@override
Future<XFile?> getImageFromSource({
required ImageSource source,
ImagePickerOptions options = const ImagePickerOptions(),
}) async => _result;
}
/// A minimal valid 1x1 transparent PNG, so the thumbnail preview can decode
/// it as a real image instead of throwing on bogus bytes.
const List<int> _onePixelPng = [
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
0xC4,
0x89,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x44,
0x41,
0x54,
0x78,
0x9C,
0x62,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
0x42,
0x60,
0x82,
];
void main() {
late Directory tempDir;
late ImagePickerPlatform originalImagePickerPlatform;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_screen_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
PhotoAttachService.resetForTesting(store: FileBlobStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
originalImagePickerPlatform = ImagePickerPlatform.instance;
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
PhotoAttachService.resetForTesting();
AppSettingsService.resetForTesting();
ImagePickerPlatform.instance = originalImagePickerPlatform;
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
@ -235,6 +150,55 @@ void main() {
});
});
testWidgets('the progress card summarises today after a log', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
expect(find.byType(TodayProgressCard), findsNothing);
await tester.enterText(find.byType(TextField).at(0), 'toast');
await settle(tester);
await tester.enterText(find.byType(TextField).at(2), '150');
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
expect(find.byType(TodayProgressCard), findsOneWidget);
// Default budget is 2200 with the settings singleton uninitialised.
expect(find.text('150 / 2200'), findsOneWidget);
expect(find.text('2050 left'), findsOneWidget);
});
});
testWidgets('typing a new description dismisses the progress card', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.enterText(find.byType(TextField).at(0), 'toast');
await settle(tester);
await tester.enterText(find.byType(TextField).at(2), '150');
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
expect(find.byType(TodayProgressCard), findsOneWidget);
// Starting the next meal clears the previous one's summary, so the
// card never describes a stale log.
await tester.enterText(find.byType(TextField).at(0), 'eggs');
await settle(tester);
expect(find.byType(TodayProgressCard), findsNothing);
});
});
testWidgets('refuses to log with an empty description', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
@ -331,87 +295,6 @@ void main() {
},
);
testWidgets(
'attaching a photo persists its path on the logged entry, and removing '
'it before logging clears it again',
(tester) async {
await tester.runAsync(() async {
// A real (1x1, transparent) PNG, not an arbitrary byte sequence --
// the thumbnail preview decodes this file as an actual image, and a
// bogus payload throws inside the image codec rather than failing
// cleanly.
final source = File('${tempDir.path}/source.jpg')
..writeAsBytesSync(_onePixelPng);
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
XFile(source.path),
);
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.enterText(find.byType(TextField).at(0), 'snack');
await settle(tester);
await tester.tap(find.byTooltip('Attach photo'));
await settle(tester);
await tester.tap(find.text('Choose from gallery'));
await settle(tester);
expect(find.byTooltip('Remove photo'), findsOneWidget);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.imagePath, isNotNull);
expect(entry.imagePath, startsWith('${tempDir.path}/images/'));
expect(File(entry.imagePath!).readAsBytesSync(), _onePixelPng);
await tester.enterText(find.byType(TextField).at(0), 'snack two');
await settle(tester);
await tester.tap(find.byTooltip('Attach photo'));
await settle(tester);
await tester.tap(find.text('Choose from gallery'));
await settle(tester);
await tester.tap(find.byTooltip('Remove photo'));
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
final secondEntry =
(await LogStorageService.instance.todayEntries()).last;
expect(secondEntry.imagePath, isNull);
});
},
);
testWidgets('tapping the attached-photo thumbnail opens the full viewer', (
tester,
) async {
await tester.runAsync(() async {
final source = File('${tempDir.path}/source.jpg')
..writeAsBytesSync(_onePixelPng);
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
XFile(source.path),
);
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.tap(find.byTooltip('Attach photo'));
await settle(tester);
await tester.tap(find.text('Choose from gallery'));
await settle(tester);
await tester.tap(find.byType(Image));
await settle(tester);
expect(find.byType(PhotoViewerScreen), findsOneWidget);
});
});
testWidgets('food bank icon navigates to FoodBankScreen', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
@ -424,21 +307,6 @@ void main() {
});
});
testWidgets('build meal button navigates to MealBuilderScreen', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
await tester.ensureVisible(find.byTooltip('Build a multi-item meal'));
await tester.tap(find.byTooltip('Build a multi-item meal'));
await settle(tester);
expect(find.byType(MealBuilderScreen), findsOneWidget);
});
});
testWidgets('logged slot chip renders check-icon avatar', (tester) async {
await tester.runAsync(() async {
final now = DateTime.now();
@ -491,197 +359,4 @@ void main() {
expect(chip.selected, isTrue);
});
});
testWidgets('navigating to MealBuilderScreen and back refreshes slots', (
tester,
) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
// Open MealBuilderScreen.
await tester.ensureVisible(find.byTooltip('Build a multi-item meal'));
await tester.tap(find.byTooltip('Build a multi-item meal'));
await settle(tester);
expect(find.byType(MealBuilderScreen), findsOneWidget);
// Pop back — triggers _onBuildMeal's await _refreshSlots() (line 213).
await tester.tap(find.byTooltip('Back'));
await settle(tester);
expect(find.byType(LogMealScreen), findsOneWidget);
});
});
const nutritionA = Nutrition(
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 4,
grams: 100,
source: 'manual',
);
testWidgets(
'repeat last meal FAB is disabled with no previous meal logged',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
final fab = tester.widget<FloatingActionButton>(
find.byType(FloatingActionButton),
);
expect(fab.onPressed, isNull);
expect(find.byTooltip('No previous meal to repeat'), findsOneWidget);
});
},
);
testWidgets(
'tapping repeat last meal logs a duplicate of the most recent entry '
"with today's current slot",
(tester) async {
await tester.runAsync(() async {
const older = FoodEntry(
id: 'older',
time: '2026-06-01T08:00:00+02:00',
desc: 'first meal',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
);
await LogStorageService.instance.writeLog({
'2026-06-01': [older],
});
await LogStorageService.instance.logMeal('second meal', nutritionA);
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
final fab = find.byType(FloatingActionButton);
await tester.ensureVisible(fab);
await tester.tap(fab);
await settle(tester);
expect(find.text('Logged "second meal" again.'), findsOneWidget);
final entries = await LogStorageService.instance.todayEntries();
final repeated = entries.where((e) => e.desc == 'second meal');
expect(repeated.length, 2);
// The seed entry (logged with no explicit slot) has slot: null; the
// repeat must be the one stamped with today's current slot.
expect(
repeated.any((e) => e.slot == currentSlot(DateTime.now())),
isTrue,
);
});
},
);
testWidgets(
'tapping repeat last meal twice keeps repeating the newly-logged entry',
(tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.logMeal('looped meal', nutritionA);
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
final fab = find.byType(FloatingActionButton);
await tester.ensureVisible(fab);
await tester.tap(fab);
await settle(tester);
await tester.tap(fab);
await settle(tester);
final entries = await LogStorageService.instance.todayEntries();
expect(entries.where((e) => e.desc == 'looped meal').length, 3);
});
},
);
testWidgets(
'reward affordance is absent by default and appears after a repeat '
'once a reward is configured',
(tester) async {
await tester.runAsync(() async {
await LogStorageService.instance.logMeal('reward meal', nutritionA);
await AppSettingsService.instance.saveReward(
label: 'Podcast',
url: 'https://example.com/podcast',
);
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
expect(find.text('Open Podcast'), findsNothing);
final fab = find.byType(FloatingActionButton);
await tester.ensureVisible(fab);
await tester.tap(fab);
await settle(tester);
expect(find.text('Open Podcast'), findsOneWidget);
});
},
);
testWidgets('editing the description field dismisses a stale reward prompt', (
tester,
) async {
await tester.runAsync(() async {
await LogStorageService.instance.logMeal('reward meal', nutritionA);
await AppSettingsService.instance.saveReward(
label: 'Podcast',
url: 'https://example.com/podcast',
);
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
final fab = find.byType(FloatingActionButton);
await tester.ensureVisible(fab);
await tester.tap(fab);
await settle(tester);
expect(find.text('Open Podcast'), findsOneWidget);
await tester.enterText(find.byType(TextField).at(0), 'x');
await settle(tester);
expect(find.text('Open Podcast'), findsNothing);
});
});
testWidgets('tapping the reward affordance launches the configured URL', (
tester,
) async {
final launcher = _FakeUrlLauncher();
UrlLauncherPlatform.instance = launcher;
await tester.runAsync(() async {
await LogStorageService.instance.logMeal('reward meal', nutritionA);
await AppSettingsService.instance.saveReward(
label: 'Podcast',
url: 'https://example.com/podcast',
);
await tester.pumpWidget(const MaterialApp(home: LogMealScreen()));
await settle(tester);
final fab = find.byType(FloatingActionButton);
await tester.ensureVisible(fab);
await tester.tap(fab);
await settle(tester);
await tester.tap(find.text('Open Podcast'));
await settle(tester);
expect(launcher.launched, 'https://example.com/podcast');
});
});
}

View File

@ -1,265 +0,0 @@
import 'dart:io';
import 'package:diet_guard_app/services/blob_store_io.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/screens/meal_builder_screen.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/photo_attach_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
/// Returns a fixed [XFile] without touching any real platform channel.
class _FakeImagePickerPlatform extends ImagePickerPlatform {
_FakeImagePickerPlatform(this._result);
final XFile? _result;
@override
Future<XFile?> getImageFromSource({
required ImageSource source,
ImagePickerOptions options = const ImagePickerOptions(),
}) async => _result;
}
void main() {
late Directory tempDir;
late ImagePickerPlatform originalImagePickerPlatform;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_builder_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
PhotoAttachService.resetForTesting(store: FileBlobStore(tempDir));
originalImagePickerPlatform = ImagePickerPlatform.instance;
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
PhotoAttachService.resetForTesting();
ImagePickerPlatform.instance = originalImagePickerPlatform;
await tempDir.delete(recursive: true);
});
final addItemButton = find.widgetWithText(ElevatedButton, 'Add item');
final logMealButton = find.widgetWithText(ElevatedButton, 'Log meal');
// See log_meal_screen_test.dart: "Log meal" triggers real dart:io File
// writes as an unawaited Future Flutter's scheduler doesn't track, so a
// short real delay before settling is needed in addition to runAsync().
Future<void> settle(WidgetTester tester) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
}
testWidgets('refuses to log a meal with no items added', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
await tester.tap(logMealButton);
await settle(tester);
expect(find.text('Add at least one item first.'), findsOneWidget);
});
});
testWidgets(
'adds an item with per/ate scaling applied, then logs the composite '
'meal with full per-component macros',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(
const MaterialApp(home: MealBuilderScreen()),
);
await settle(tester);
// Field order: [0] meal name, [1] item name, [2] per (g),
// [3] kcal, [4] protein, [5] carbs, [6] fat, [7] ate (g).
await tester.enterText(find.byType(TextField).at(1), 'rice');
await tester.enterText(find.byType(TextField).at(2), '100');
await tester.enterText(find.byType(TextField).at(3), '200');
await tester.enterText(find.byType(TextField).at(4), '4');
await tester.enterText(find.byType(TextField).at(5), '44');
await tester.enterText(find.byType(TextField).at(6), '1');
await tester.enterText(find.byType(TextField).at(7), '150');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
expect(find.textContaining('So far (1)'), findsOneWidget);
expect(find.textContaining('300 kcal'), findsOneWidget);
await tester.enterText(find.byType(TextField).at(1), 'chicken');
await tester.enterText(find.byType(TextField).at(3), '165');
await tester.enterText(find.byType(TextField).at(4), '31');
await tester.enterText(find.byType(TextField).at(5), '0');
await tester.enterText(find.byType(TextField).at(6), '4');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
await tester.tap(logMealButton);
await settle(tester);
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.source, 'meal');
expect(entry.kcal, 465); // 300 (scaled rice) + 165 (chicken)
expect(entry.components, hasLength(2));
final rice = entry.components!.firstWhere((c) => c.name == 'rice');
expect(rice.kcal, 300);
expect(rice.proteinG, 6);
expect(rice.carbsG, 66);
expect(rice.fatG, 1.5);
expect(rice.grams, 150);
});
},
);
testWidgets(
'attaching a photo to a composite meal persists its path on the logged '
'entry',
(tester) async {
await tester.runAsync(() async {
final source = File('${tempDir.path}/source.jpg')
..writeAsBytesSync([1, 2, 3]);
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
XFile(source.path),
);
await tester.pumpWidget(
const MaterialApp(home: MealBuilderScreen()),
);
await settle(tester);
await tester.enterText(find.byType(TextField).at(1), 'soup');
await tester.enterText(find.byType(TextField).at(3), '120');
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);
await tester.tap(find.text('Choose from gallery'));
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);
expect(entry.imagePath, startsWith('${tempDir.path}/images/'));
});
},
);
testWidgets(
'logged meal uses provided name when name field is non-empty (line 73)',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
// Enter a meal name in field at(0), item in at(1), kcal in at(3).
await tester.enterText(find.byType(TextField).at(0), 'Sunday dinner');
await tester.enterText(find.byType(TextField).at(1), 'pasta');
await tester.enterText(find.byType(TextField).at(3), '400');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.desc, equals('Sunday dinner'));
});
},
);
testWidgets(
'Add item with empty description shows error status (line 46)',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
// Tap "Add item" without entering any description.
await tester.tap(addItemButton);
await settle(tester);
expect(
find.text('Type the item first, then add it.'),
findsOneWidget,
);
});
},
);
testWidgets(
'logging a meal with empty name field defaults name to "meal" (line 73)',
(tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
// Add one item (name field intentionally left empty).
await tester.enterText(find.byType(TextField).at(1), 'rice');
await tester.enterText(find.byType(TextField).at(3), '200');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
// Leave meal name field empty, then log — name defaults to 'meal'.
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.desc, equals('meal'));
});
},
);
testWidgets(
'photo attach sheet "Take a photo" choice covers camera onTap (lines 42-43)',
(tester) async {
await tester.runAsync(() async {
final fakePhoto = File('${tempDir.path}/fake_camera.jpg')
..createSync()
..writeAsBytesSync([0xFF, 0xD8, 0xFF]);
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
XFile(fakePhoto.path),
);
await tester.pumpWidget(const MaterialApp(home: MealBuilderScreen()));
await settle(tester);
await tester.enterText(find.byType(TextField).at(1), 'salad');
await tester.enterText(find.byType(TextField).at(3), '100');
await settle(tester);
await tester.tap(addItemButton);
await settle(tester);
await tester.ensureVisible(find.text('Attach photo'));
await tester.tap(find.text('Attach photo'));
await settle(tester);
// Tap "Take a photo" → triggers camera onTap (lines 42-43).
await tester.tap(find.text('Take a photo'));
await settle(tester);
await tester.ensureVisible(logMealButton);
await tester.tap(logMealButton);
await settle(tester);
final entry = (await LogStorageService.instance.todayEntries()).single;
expect(entry.imagePath, isNotNull);
});
},
);
}

View File

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/screens/settings_screen.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.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';
@ -47,6 +48,9 @@ void main() {
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
});
@ -55,6 +59,7 @@ void main() {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
@ -99,46 +104,6 @@ void main() {
});
});
testWidgets('loads existing saved reward values into their fields', (
tester,
) async {
await tester.runAsync(() async {
await AppSettingsService.instance.saveReward(
label: 'Podcast',
url: 'https://example.com/podcast',
);
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
expect(find.widgetWithText(TextField, 'Podcast'), findsOneWidget);
expect(
find.widgetWithText(TextField, 'https://example.com/podcast'),
findsOneWidget,
);
});
});
testWidgets('typing into the reward fields persists them', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await settle(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Reward label'),
'Podcast',
);
await tester.enterText(
find.widgetWithText(TextField, 'Reward URL'),
'https://example.com/podcast',
);
await settle(tester);
expect(AppSettingsService.rewardLabel, 'Podcast');
expect(AppSettingsService.rewardUrl, 'https://example.com/podcast');
});
});
testWidgets('Save persists the entered token', (tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
@ -13,10 +14,12 @@ void main() {
'diet_guard_settings_test_',
);
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
});
tearDown(() async {
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
@ -30,12 +33,19 @@ void main() {
group('resetForTesting', () {
test('with testDir creates a working instance', () {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.instance, isNotNull);
});
test('without testDir nulls the singleton', () {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
// instance getter throws when null — verify via dailyKcalGoal fallback.
expect(AppSettingsService.dailyKcalGoal, 2200);
});
@ -44,6 +54,9 @@ void main() {
group('init early-return', () {
test('returns existing instance without re-initialising', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
final first = AppSettingsService.instance;
// init() sees _instance != null and returns early (no platform channel).
final second = await AppSettingsService.init();
@ -54,6 +67,9 @@ void main() {
group('saveDailyKcalGoal', () {
test('updates in-memory value and persists to file', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
await AppSettingsService.instance.saveDailyKcalGoal(1800);
expect(AppSettingsService.dailyKcalGoal, 1800);
@ -65,8 +81,76 @@ void main() {
expect(data['daily_kcal_goal'], 1800);
});
test('grandfathers the previous value to every past day', () async {
// The load-bearing ordering test, on the real upgrade path: a device
// that already had a goal (with an updatedAt, i.e. genuinely set)
// gains a history the first time it loads, and a later edit must not
// drag past days along with it. Asserts a PAST day -- a today-only
// assertion passes even when the seed happens after the upsert.
await File('${tempDir.path}/app_settings.json').writeAsString(
jsonEncode({
'daily_kcal_goal': 2200,
'daily_kcal_goal_updated_at': '2026-07-13T21:15:09.000',
}),
);
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
await AppSettingsService.instance.saveDailyKcalGoal(2000);
expect(BudgetHistoryService.schedule.forDay('2020-01-01'), 2200);
expect(BudgetHistoryService.schedule.current, 2000);
expect(AppSettingsService.dailyKcalGoal, 2000);
});
test('loading an already-set goal seeds the history on its own', () async {
// Seeding must not depend on the user making an edit: a device that
// merely *synced* a goal still has to classify its history against
// that goal, matching _budget.current_schedule's seed-on-read.
await File('${tempDir.path}/app_settings.json').writeAsString(
jsonEncode({
'daily_kcal_goal': 1800,
'daily_kcal_goal_updated_at': '2026-07-13T21:15:09.000',
}),
);
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
expect(BudgetHistoryService.schedule.forDay('2020-01-01'), 1800);
});
test('a never-set goal seeds no phantom history', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.instance.saveDailyKcalGoal(2000);
// Nothing to grandfather, so the only entry is today's; past days fall
// back to the current goal, exactly as _budget.current_schedule does.
expect(BudgetHistoryService.schedule.entries, hasLength(1));
expect(BudgetHistoryService.schedule.forDay('2020-01-01'), 2000);
});
test('a merge write-back records no history change', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.instance.applySyncedBudget(
1700,
updatedAt: DateTime(2026, 7, 26),
);
// The sync layer applies merged history entries itself; a write-back
// must not manufacture a local edit on top of them.
expect(BudgetHistoryService.schedule.entries, isEmpty);
});
test('stamps a fresh dailyKcalGoalUpdatedAt', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
final before = DateTime.now();
await AppSettingsService.instance.saveDailyKcalGoal(1800);
final after = DateTime.now();
@ -83,6 +167,9 @@ void main() {
'persists the given value and updatedAt verbatim, not "now"',
() async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
final winningEdit = DateTime.utc(2020);
await AppSettingsService.instance.applySyncedBudget(
@ -107,6 +194,9 @@ void main() {
test('a null updatedAt is accepted and persisted as null', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
await AppSettingsService.instance.applySyncedBudget(1700);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
});
@ -119,12 +209,18 @@ void main() {
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 1600);
});
test('keeps default 2200 when file does not exist', () async {
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
@ -135,6 +231,9 @@ void main() {
).writeAsString('not json at all');
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
@ -145,6 +244,9 @@ void main() {
).writeAsString(jsonEncode([1, 2, 3]));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
@ -155,6 +257,9 @@ void main() {
).writeAsString(jsonEncode({'daily_kcal_goal': 'two thousand'}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoal, 2200);
});
@ -168,6 +273,9 @@ void main() {
);
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(
AppSettingsService.dailyKcalGoalUpdatedAt,
@ -183,6 +291,9 @@ void main() {
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
},
@ -199,106 +310,12 @@ void main() {
);
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
expect(AppSettingsService.dailyKcalGoalUpdatedAt, isNull);
},
);
test('loads reward_label and reward_url from an existing file', () async {
await File('${tempDir.path}/app_settings.json').writeAsString(
jsonEncode({
'daily_kcal_goal': 1600,
'reward_label': 'Podcast',
'reward_url': 'https://example.com/podcast',
}),
);
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
expect(AppSettingsService.rewardLabel, 'Podcast');
expect(AppSettingsService.rewardUrl, 'https://example.com/podcast');
});
test('reward fields stay null when the file has no such keys', () async {
await File(
'${tempDir.path}/app_settings.json',
).writeAsString(jsonEncode({'daily_kcal_goal': 1600}));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
expect(AppSettingsService.rewardLabel, isNull);
expect(AppSettingsService.rewardUrl, isNull);
});
test('reward fields stay null when not strings', () async {
await File('${tempDir.path}/app_settings.json').writeAsString(
jsonEncode({
'daily_kcal_goal': 1600,
'reward_label': 123,
'reward_url': false,
}),
);
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
expect(AppSettingsService.rewardLabel, isNull);
expect(AppSettingsService.rewardUrl, isNull);
});
});
group('rewardLabel / rewardUrl static getters', () {
test('default to null when singleton is uninitialised', () {
expect(AppSettingsService.rewardLabel, isNull);
expect(AppSettingsService.rewardUrl, isNull);
});
});
group('saveReward', () {
test('persists label and url, readable back via static getters', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.instance.saveReward(
label: 'Podcast',
url: 'https://example.com/podcast',
);
expect(AppSettingsService.rewardLabel, 'Podcast');
expect(AppSettingsService.rewardUrl, 'https://example.com/podcast');
final raw = await File(
'${tempDir.path}/app_settings.json',
).readAsString();
final data = jsonDecode(raw) as Map;
expect(data['reward_label'], 'Podcast');
expect(data['reward_url'], 'https://example.com/podcast');
});
test('clears fields back to null when saved as null', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.instance.saveReward(
label: 'Podcast',
url: 'https://example.com/podcast',
);
await AppSettingsService.instance.saveReward(label: null, url: null);
expect(AppSettingsService.rewardLabel, isNull);
expect(AppSettingsService.rewardUrl, isNull);
});
test('does not disturb the existing kcal goal fields', () async {
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.instance.saveDailyKcalGoal(1800);
await AppSettingsService.instance.saveReward(
label: 'Podcast',
url: 'https://example.com/podcast',
);
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);
});
});
}

View File

@ -7,6 +7,7 @@ import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/background_sync_service.dart';
import 'package:diet_guard_app/services/foodbank_service.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
@ -29,12 +30,16 @@ void main() {
// .init() call short-circuits instead of hitting the real (unmocked in
// this test) path_provider channel.
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
@ -75,9 +80,9 @@ void main() {
final ok = await backgroundSyncPush(httpClient: mock);
expect(ok, isTrue);
// One food_log.json push, plus one budget.json push (syncLog always
// pushes, even the empty merged budget when nothing's been set yet).
expect(puts, 2);
// syncLog always pushes, even an empty merged result: food_log.json,
// budget.json, food_bank.json, food_bank_manual.json.
expect(puts, 4);
});
test('reports failure (retry) when the push errors', () async {

View File

@ -1,65 +0,0 @@
// Photo bytes went behind a BlobStore when the desktop target became a web
// build: Android keeps files, the browser keeps IndexedDB entries. These
// tests pin the file backend, which is what the phone actually runs.
import 'dart:io';
import 'dart:typed_data';
import 'package:diet_guard_app/services/blob_store_io.dart';
import 'package:diet_guard_app/services/photo_attach_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory tempDir;
late FileBlobStore store;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_blob_');
store = FileBlobStore(tempDir);
});
tearDown(() async => tempDir.delete(recursive: true));
test('put writes under images/ with the given extension', () async {
final key = await store.put('.jpg', Uint8List.fromList([1, 2, 3]));
expect(key, startsWith(p.join(tempDir.path, 'images')));
expect(key, endsWith('.jpg'));
expect(File(key).readAsBytesSync(), [1, 2, 3]);
});
test('put gives each blob its own key', () async {
final first = await store.put('.jpg', Uint8List.fromList([1]));
final second = await store.put('.jpg', Uint8List.fromList([2]));
expect(first, isNot(second));
});
test('get returns the stored bytes', () async {
final key = await store.put('.png', Uint8List.fromList([9, 8]));
expect(await store.get(key), [9, 8]);
});
test('get returns null for a photo that is gone', () async {
// The entry outlives its file when the user clears app storage; the UI
// shows a broken-image icon rather than failing to build.
expect(await store.get(p.join(tempDir.path, 'images', 'missing.jpg')), null);
});
test('get returns null when the path is not a readable file', () async {
final directoryPath = p.join(tempDir.path, 'a_directory')..toString();
Directory(directoryPath).createSync();
expect(await store.get(directoryPath), null);
});
test('PhotoAttachService.readBytes reads through its store', () async {
// Backs the web image widget, which cannot render from a path.
PhotoAttachService.resetForTesting(store: store);
addTearDown(PhotoAttachService.resetForTesting);
final key = await store.put('.jpg', Uint8List.fromList([4, 5]));
expect(await PhotoAttachService.instance.readBytes(key), [4, 5]);
});
}

View File

@ -0,0 +1,133 @@
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_hist_');
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
});
tearDown(() async {
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
test('schedule is empty when the singleton is uninitialised', () {
BudgetHistoryService.resetForTesting();
expect(BudgetHistoryService.schedule.entries, isEmpty);
expect(BudgetHistoryService.schedule.forDay('2026-06-01'), 2200);
});
test('seedIfEmpty grandfathers the current value to the epoch', () async {
await BudgetHistoryService.instance.seedIfEmpty(
2200,
DateTime.utc(2026, 7, 13),
);
expect(BudgetHistoryService.schedule.forDay('2026-06-01'), 2200);
expect(
BudgetHistoryService.schedule.entries.single.effectiveFrom,
kEpochDay,
);
});
test('seedIfEmpty does nothing when the goal was never set', () async {
// A null updatedAt means this device has nothing of its own to
// grandfather; the default fallback already covers every day.
await BudgetHistoryService.instance.seedIfEmpty(2200, null);
expect(BudgetHistoryService.schedule.entries, isEmpty);
});
test('seedIfEmpty never overwrites an existing history', () async {
await BudgetHistoryService.instance.recordChange(
1800,
when: DateTime(2026, 7, 26),
);
await BudgetHistoryService.instance.seedIfEmpty(
2200,
DateTime.utc(2026, 7, 13),
);
expect(BudgetHistoryService.schedule.entries, hasLength(1));
expect(BudgetHistoryService.schedule.entries.single.kcal, 1800);
});
test('recordChange persists and reloads', () async {
await BudgetHistoryService.instance.recordChange(
2000,
when: DateTime(2026, 7, 26),
);
await BudgetHistoryService.initForTesting(FileDocumentStore(tempDir));
expect(BudgetHistoryService.schedule.forDay('2026-07-26'), 2000);
});
test('overlapping same-day writes leave exactly one entry', () async {
// settings_screen saves as the user types, so several of these are in
// flight at once; the service serializes its whole in-memory schedule
// rather than read-modify-writing, so they cannot race into duplicates.
final when = DateTime(2026, 7, 26, 12);
await Future.wait([
BudgetHistoryService.instance.recordChange(2, when: when),
BudgetHistoryService.instance.recordChange(20, when: when),
BudgetHistoryService.instance.recordChange(200, when: when),
BudgetHistoryService.instance.recordChange(2000, when: when),
]);
expect(BudgetHistoryService.schedule.entries, hasLength(1));
});
test('applyMerged replaces the stored history', () async {
await BudgetHistoryService.instance.recordChange(
1800,
when: DateTime(2026, 7, 26),
);
await BudgetHistoryService.instance.applyMerged([
const BudgetEntry(
effectiveFrom: kEpochDay,
kcal: 2200,
editedAt: '2026-07-13T00:00:00.000Z',
),
]);
expect(BudgetHistoryService.schedule.entries.single.kcal, 2200);
});
test('a corrupt document degrades to no history', () async {
await File(
'${tempDir.path}/budget_history.json',
).writeAsString('not json{{{');
await BudgetHistoryService.initForTesting(FileDocumentStore(tempDir));
expect(BudgetHistoryService.schedule.entries, isEmpty);
});
test('an absent document loads as no history', () async {
await BudgetHistoryService.initForTesting(FileDocumentStore(tempDir));
expect(BudgetHistoryService.schedule.entries, isEmpty);
});
test('the stored document is plain readable JSON', () async {
await BudgetHistoryService.instance.recordChange(
2000,
when: DateTime(2026, 7, 26),
);
final raw = await File(
'${tempDir.path}/budget_history.json',
).readAsString();
final data = jsonDecode(raw) as Map;
expect(data['v'], 1);
expect((data['e'] as Map)['2026-07-26'], isA<Map>());
});
}

View File

@ -0,0 +1,168 @@
// Mirror of `diet_guard/tests/test_budget_history.py`'s pure half. Keep the
// two in lockstep: a divergence means the PC and the phone resolve the same
// day to different budgets.
import 'dart:convert';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:flutter_test/flutter_test.dart';
BudgetEntry _entry(
String day,
int kcal, [
String t = '2026-01-01T00:00:00.000Z',
]) => BudgetEntry(effectiveFrom: day, kcal: kcal, editedAt: t);
void main() {
group('forDay', () {
test('empty history falls back to the default', () {
expect(BudgetSchedule.empty.forDay('2026-06-01'), kDefaultDailyKcalGoal);
});
test('day before every entry falls back to the fallback', () {
final schedule = BudgetSchedule([
_entry('2026-07-26', 2000),
], fallback: 2200);
expect(schedule.forDay('2026-06-01'), 2200);
});
test('exact effective-from day uses the new value', () {
final schedule = BudgetSchedule([
_entry('2026-07-26', 2000),
], fallback: 2200);
expect(schedule.forDay('2026-07-26'), 2000);
});
test('day after uses the new value', () {
final schedule = BudgetSchedule([
_entry('2026-07-26', 2000),
], fallback: 2200);
expect(schedule.forDay('2026-07-27'), 2000);
});
test('between entries uses the earlier one', () {
final schedule = BudgetSchedule([
_entry(kEpochDay, 2200),
_entry('2026-07-26', 2000),
], fallback: 1);
expect(schedule.forDay('2026-07-25'), 2200);
expect(schedule.forDay('2026-07-26'), 2000);
});
test('picks the latest of several changes', () {
final schedule = BudgetSchedule([
_entry(kEpochDay, 2400),
_entry('2026-03-01', 2200),
_entry('2026-07-26', 2000),
], fallback: 1);
expect(schedule.forDay('2026-02-28'), 2400);
expect(schedule.forDay('2026-05-05'), 2200);
expect(schedule.forDay('2026-12-31'), 2000);
});
test('current resolves today', () {
final schedule = BudgetSchedule([_entry(kEpochDay, 1900)], fallback: 1);
expect(schedule.current, 1900);
});
});
group('parse', () {
test('round trips entries', () {
final entries = [_entry(kEpochDay, 2200), _entry('2026-07-26', 2000)];
final raw = jsonDecode(jsonEncode(BudgetSchedule.encode(entries)));
expect(BudgetSchedule.parse(raw), entries);
});
test('sorts ascending regardless of stored order', () {
final raw = {
'v': 1,
'e': {
'2026-07-26': {'b': 2000, 't': 'x'},
'1970-01-01': {'b': 2200, 't': 'y'},
},
};
expect(BudgetSchedule.parse(raw).map((e) => e.effectiveFrom).toList(), [
'1970-01-01',
'2026-07-26',
]);
});
test('non-map is empty', () {
expect(BudgetSchedule.parse([1, 2, 3]), isEmpty);
});
test('wrong version is empty', () {
expect(
BudgetSchedule.parse({'v': 99, 'e': <String, dynamic>{}}),
isEmpty,
);
});
test('non-map entries container is empty', () {
expect(BudgetSchedule.parse({'v': 1, 'e': 'nope'}), isEmpty);
});
test('malformed entries are skipped', () {
final raw = {
'v': 1,
'e': {
'2026-07-26': 'not a record',
'2026-07-27': {'b': 'not an int'},
'2026-07-29': {'b': 2000, 't': '2026-07-29T00:00:00.000Z'},
},
};
expect(BudgetSchedule.parse(raw).map((e) => e.effectiveFrom).toList(), [
'2026-07-29',
]);
});
test('missing edit time falls back to the epoch', () {
final entries = BudgetSchedule.parse({
'v': 1,
'e': {
'2026-07-26': {'b': 2000},
},
});
expect(entries.single.editedAt, startsWith('1970-01-01'));
});
});
group('upsert', () {
test('appends a new day', () {
final schedule = BudgetSchedule([
_entry(kEpochDay, 2200),
]).upsert(2000, when: DateTime(2026, 7, 26, 10));
expect(schedule.entries.map((e) => e.effectiveFrom).toList(), [
kEpochDay,
'2026-07-26',
]);
expect(schedule.entries.last.kcal, 2000);
});
test('a second edit the same day replaces rather than appends', () {
var schedule = BudgetSchedule.empty.upsert(
2000,
when: DateTime(2026, 7, 26, 10),
);
schedule = schedule.upsert(1900, when: DateTime(2026, 7, 26, 18, 30));
expect(schedule.entries, hasLength(1));
expect(schedule.entries.single.kcal, 1900);
});
});
group('seed', () {
test('grandfathers the value to the epoch day', () {
final seeded = BudgetSchedule.seed(2200, DateTime.utc(2026, 7, 13, 19));
expect(seeded.single.effectiveFrom, kEpochDay);
expect(seeded.single.kcal, 2200);
expect(seeded.single.editedAt, startsWith('2026-07-13'));
});
test('a null edit time falls back to the epoch', () {
expect(
BudgetSchedule.seed(2200, null).single.editedAt,
startsWith('1970'),
);
});
});
}

View File

@ -1,5 +1,6 @@
import 'package:diet_guard_app/models/day_status.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/day_status_service.dart';
import 'package:flutter_test/flutter_test.dart';
@ -15,6 +16,35 @@ FoodEntry buildEntry({required double kcal, bool deleted = false}) => FoodEntry(
deleted: deleted,
);
FoodEntry buildMacroEntry({
required double proteinG,
required double carbsG,
required double fatG,
}) => FoodEntry(
time: '2026-01-01T12:00:00',
desc: 'test',
grams: 100,
kcal: 0,
proteinG: proteinG,
carbsG: carbsG,
fatG: fatG,
source: 'manual',
);
/// A schedule where one budget has applied since the beginning of time.
BudgetSchedule flat(int budget) => BudgetSchedule(
[const BudgetEntry(effectiveFrom: kEpochDay, kcal: 0, editedAt: '')]
.map(
(_) => BudgetEntry(
effectiveFrom: kEpochDay,
kcal: budget,
editedAt: '1970-01-01T00:00:00.000Z',
),
)
.toList(),
fallback: budget,
);
void main() {
group('dayTotalKcal', () {
test('sums entries for the day', () {
@ -39,6 +69,25 @@ void main() {
});
});
group('sumMacros', () {
test('sums protein, carbs and fat across entries', () {
final totals = sumMacros([
buildMacroEntry(proteinG: 10, carbsG: 20, fatG: 5),
buildMacroEntry(proteinG: 5, carbsG: 30, fatG: 2.5),
]);
expect(totals.proteinG, 15);
expect(totals.carbsG, 50);
expect(totals.fatG, 7.5);
});
test('is all zeroes for no entries', () {
final totals = sumMacros(const []);
expect(totals.proteinG, 0);
expect(totals.carbsG, 0);
expect(totals.fatG, 0);
});
});
group('dayStatus', () {
test('missing day is notLogged', () {
final log = <String, List<FoodEntry>>{};
@ -101,7 +150,7 @@ void main() {
'2026-01-01': [buildEntry(kcal: 1000)],
'2026-01-02': [buildEntry(kcal: 5000)],
};
expect(statusMap(log, budget: 2000), {
expect(statusMap(log, schedule: flat(2000)), {
'2026-01-01': DayStatus.green,
'2026-01-02': DayStatus.red,
});
@ -109,7 +158,7 @@ void main() {
test('empty log is empty map', () {
final log = <String, List<FoodEntry>>{};
expect(statusMap(log, budget: 2000), <String, DayStatus>{});
expect(statusMap(log, schedule: flat(2000)), <String, DayStatus>{});
});
});

View File

@ -3,19 +3,27 @@
// (real WorkManager isolate, manual on-device smoke test) per the project
// plan, and is excluded from coverage.
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_service.dart';
import 'package:diet_guard_app/services/document_store_io.dart';
import 'package:diet_guard_app/services/due_slot_check.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/notification_backend_io.dart';
import 'package:diet_guard_app/services/notification_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../fake_notifications.dart';
import '../fake_secure_storage.dart';
const _manual = Nutrition(
kcal: 200,
@ -34,6 +42,16 @@ void main() {
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_check_');
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
// The due-slot pull reuses backgroundSyncPush, which re-inits these two;
// pre-seeding the singletons keeps that off the real path_provider
// channel, exactly as background_sync_service_test.dart does.
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
notificationLog = installFakeAndroidNotifications();
NotificationService.resetForTesting(
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
@ -42,6 +60,9 @@ void main() {
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
NotificationService.resetForTesting();
await tempDir.delete(recursive: true);
});
@ -86,4 +107,105 @@ void main() {
isNotEmpty,
);
});
test('does not pull when nothing is due', () async {
await LogStorageService.instance.logMeal('breakfast', _manual, slot: 8);
SharedPreferences.setMockInitialValues({
'sync.owner': 'o',
'sync.repo': 'r',
});
installFakeSecureStorage(initial: {'sync.token': 't'});
var requests = 0;
final mock = MockClient((req) async {
requests++;
return http.Response('', 404);
});
await checkAndNotify(now: DateTime(2026, 1, 1, 8), httpClient: mock);
// Every due slot was already satisfied locally, so the network is never
// touched -- the check stays cheap on the overwhelmingly common tick.
expect(requests, 0);
});
test('pullWhenDue false skips the network even with a slot due', () async {
SharedPreferences.setMockInitialValues({
'sync.owner': 'o',
'sync.repo': 'r',
});
installFakeSecureStorage(initial: {'sync.token': 't'});
var requests = 0;
final mock = MockClient((req) async {
requests++;
return http.Response('', 404);
});
await checkAndNotify(
now: DateTime(2026, 1, 1, 16),
httpClient: mock,
pullWhenDue: false,
);
expect(requests, 0);
// Still reconciles notifications from the local log alone.
expect(
notificationLog.where((c) => c.method == 'show'),
isNotEmpty,
);
});
test('a meal pulled from another device cancels its reminder', () async {
SharedPreferences.setMockInitialValues({
'sync.owner': 'o',
'sync.repo': 'r',
});
installFakeSecureStorage(initial: {'sync.token': 't'});
// Must be *today* in the real clock's terms: `now` only steers which
// slots are due, while "which day's entries count" comes from the
// system date inside loggedSlotsToday.
final today = DateTime.now();
final day =
'${today.year.toString().padLeft(4, '0')}-'
'${today.month.toString().padLeft(2, '0')}-'
'${today.day.toString().padLeft(2, '0')}';
// The PC pushed a 12:00 meal this device has never seen locally.
final remote =
'{"pc-entry":{"id":"pc-entry","fields":{"body":[{"time":'
'"${day}T12:30:00+01:00","desc":"pc lunch","kcal":500,'
'"protein_g":30,"carbs_g":50,"fat_g":15,"grams":300,'
'"source":"manual","slot":12,"day":"$day"},'
'"${day}T11:30:00.000Z-0000-pc"]},"deleted":false,'
'"deleted_hlc":null}}';
final mock = MockClient((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
if (req.method == 'GET' && req.url.pathSegments.length == 3) {
return http.Response('{}', 200);
}
if (req.url.path.endsWith('/devices')) {
return http.Response('[{"name":"pc","type":"dir"}]', 200);
}
if (req.url.path.endsWith('/devices/pc/food_log.json')) {
// The Contents API returns base64 in a `content` field, not raw JSON.
return http.Response(
jsonEncode({'content': base64.encode(utf8.encode(remote))}),
200,
);
}
return http.Response('', 404);
});
await checkAndNotify(
now: DateTime(today.year, today.month, today.day, 12),
httpClient: mock,
);
// This is the false alarm the fix exists to kill: 12:00 was unlogged
// locally, so the pre-fix code fired a reminder for a meal that was
// already logged on the PC.
final shown = notificationLog
.where((c) => c.method == 'show')
.map((c) => (c.arguments as Map)['id'])
.toSet();
expect(shown, isNot(contains(12)));
});
}

View File

@ -1,12 +1,10 @@
// The IndexedDB stores are the desktop device's primary copy of the food log
// and the *only* copy of its photos, and their wrapper-mirror fallback is the
// recovery path for a wiped Chrome profile. `idb_shim`'s in-memory backend
// lets all of that run on the plain VM, so none of it has to ship untested
// just because it only executes in a browser.
// The IndexedDB document store is the desktop device's primary copy of the
// food log, and its wrapper-mirror fallback is the recovery path for a wiped
// Chrome profile. `idb_shim`'s in-memory backend lets all of that run on the
// plain VM, so none of it has to ship untested just because it only executes
// in a browser.
import 'dart:convert';
import 'dart:typed_data';
import 'package:diet_guard_app/services/blob_store_indexeddb.dart';
import 'package:diet_guard_app/services/desktop_wrapper.dart';
import 'package:diet_guard_app/services/document_store_indexeddb.dart';
import 'package:flutter_test/flutter_test.dart';
@ -94,54 +92,4 @@ void main() {
expect(await store.read('food_log.json'), '{"a":1}');
});
});
group('IndexedDbBlobStore', () {
late IndexedDbBlobStore store;
setUp(() async {
store = IndexedDbBlobStore(
await _openMemoryDb(IndexedDbBlobStore.storeName),
httpClient: wrapper.client,
);
});
test('put returns a key ending in the extension and stores the bytes', () async {
final key = await store.put('.jpg', Uint8List.fromList([1, 2, 3]));
expect(key, endsWith('.jpg'));
expect(await store.get(key), [1, 2, 3]);
});
test('put gives each photo its own key', () async {
final first = await store.put('.jpg', Uint8List.fromList([1]));
final second = await store.put('.jpg', Uint8List.fromList([1]));
expect(first, isNot(second));
});
test('mirrors photo bytes to the wrapper', () async {
// Photos never sync -- `imagePath` is stripped before push -- so this
// mirror is the only thing standing between a cleared profile and
// losing them permanently.
final key = await store.put('.jpg', Uint8List.fromList([7, 7]));
expect(wrapper.stored[key], [7, 7]);
});
test('recovers a photo from the wrapper when IndexedDB is empty', () async {
wrapper.stored['photo.jpg'] = [4, 2];
expect(await store.get('photo.jpg'), [4, 2]);
});
test('returns null for a photo neither side has', () async {
expect(await store.get('gone.jpg'), null);
});
test('returns null when the wrapper is not running', () async {
wrapper.offline = true;
expect(await store.get('gone.jpg'), null);
});
});
}

View File

@ -382,73 +382,4 @@ void main() {
expect(log['2026-06-22']!.first.desc, 'original desc');
});
});
group('lastLoggedEntry', () {
test('returns null for an empty log', () async {
expect(await LogStorageService.instance.lastLoggedEntry(), isNull);
});
test('returns the newest non-deleted entry across all days', () async {
const older = FoodEntry(
id: 'older',
time: '2026-06-01T08:00:00+02:00',
desc: 'older meal',
grams: 100,
kcal: 100,
proteinG: 5,
carbsG: 10,
fatG: 2,
source: 'manual',
);
const newer = FoodEntry(
id: 'newer',
time: '2026-06-22T20:00:00+02:00',
desc: 'newer meal',
grams: 100,
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 4,
source: 'manual',
);
await LogStorageService.instance.writeLog({
'2026-06-01': [older],
'2026-06-22': [newer],
});
final last = await LogStorageService.instance.lastLoggedEntry();
expect(last!.id, 'newer');
});
test('skips a tombstoned newest entry and falls through', () async {
const tombstoned = FoodEntry(
id: 'gone',
time: '2026-06-22T20:00:00+02:00',
desc: 'undone meal',
grams: 100,
kcal: 300,
proteinG: 1,
carbsG: 1,
fatG: 1,
source: 'manual',
deleted: true,
);
const kept = FoodEntry(
id: 'kept',
time: '2026-06-15T12:00:00+02:00',
desc: 'kept meal',
grams: 100,
kcal: 150,
proteinG: 5,
carbsG: 15,
fatG: 3,
source: 'manual',
);
await LogStorageService.instance.writeLog({
'2026-06-15': [kept],
'2026-06-22': [tombstoned],
});
final last = await LogStorageService.instance.lastLoggedEntry();
expect(last!.id, 'kept');
});
});
}

View File

@ -1,68 +0,0 @@
import 'dart:io';
import 'package:diet_guard_app/services/blob_store_io.dart';
import 'package:diet_guard_app/services/photo_attach_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
/// Returns a fixed [XFile] (or null, to simulate a cancelled picker) without
/// touching any real platform channel.
class _FakeImagePickerPlatform extends ImagePickerPlatform {
_FakeImagePickerPlatform(this._result);
final XFile? _result;
@override
Future<XFile?> getImageFromSource({
required ImageSource source,
ImagePickerOptions options = const ImagePickerOptions(),
}) async => _result;
}
void main() {
late Directory tempDir;
late ImagePickerPlatform originalPlatform;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_photo_');
originalPlatform = ImagePickerPlatform.instance;
PhotoAttachService.resetForTesting(store: FileBlobStore(tempDir));
});
tearDown(() async {
ImagePickerPlatform.instance = originalPlatform;
PhotoAttachService.resetForTesting();
await tempDir.delete(recursive: true);
});
test(
'copies the picked file into <documents>/images with a new name',
() async {
final source = File('${tempDir.path}/source.jpg')
..writeAsBytesSync([1, 2, 3, 4]);
ImagePickerPlatform.instance = _FakeImagePickerPlatform(
XFile(source.path),
);
final result = await PhotoAttachService.instance.pickAndStore(
ImageSource.gallery,
);
expect(result, isNotNull);
expect(result, startsWith('${tempDir.path}/images/'));
expect(result, endsWith('.jpg'));
expect(File(result!).readAsBytesSync(), [1, 2, 3, 4]);
},
);
test('returns null when the picker is cancelled', () async {
ImagePickerPlatform.instance = _FakeImagePickerPlatform(null);
final result = await PhotoAttachService.instance.pickAndStore(
ImageSource.camera,
);
expect(result, isNull);
});
}

View File

@ -8,8 +8,10 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/services/log_storage_service.dart';
import 'package:diet_guard_app/services/budget_schedule.dart';
import 'package:diet_guard_app/services/sync_merge.dart';
import 'package:flutter_test/flutter_test.dart';
@ -34,6 +36,19 @@ FoodEntry _entry({
DayLog _mergeDaylogs(DayLog a, DayLog b) =>
logToDayLog(mergeLogs(dayLogToLog(a), dayLogToLog(b)));
const _history = [
BudgetEntry(
effectiveFrom: '1970-01-01',
kcal: 2200,
editedAt: '2026-07-13T21:15:09.000Z',
),
BudgetEntry(
effectiveFrom: '2026-07-26',
kcal: 2000,
editedAt: '2026-07-26T10:00:00.000Z',
),
];
void main() {
group('union by id', () {
test('disjoint logs union into one', () {
@ -312,6 +327,208 @@ void main() {
});
});
group('derived food-bank adapters', () {
// Mirror of `test_sync_merge.py`'s TestFoodBankAdapters.
FoodBankRecord apple({required double count, double kcal = 95}) =>
FoodBankRecord(
desc: 'Apple',
kcal: kcal,
proteinG: 0.5,
carbsG: 25,
fatG: 0.3,
grams: 180,
count: count,
);
test('count is the clock', () {
final log = foodBankToLog({'apple': apple(count: 5)});
expect(log['apple']!.fields['body']!.$2.wallTimeMs, 5);
});
test('the higher count wins', () {
final behind = foodBankToLog({'apple': apple(count: 3, kcal: 90)});
final ahead = foodBankToLog({'apple': apple(count: 9, kcal: 95)});
final merged = logToFoodBank(mergeLogs(behind, ahead));
expect(merged['apple']!.count, 9);
expect(merged['apple']!.kcal, 95);
});
test('the merge is order independent', () {
final a = foodBankToLog({'apple': apple(count: 3)});
final b = foodBankToLog({'apple': apple(count: 9)});
expect(
logToFoodBank(mergeLogs(a, b))['apple']!.count,
logToFoodBank(mergeLogs(b, a))['apple']!.count,
);
});
test('re-merging is idempotent', () {
final once = foodBankToLog({'apple': apple(count: 5)});
expect(logToFoodBank(mergeLogs(once, once))['apple']!.count, 5);
});
test('different foods union', () {
final a = foodBankToLog({'apple': apple(count: 1)});
final b = foodBankToLog({
'pear': FoodBankRecord(
desc: 'Pear',
kcal: 57,
proteinG: 0,
carbsG: 15,
fatG: 0,
grams: 100,
count: 1,
),
});
expect(logToFoodBank(mergeLogs(a, b)).keys.toSet(), {'apple', 'pear'});
});
test('a non-map body is skipped on read', () {
final log = {
'apple': Record(
id: 'apple',
fields: {
'body': ('x', budgetHlc(const {'t': ''})),
},
),
};
expect(logToFoodBank(log), isEmpty);
});
test('parses pushed wire content', () {
final wire = encodeFoodBankForPush(
foodBankToLog({'apple': apple(count: 5)}),
);
expect(parseRemoteFoodBank(wire).containsKey('apple'), isTrue);
});
test('non-object top level throws FormatException', () {
expect(() => parseRemoteFoodBank('[1,2,3]'), throwsFormatException);
});
test('the wire shape matches the Python side', () {
final log = foodBankToLog({'apple': apple(count: 5)});
expect(log['apple']!.id, 'apple');
expect(log['apple']!.fields.keys.toList(), ['body']);
});
});
group('curated food-bank adapters', () {
// Mirror of `test_sync_merge.py`'s TestManualBankAdapters. A divergence
// here means a food curated on one device never reaches the other.
FoodBankRecord skyr({double kcal = 120, String? t}) => FoodBankRecord(
desc: 'Skyr',
kcal: kcal,
proteinG: 20,
carbsG: 5,
fatG: 0.5,
grams: 150,
count: 0,
editedAt: t ?? '2026-07-26T10:00:00.000Z',
);
test('round trips a record', () {
final back = logToManualBank(manualBankToLog({'skyr': skyr()}));
expect(back['skyr']!.desc, 'Skyr');
expect(back['skyr']!.kcal, 120);
});
test('the edit stamp does not travel inside the body', () {
final log = manualBankToLog({'skyr': skyr()});
expect(
(log['skyr']!.fields['body']!.$1 as Map).containsKey('t'),
isFalse,
);
});
test('edit time is reconstructed from the Hlc', () {
final back = logToManualBank(manualBankToLog({'skyr': skyr()}));
expect(back['skyr']!.editedAt, isNotNull);
});
test('a missing edit stamp falls back to the epoch', () {
final log = manualBankToLog({
'skyr': const FoodBankRecord(
desc: 'Skyr',
kcal: 1,
proteinG: 0,
carbsG: 0,
fatG: 0,
grams: 0,
count: 0,
),
});
expect(log['skyr']!.fields['body']!.$2.wallTimeMs, 0);
});
test('a newer edit wins the merge', () {
final older = manualBankToLog({'skyr': skyr()});
final newer = manualBankToLog({
'skyr': skyr(kcal: 999, t: '2999-01-01T00:00:00.000Z'),
});
expect(logToManualBank(mergeLogs(older, newer))['skyr']!.kcal, 999);
});
test('different foods union rather than replace', () {
final a = manualBankToLog({'skyr': skyr()});
final b = manualBankToLog({
'kefir': FoodBankRecord(
desc: 'Kefir',
kcal: 60,
proteinG: 3,
carbsG: 4,
fatG: 2,
grams: 100,
count: 0,
editedAt: '2026-07-26T10:00:00.000Z',
),
});
expect(logToManualBank(mergeLogs(a, b)).keys.toSet(), {'skyr', 'kefir'});
});
test('a non-map body is skipped on read', () {
final log = {
'skyr': Record(
id: 'skyr',
fields: {
'body': ('nope', budgetHlc(const {'t': ''})),
},
),
};
expect(logToManualBank(log), isEmpty);
});
test('a tombstoned record is dropped on read', () {
final log = {
'skyr': Record(
id: 'skyr',
fields: {
'body': (skyr().toJson(), budgetHlc(const {'t': ''})),
},
deleted: true,
),
};
expect(logToManualBank(log), isEmpty);
});
test('parses pushed wire content', () {
final wire = encodeManualBankForPush(manualBankToLog({'skyr': skyr()}));
expect(parseRemoteManualBank(wire).containsKey('skyr'), isTrue);
});
test('non-object top level throws FormatException', () {
expect(() => parseRemoteManualBank('[1,2,3]'), throwsFormatException);
});
test('the wire shape matches the Python side', () {
// Python uses the same `body` field name and the same normalized-name
// record id; a rename on either side silently splits the two banks.
final log = manualBankToLog({'skyr': skyr()});
expect(log['skyr']!.id, 'skyr');
expect(log['skyr']!.fields.keys.toList(), ['body']);
});
});
group('budget adapters', () {
Map<String, dynamic> budgetRecord({
int b = 2000,
@ -342,12 +559,103 @@ void main() {
expect(budgetToLog(null), isEmpty);
});
test('round trip preserves the budget and weight', () {
final record = budgetRecord(w: 80);
test('round trip preserves the budget', () {
final record = budgetRecord();
final roundTripped = logToBudget(budgetToLog(record));
expect(roundTripped, isNotNull);
expect(roundTripped!['b'], 2000);
expect(roundTripped['w'], 80);
});
test('weight never travels', () {
// `w` is PC-local, so it must not enter the shared value map: the
// phone rebuilds that map without it, and before this fix a winning
// phone edit silently deleted the PC's stored weight -- and with it
// the protein target. Matches Python's budget_to_log.
final record = budgetRecord(w: 80);
final roundTripped = logToBudget(budgetToLog(record));
expect(roundTripped, isNotNull);
expect(roundTripped!.containsKey('w'), isFalse);
});
test('history becomes one field per date', () {
final log = budgetToLog(budgetRecord(), _history);
final fields = log[budgetRecordId]!.fields;
expect(fields.containsKey('value'), isTrue);
expect(fields['hist:1970-01-01']!.$1, 2200);
expect(fields['hist:2026-07-26']!.$1, 2000);
});
test('history round trips back to entries', () {
final entries = logToHistory(budgetToLog(budgetRecord(), _history));
expect(entries.map((e) => e.effectiveFrom).toList(), [
'1970-01-01',
'2026-07-26',
]);
expect(entries.map((e) => e.kcal).toList(), [2200, 2000]);
});
test('no history round trips to nothing', () {
expect(logToHistory(budgetToLog(budgetRecord())), isEmpty);
});
test('an empty log has no history', () {
expect(logToHistory(<String, Record>{}), isEmpty);
});
test('non-history fields are ignored', () {
final log = budgetToLog(budgetRecord());
expect(logToHistory(log), isEmpty);
});
test('a non-int history value is skipped', () {
final log = {
budgetRecordId: Record(
id: budgetRecordId,
fields: {
'hist:2026-07-26': ('nonsense', budgetHlc(budgetRecord())),
},
),
};
expect(logToHistory(log), isEmpty);
});
test('an unparsable edit time still yields a field', () {
const broken = BudgetEntry(
effectiveFrom: '2026-07-26',
kcal: 2000,
editedAt: 'not a timestamp',
);
final log = budgetToLog(budgetRecord(), [broken]);
expect(
log[budgetRecordId]!.fields.containsKey('hist:2026-07-26'),
isTrue,
);
});
test('history survives a merge with a history-free peer', () {
// The rollout guarantee: mergeRecord is per-field LWW over the
// *union* of field names, so a peer that only pushes `value` leaves
// `hist:*` untouched -- which is why this needed no coordinated
// release. Mirrors the Python test of the same name.
final ours = budgetToLog(budgetRecord(), _history);
final legacy = {
budgetRecordId: Record(
id: budgetRecordId,
fields: {
'value': (
{'v': 2, 'b': 2200},
budgetHlc({'t': '2099-01-01T00:00:00Z'}),
),
},
),
};
expect(logToHistory(mergeLogs(ours, legacy)), hasLength(2));
});
test('the wire field names match the Python side exactly', () {
// A rename on either side silently splits the two devices' history.
expect(budgetHistoryFieldPrefix, 'hist:');
expect(budgetRecordId, 'budget');
});
test('round-tripped t reflects the winning Hlc', () {

View File

@ -1,7 +1,6 @@
// Mirrors `test_sync.py`'s `TestRunSync` and `TestSyncBudget` cases
// (own-id-skip, no-prior-push, non-object payload, corrupt JSON, remote
// merge, food bank rebuild, budget last-writer-wins), plus one Dart-specific
// case for the phone's `imagePath`-preserve-by-id step (plan decision 10)
// that has no PC-side equivalent.
import 'dart:convert';
@ -12,6 +11,7 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_entry.dart';
import 'package:diet_guard_app/models/nutrition.dart';
import 'package:diet_guard_app/services/app_settings_service.dart';
import 'package:diet_guard_app/services/budget_history_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/sync_merge.dart';
@ -125,12 +125,16 @@ void main() {
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
FoodBankService.resetForTesting(store: FileDocumentStore(tempDir));
await AppSettingsService.initForTesting(FileDocumentStore(tempDir));
BudgetHistoryService.resetForTesting(
store: FileDocumentStore(tempDir),
);
});
tearDown(() async {
LogStorageService.resetForTesting();
FoodBankService.resetForTesting();
AppSettingsService.resetForTesting();
BudgetHistoryService.resetForTesting();
await tempDir.delete(recursive: true);
});
@ -140,9 +144,9 @@ void main() {
final merged = await runSync(fake.buildClient());
expect(merged.values.expand((e) => e).length, 1);
// One food_log.json push, plus one budget.json push (syncLog always
// pushes, even an empty merged budget when nothing's been set yet).
expect(fake.puts, hasLength(2));
// syncLog always pushes, even an empty merged result: food_log.json,
// budget.json, food_bank.json, food_bank_manual.json.
expect(fake.puts, hasLength(4));
});
test("skips its own device id ('phone') when listing", () async {
@ -260,11 +264,10 @@ void main() {
expect(bank.containsKey('oatmeal'), isTrue);
});
test('pushes a payload without imagePath or hmac', () async {
test('pushes a payload without hmac', () async {
await LogStorageService.instance.logMeal(
'oatmeal',
_manual,
imagePath: '/local/photo.jpg',
);
final fake = _FakeGitHub();
await runSync(fake.buildClient());
@ -272,7 +275,6 @@ void main() {
final pushed =
fake.putsByPath['diet-guard-sync/devices/phone/food_log.json']!;
final pushedText = utf8.decode(base64.decode(pushed['content'] as String));
expect(pushedText, isNot(contains('imagePath')));
expect(pushedText, isNot(contains('hmac')));
});
@ -301,53 +303,6 @@ void main() {
);
});
test(
'preserves a local imagePath even when a remote tombstone wins the merge',
() async {
await LogStorageService.instance.writeLog({
'2026-06-22': [
const FoodEntry(
id: 'x',
time: '2026-06-22T08:00:00',
desc: 'photo meal',
grams: 100,
kcal: 200,
proteinG: 10,
carbsG: 20,
fatG: 5,
source: 'manual',
imagePath: '/local/photo.jpg',
),
],
});
final remoteLog = jsonEncode({
'2026-06-22': [
{
'id': 'x',
'time': '2026-06-22T08:00:00',
'desc': 'photo meal',
'kcal': 200.0,
'protein_g': 10.0,
'carbs_g': 20.0,
'fat_g': 5.0,
'grams': 100.0,
'source': 'manual',
'deleted': true,
},
],
});
final fake = _FakeGitHub(
deviceDirs: const ['pc'],
files: {'diet-guard-sync/devices/pc/food_log.json': remoteLog},
);
final merged = await runSync(fake.buildClient());
final entry = merged.values.expand((e) => e).single;
expect(entry.deleted, isTrue);
expect(entry.imagePath, '/local/photo.jpg');
},
);
group('budget sync', () {
test(
'pushes the local budget when no other devices have synced',

View File

@ -0,0 +1,93 @@
import 'package:diet_guard_app/widgets/today_progress_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
TodayProgress _progress({
String justLogged = 'toast',
double consumedKcal = 1450,
int budgetKcal = 2200,
double proteinG = 90,
double carbsG = 120,
double fatG = 40,
int adherenceStreak = 3,
}) => TodayProgress(
justLogged: justLogged,
consumedKcal: consumedKcal,
budgetKcal: budgetKcal,
proteinG: proteinG,
carbsG: carbsG,
fatG: fatG,
adherenceStreak: adherenceStreak,
);
Future<void> _pump(WidgetTester tester, TodayProgress progress) =>
tester.pumpWidget(
MaterialApp(
home: Scaffold(body: TodayProgressCard(progress: progress)),
),
);
void main() {
group('TodayProgress', () {
test('remaining is budget minus consumed', () {
expect(_progress().remainingKcal, 750);
expect(_progress().isOverBudget, isFalse);
});
test('remaining goes negative once past the budget', () {
final over = _progress(consumedKcal: 2500);
expect(over.remainingKcal, -300);
expect(over.isOverBudget, isTrue);
});
test('exactly on the budget is not over', () {
final exact = _progress(consumedKcal: 2200);
expect(exact.remainingKcal, 0);
expect(exact.isOverBudget, isFalse);
});
});
group('TodayProgressCard', () {
testWidgets('shows the meal, the kcal position, and macros', (
tester,
) async {
await _pump(tester, _progress());
expect(find.text('Logged "toast".'), findsOneWidget);
expect(find.text('1450 / 2200'), findsOneWidget);
expect(find.text('750 left'), findsOneWidget);
expect(find.text('P 90g · C 120g · F 40g'), findsOneWidget);
});
testWidgets('reports the overshoot in the error color when over', (
tester,
) async {
await _pump(tester, _progress(consumedKcal: 2500));
expect(find.text('300 over'), findsOneWidget);
final total = tester.widget<Text>(find.text('2500 / 2200'));
final context = tester.element(find.text('2500 / 2200'));
expect(total.style?.color, Theme.of(context).colorScheme.error);
});
testWidgets('pluralises the adherence streak', (tester) async {
await _pump(tester, _progress(adherenceStreak: 1));
expect(find.text('Adherence streak: 1 day'), findsOneWidget);
await _pump(tester, _progress(adherenceStreak: 12));
expect(find.text('Adherence streak: 12 days'), findsOneWidget);
});
testWidgets('rounds fractional kcal and macros for display', (
tester,
) async {
await _pump(
tester,
_progress(consumedKcal: 1450.6, proteinG: 90.4, carbsG: 0, fatG: 0),
);
expect(find.text('1451 / 2200'), findsOneWidget);
expect(find.text('P 90g · C 0g · F 0g'), findsOneWidget);
});
});
}

View File

@ -10,3 +10,11 @@ The package has three layers, built in order:
* the escalation (later): a daily report that tightens the other personal
enforcers (games, PC uptime) when logging is skipped or the budget blown.
"""
from diet_guard import _test_guard
# Under pytest only: hard-fail any write to the user's real state directories.
# A test once destroyed the user's GitHub PAT by running outside the package
# conftest that redirects those paths; this makes that class of accident
# impossible rather than merely discouraged. See diet_guard/_test_guard.py.
_test_guard.install()

View File

@ -11,6 +11,10 @@ This is a deliberate design change from the file's previous ``chattr +i``
seal, which existed specifically to make impulsive "make room" edits
require a deliberate root step. That friction is gone by design; nothing in
this module tries to reintroduce it.
Everything here answers "what is the budget *now*". "What was the budget on
2026-06-14?" is :mod:`diet_guard._budget_history`, which :func:`write_budget`
keeps up to date on every edit.
"""
from __future__ import annotations
@ -20,6 +24,14 @@ from datetime import datetime, timezone
import json
import logging
from diet_guard._budget_history import (
BudgetSchedule,
history_to_json,
load_entries,
record_budget_change,
seed_from_budget,
write_raw_history,
)
from diet_guard._constants import BUDGET_FILE
_logger = logging.getLogger(__name__)
@ -135,6 +147,35 @@ def is_initialized() -> bool:
return BUDGET_FILE.exists()
def _seed_history_if_empty(record: dict[str, object] | None) -> None:
"""Grandfather ``record``'s budget to the epoch if no history exists yet.
Guards on the history being **empty**, not on the file being absent. An
empty-but-present document is reachable in normal operation -- notably
:func:`diet_guard._sync._sync_budget` writing back a merge that carried no
``hist:`` fields (a pre-feature peer) -- and a presence-based guard would
then never seed again, leaving a history of only ``{today: <new value>}``
so every past day falls through to the *current* budget. That is exactly
the retroactive reclassification this module exists to prevent.
"""
if load_entries():
return
seeded = seed_from_budget(record)
if seeded:
write_raw_history(history_to_json(seeded))
def current_schedule(*, default: int) -> BudgetSchedule:
"""Return the budget history as a schedule, seeding it if still empty.
Lives here rather than in :mod:`diet_guard._budget_history` so the seeding
logic sits with the module that owns the value being grandfathered; that
also keeps ``_budget_history`` free of any import back into this module.
"""
_seed_history_if_empty(read_raw_record())
return BudgetSchedule(load_entries(), default=default)
def write_budget(value: int, *, weight_kg: float | None = None) -> None:
"""Write ``value`` as the daily kcal budget, plainly (no seal, no signing).
@ -143,6 +184,17 @@ def write_budget(value: int, *, weight_kg: float | None = None) -> None:
know *when* this write happened to resolve a last-edit-wins merge
against another device's write).
Also the single funnel that maintains the effective-from history (see
:mod:`diet_guard._budget_history`), so past days keep being judged
against the budget that applied to them.
The ordering below is load-bearing and must not be rearranged: the
*pre-write* record is grandfathered to :data:`EPOCH_DAY` **before** the
new value is recorded for today. Seeding afterwards would leave a
history of only ``{today: <new value>}``, which makes every past day
resolve to the new value -- precisely the retroactive reclassification
the history exists to prevent.
Args:
value: The daily budget in kcal.
weight_kg: Body weight in kg to store alongside the budget, so a
@ -150,6 +202,8 @@ def write_budget(value: int, *, weight_kg: float | None = None) -> None:
writes a budget-only record that reads back with no protein
target.
"""
existing = read_raw_record()
_seed_history_if_empty(existing)
record: dict[str, object] = {
"v": _FILE_VERSION,
"b": int(value),
@ -158,6 +212,7 @@ def write_budget(value: int, *, weight_kg: float | None = None) -> None:
if weight_kg is not None:
record["w"] = round(float(weight_kg), 1)
write_raw_record(record)
record_budget_change(int(value))
def _read_record() -> dict[str, object]:

View File

@ -0,0 +1,233 @@
"""Effective-from history of the daily kcal budget.
The budget is a single, freely-editable number (see :mod:`diet_guard._budget`),
but *classifying a past day* must use the budget that applied on that day --
otherwise lowering the budget silently reclassifies months of history, breaking
the adherence streak and the year-to-date tally for days that were in fact
adherent at the time.
The history is a forward-only list of ``(effective_from, kcal)`` entries. The
budget for a day is the newest entry whose ``effective_from`` is on or before
it. Editing the budget appends (or, for a same-day re-edit, replaces) the
entry for that day; every earlier day keeps whatever applied then.
Storage and sync are deliberately split:
* the entries live in their own file (``.budget_history``), leaving
:mod:`diet_guard._budget`'s ``.budget`` schema untouched at v2;
* they travel as extra ``hist:<YYYY-MM-DD>`` *fields* on the existing
``budget`` CRDT record (see :mod:`diet_guard._sync_merge`).
That second point is the load-bearing one. ``crdt_sync``'s ``merge_record``
does per-field last-writer-wins over the *union* of field names, and both
devices push the merged record rather than their own, so a device that knows
nothing about history can neither clobber those fields nor stop them
propagating -- it relays them untouched. No coordinated release is needed.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import logging
from diet_guard._constants import BUDGET_HISTORY_FILE
_logger = logging.getLogger(__name__)
_FILE_VERSION = 1
# The effective-from date the migration seeds, so the pre-history budget
# covers every day that was ever logged. Any real date is >= this.
EPOCH_DAY = "1970-01-01"
_EPOCH_ISO = "1970-01-01T00:00:00+00:00"
@dataclass(frozen=True)
class BudgetEntry:
"""One budget value and the date it started applying.
Attributes:
effective_from: ``YYYY-MM-DD``; the first day this value applies to.
kcal: The daily budget in kcal from that day onward.
edited_at: Local ISO-8601 timestamp of the edit that created it, used
to derive a deterministic Hlc for the sync merge.
"""
effective_from: str
kcal: int
edited_at: str
@dataclass(frozen=True)
class BudgetSchedule:
"""The budget as a function of the day, with a fallback for empty history.
Attributes:
entries: Ascending by ``effective_from``.
default: Used when no entry applies -- an unset device, or a day
before the earliest entry. In practice the second case never
arises, since the migration always seeds :data:`EPOCH_DAY`.
"""
entries: tuple[BudgetEntry, ...]
default: int
def for_day(self, day: str) -> int:
"""Return the budget that applied on ``day`` (a ``YYYY-MM-DD`` key)."""
applicable = [e for e in self.entries if e.effective_from <= day]
if not applicable:
return self.default
return applicable[-1].kcal
def history_from_json(raw: object) -> tuple[BudgetEntry, ...]:
"""Parse a stored history document, tolerating anything malformed.
Deliberately single-branched on bad input: a corrupt or unrecognised
history degrades to "no history", which callers already handle by falling
back to the current scalar budget. Every extra rejection branch would be
another branch the 100%-coverage gate has to reach for no behavioural gain.
Args:
raw: The parsed JSON document, of any shape.
Returns:
The entries, ascending by ``effective_from`` (empty if unusable).
"""
if not isinstance(raw, dict) or raw.get("v") != _FILE_VERSION:
return ()
stored = raw.get("e")
if not isinstance(stored, dict):
return ()
entries = []
for day, record in stored.items():
if not isinstance(record, dict):
continue
kcal = record.get("b")
if isinstance(kcal, bool) or not isinstance(kcal, int):
continue
edited_at = record.get("t")
entries.append(
BudgetEntry(
effective_from=str(day),
kcal=kcal,
edited_at=edited_at if isinstance(edited_at, str) else _EPOCH_ISO,
),
)
return tuple(sorted(entries, key=lambda e: e.effective_from))
def history_to_json(entries: tuple[BudgetEntry, ...]) -> dict[str, object]:
"""Serialize ``entries`` to the stored document shape."""
return {
"v": _FILE_VERSION,
"e": {e.effective_from: {"b": e.kcal, "t": e.edited_at} for e in entries},
}
def upsert(
entries: tuple[BudgetEntry, ...],
*,
kcal: int,
when: datetime,
) -> tuple[BudgetEntry, ...]:
"""Return ``entries`` with ``when``'s date set to ``kcal``.
Keyed on the date, so editing the budget twice in one day replaces that
day's entry instead of accumulating duplicates.
Args:
entries: The current history.
kcal: The new budget value.
when: The edit time; its local date becomes ``effective_from``.
Returns:
The updated history, ascending by ``effective_from``.
"""
day = when.date().isoformat()
kept = [e for e in entries if e.effective_from != day]
kept.append(
BudgetEntry(
effective_from=day,
kcal=int(kcal),
edited_at=when.isoformat(timespec="seconds"),
),
)
return tuple(sorted(kept, key=lambda e: e.effective_from))
def seed_from_budget(record: dict[str, object] | None) -> tuple[BudgetEntry, ...]:
"""Return the one-entry history that grandfathers an existing budget.
The pre-history budget is treated as having applied since
:data:`EPOCH_DAY`, so every already-logged day keeps the value it was
actually judged against. Reuses the record's own ``t`` rather than "now",
which is what lets two devices seed independently and still converge: both
derive the same value at the same Hlc wall time.
Args:
record: The raw ``.budget`` record, or None if never initialised.
Returns:
A single-entry history, or empty when there is no budget to grandfather.
"""
if record is None:
return ()
kcal = record.get("b")
if isinstance(kcal, bool) or not isinstance(kcal, int):
return ()
edited_at = record.get("t")
return (
BudgetEntry(
effective_from=EPOCH_DAY,
kcal=kcal,
edited_at=edited_at if isinstance(edited_at, str) else _EPOCH_ISO,
),
)
def read_raw_history() -> dict[str, object] | None:
"""Return the on-disk history document verbatim, or None if absent/corrupt.
Never raises: an unreadable history means "fall back to the scalar
budget", which is exactly the pre-history behaviour.
"""
if not BUDGET_HISTORY_FILE.exists():
return None
try:
with BUDGET_HISTORY_FILE.open() as handle:
document = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(document, dict):
return None
return document
def write_raw_history(document: dict[str, object]) -> None:
"""Persist ``document`` verbatim, overwriting the history file."""
BUDGET_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
with BUDGET_HISTORY_FILE.open("w") as handle:
json.dump(document, handle)
def load_entries() -> tuple[BudgetEntry, ...]:
"""Return the stored history, or empty if none has been written yet.
Deliberately does not reach into :mod:`diet_guard._budget` to lazily seed:
that would be a circular import, and it would hide *where* seeding happens.
Seeding lives in :func:`diet_guard._budget.current_schedule` and
:func:`diet_guard._budget.write_budget`, which own the value being
grandfathered.
"""
raw = read_raw_history()
return () if raw is None else history_from_json(raw)
def record_budget_change(kcal: int, *, when: datetime | None = None) -> None:
"""Record a budget edit, effective from the day it was made."""
moment = when if when is not None else datetime.now(tz=timezone.utc).astimezone()
write_raw_history(history_to_json(upsert(load_entries(), kcal=kcal, when=moment)))

View File

@ -37,7 +37,7 @@ from diet_guard._portions import (
estimate_unit_grams,
)
from diet_guard._resolve import ManualMacros, resolve_nutrition
from diet_guard._slots import current_slot, day_slots, slot_label
from diet_guard._slots import day_slots, slot_for_log, slot_label
from diet_guard._state import (
entry_kcal,
log_meal,
@ -344,7 +344,7 @@ def _cmd_ate(description: str, portion: _Portion, macros: _ManualMacros) -> int:
"re-run with --kcal <number> to log it manually.",
)
return 1
log_meal(description, nutrition, current_slot(now_local()))
log_meal(description, nutrition, slot_for_log(now_local()))
remember_food(description, nutrition)
macro_str = f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
portion_str = f"{nutrition.grams:g} g" if nutrition.grams else "portion n/a"

View File

@ -26,12 +26,22 @@ FOOD_LOG_FILE: Path = DATA_DIR / "food_log.json"
# searches -- Open Food Facts is used to *fill* a new food's macros, never to
# search. Local-only, git-ignored.
FOOD_BANK_FILE: Path = DATA_DIR / "food_bank.json"
# Hand-curated bank entries (see _foodbank_manual.py): foods added without
# ever logging them, so they are NOT derivable from the food log and must
# sync in their own right. Kept separate from FOOD_BANK_FILE because that
# one is rewritten wholesale on every log write.
MANUAL_BANK_FILE: Path = DATA_DIR / "food_bank_manual.json"
# The budget: a plain JSON dotfile alongside the log, freely editable on this
# device or the phone app and synced between them (see _sync.py).
# Git-ignored, never committed. "Hidden" here means never-online (it lives
# outside the repo) -- the number itself is shown freely in local CLI/GUI/app
# output.
BUDGET_FILE: Path = DATA_DIR / ".budget"
# The effective-from history of that budget (see _budget_history.py), kept in
# its own file so ``.budget``'s schema stays at v2 and every existing reader
# of it is untouched. Classifying a *past* day reads this; everything about
# *today* still reads BUDGET_FILE. Git-ignored, never committed.
BUDGET_HISTORY_FILE: Path = DATA_DIR / ".budget_history"
# --- Estimator (Open Food Facts) -------------------------------------------
# The default backend is Open Food Facts' "Search-a-licious" full-text search:

View File

@ -12,10 +12,15 @@ DAY STATUS SPEC (keep in sync with ``app/lib/services/day_status_service.dart``)
* the adherence streak breaks on a red or a not_logged day; yellow and green
both keep it alive.
Every function here is a pure function of an explicit ``log``/``status_map``
argument (never reaching into on-disk state itself), so the boundary matrix
above is trivially testable with synthetic data. :func:`current_status_map`
is the one exception: a thin convenience wrapper for real UI callers.
Every function here is a pure function of its explicit ``log`` /
``schedule`` / ``status_map`` arguments (never reaching into on-disk state
itself), so the boundary matrix above is trivially testable with synthetic
data.
``budget`` is per-day, not a single number: :func:`status_map` resolves each
day through a :class:`~diet_guard._budget_history.BudgetSchedule` so that
lowering the budget today does not retroactively reclassify every past day
(see :mod:`diet_guard._budget_history`).
"""
from __future__ import annotations
@ -25,11 +30,11 @@ from datetime import date, timedelta
import enum
from typing import TYPE_CHECKING
from diet_guard._budget import daily_budget
from diet_guard._constants import BUDGET_WARN_FRACTION
from diet_guard._state import entry_kcal, load_log, now_local
from diet_guard._state import entry_kcal, now_local
if TYPE_CHECKING:
from diet_guard._budget_history import BudgetSchedule
from diet_guard._state import DayLog
@ -96,14 +101,17 @@ def day_status(log: DayLog, day: str, budget: int) -> DayStatus:
return DayStatus.RED
def status_map(log: DayLog, *, budget: int) -> dict[str, DayStatus]:
def status_map(log: DayLog, *, schedule: BudgetSchedule) -> dict[str, DayStatus]:
"""Return a :class:`DayStatus` for every date key present in ``log``.
Each day is judged against the budget that applied *on that day*, so a
later budget change leaves earlier days classified exactly as before.
Days with no log entries are simply absent from the result; a caller
rendering a full calendar treats a missing key as
:attr:`DayStatus.NOT_LOGGED`.
"""
return {day: day_status(log, day, budget) for day in log}
return {day: day_status(log, day, schedule.for_day(day)) for day in log}
def _streak(
@ -185,17 +193,3 @@ def year_to_date_tally(
elapsed_days=elapsed_days,
adherent_days=adherent_days,
)
def current_status_map() -> dict[str, DayStatus]:
"""Return :func:`status_map` for the real on-disk log and budget.
Convenience wrapper for UI callers; the pure functions above take
explicit ``log``/``budget``/``status_map_`` arguments so they stay
trivially testable with synthetic data.
Raises:
BudgetNotInitializedError: If no budget has been set yet.
BudgetFileCorruptError: If the budget file is corrupt.
"""
return status_map(load_log(), budget=daily_budget())

View File

@ -19,16 +19,12 @@ from __future__ import annotations
import json
import logging
import time
from typing import TYPE_CHECKING
from diet_guard._coerce import as_float
from diet_guard._constants import FOOD_BANK_FILE
from diet_guard._estimator import Nutrition
from diet_guard._foodbank_manual import read_manual_bank
from diet_guard._fuzzy import match_score
from diet_guard._meal import MealItem, meal_total
if TYPE_CHECKING:
from collections.abc import Sequence
_logger = logging.getLogger(__name__)
@ -107,6 +103,16 @@ def _write_bank(bank: dict[str, BankRecord]) -> None:
json.dump(bank, handle, indent=2, sort_keys=True)
def read_food_bank() -> dict[str, BankRecord]:
"""Return the derived bank verbatim, for the sync layer."""
return _read_bank()
def write_food_bank(bank: dict[str, BankRecord]) -> None:
"""Persist a merged derived bank, for the sync layer."""
_write_bank(bank)
def _record_to_nutrition(record: BankRecord) -> Nutrition:
"""Build a :class:`Nutrition` from a stored bank record.
@ -142,28 +148,6 @@ def remember_food(description: str, nutrition: Nutrition) -> None:
_upsert(description, nutrition, components=None)
def remember_meal(name: str, items: Sequence[MealItem]) -> Nutrition:
"""Bank each component and the composite meal, returning the summed macros.
Each item is remembered on its own (so it autocompletes next time) and the
meal is stored as one entry carrying its summed macros plus its component
names, so the whole meal can be re-picked later as a single summed food. A
blank meal name still banks the items but stores no empty-keyed composite.
Args:
name: The composite meal's name (e.g. ``"dinner"``).
items: The meal's components, each with its own nutrition.
Returns:
The summed nutrition for the whole meal.
"""
for item in items:
remember_food(item.name, item.nutrition)
total = meal_total(items)
_upsert(name, total, components=[item.name for item in items])
return total
def _apply_upsert(
bank: dict[str, BankRecord],
description: str,
@ -211,8 +195,10 @@ def _upsert(
) -> None:
"""Insert or refresh one bank record on disk, bumping its use count.
Shared by :func:`remember_food` (a single food) and :func:`remember_meal`
(a composite, which additionally records its ``components``).
The ``components`` path is now only reached by
:func:`rebuild_food_bank` replaying composite entries already in the log
-- nothing creates a new one, but historical composites keep re-banking
their parts.
Args:
description: The food or meal name (its normalized form is the key).
@ -292,16 +278,30 @@ def rebuild_food_bank(log: dict[str, list[dict[str, object]]]) -> dict[str, Bank
return bank
def _all_records() -> dict[str, BankRecord]:
"""Return curated entries merged under the log-derived ones.
Log-derived records win on a name collision: they carry a real ``count``
and the macros the food was actually logged with, whereas a curated entry
is only a starting point. Mirrors ``foodbank_service.dart``'s
``{...manualBank, ...logBank}``.
"""
return {**read_manual_bank(), **_read_bank()}
def lookup_food(description: str) -> Nutrition | None:
"""Return the exact-match macros for ``description``, or None.
Searches curated entries as well as logged ones, so a food added by hand
on either device resolves here too.
Args:
description: The food name to look up verbatim (case-insensitive).
Returns:
The stored Nutrition, or None if the food is not banked.
"""
record = _read_bank().get(_normalize(description))
record = _all_records().get(_normalize(description))
return _record_to_nutrition(record) if record is not None else None
@ -319,7 +319,9 @@ def search_foods(
An empty query returns the most-logged foods (the expandable full list).
A non-empty query keeps substring and close-typo matches, ranked by match
quality then by use count.
quality then by use count. Searches curated entries (added by hand on
either device, see :mod:`diet_guard._foodbank_manual`) as well as logged
ones; a logged record wins on a name collision.
Args:
query: Free-text the user has typed so far.
@ -328,7 +330,7 @@ def search_foods(
Returns:
``(display_name, Nutrition)`` pairs, ranked, at most ``limit`` long.
"""
bank = _read_bank()
bank = _all_records()
normalized = _normalize(query)
if not normalized:
return _ranked_all(bank, limit)

View File

@ -0,0 +1,87 @@
"""Hand-curated food bank entries: foods added without ever eating them.
The main food bank (:mod:`diet_guard._foodbank`) is *derived* -- every record
is replayed from the synced food log, so both devices compute the same bank
from the same input and it needs no sync of its own. This module holds the
part that is **not** derivable: entries the user added by hand in the phone
app's food-bank screen, which never appear in any log.
Those were previously phone-only, which made them the last piece of
device-local state in the app. They now sync like everything else -- one
``crdt_sync`` record per normalized name, last-writer-wins by edit time -- so
a food added on the phone autocompletes in the PC gate and vice versa.
Kept in a separate file from the derived bank on purpose: the derived bank is
rewritten wholesale on every log write (:func:`diet_guard._foodbank.
rebuild_food_bank`), so storing curated entries there would lose them on the
next meal.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
from diet_guard._constants import MANUAL_BANK_FILE
_logger = logging.getLogger(__name__)
# On-disk shape: {normalized_name: {..bank record fields.., "t": <local ISO>}}.
# ``t`` is the edit timestamp the sync merge compares; it is not part of the
# record's nutritional meaning.
ManualRecord = dict[str, object]
_EPOCH_ISO = "1970-01-01T00:00:00+00:00"
def read_manual_bank() -> dict[str, ManualRecord]:
"""Return the hand-curated bank, or an empty dict on any error.
Never raises: a missing or unreadable file simply means "no curated
entries", which every caller already handles.
"""
if not MANUAL_BANK_FILE.exists():
return {}
try:
with MANUAL_BANK_FILE.open() as handle:
bank = json.load(handle)
except (OSError, json.JSONDecodeError):
_logger.warning("Manual food bank %s is unreadable", MANUAL_BANK_FILE)
return {}
if not isinstance(bank, dict):
return {}
return {str(key): value for key, value in bank.items() if isinstance(value, dict)}
def write_manual_bank(bank: dict[str, ManualRecord]) -> None:
"""Persist ``bank`` verbatim, overwriting the file."""
MANUAL_BANK_FILE.parent.mkdir(parents=True, exist_ok=True)
with MANUAL_BANK_FILE.open("w") as handle:
json.dump(bank, handle)
def record_edit_time(record: ManualRecord) -> str:
"""Return ``record``'s edit timestamp, or the epoch when it has none."""
stamp = record.get("t")
return stamp if isinstance(stamp, str) else _EPOCH_ISO
def add_manual_entry(name: str, record: ManualRecord) -> None:
"""Add or replace the curated entry for ``name``, stamping the edit time.
Args:
name: The food's display name; its normalized form is the key.
record: The bank fields to store (``t`` is set here, not by callers).
"""
bank = read_manual_bank()
stamped = dict(record)
stamped["t"] = (
datetime.now(tz=timezone.utc)
.astimezone()
.isoformat(
timespec="seconds",
)
)
bank[name.strip().casefold()] = stamped
write_manual_bank(bank)

View File

@ -180,7 +180,6 @@ class MealGate(_GateCalendar):
on_unit_change=self._on_unit_change,
on_submit=self._on_submit,
on_close=self.close,
on_add_item=self._on_add_item,
on_fetch_sync=self._on_fetch_sync,
)
self._build()

View File

@ -26,6 +26,7 @@ from diet_guard._budget import (
BudgetFileCorruptError,
BudgetNotInitializedError,
budget_weight,
current_schedule,
daily_budget,
write_budget,
)
@ -121,12 +122,20 @@ class _GateCalendar(_GateMealFlow):
def _refresh_calendar(self) -> None:
"""Recompute the calendar grid, streaks, YTD tally, and budget field.
Each past day is judged against the budget that applied *then*
(:mod:`diet_guard._budget_history`), so changing the budget today
leaves the grid, the streaks, and the tally for earlier days alone.
A budget that was never set defaults to
:data:`_DEFAULT_BUDGET_KCAL`, matching the phone app, so the tab is
meaningful from a fresh install with no setup ritual. A genuinely
corrupt budget file is a real problem, not just "unset" -- that
still degrades to a neutral grid and an error message, instead of
raising through the tab and crashing (or failing open) the lock.
Degradation is deliberately asymmetric: an unreadable *history*
silently falls back to today's scalar budget (the pre-history
behaviour), because that is a display nicety, whereas an unreadable
budget is the number itself.
"""
log = load_log()
try:
@ -135,7 +144,11 @@ class _GateCalendar(_GateMealFlow):
budget = _DEFAULT_BUDGET_KCAL
except BudgetFileCorruptError:
budget = None
status_map_ = status_map(log, budget=budget) if budget is not None else None
status_map_ = (
status_map(log, schedule=current_schedule(default=budget))
if budget is not None
else None
)
self._render_month(status_map_)
if status_map_ is None:
self._cal_vars.streaks.set("Budget file is corrupt -- fix it below.")

View File

@ -32,7 +32,6 @@ if TYPE_CHECKING:
from gatelock import GateRoot
from diet_guard._estimator import Nutrition
from diet_guard._meal import MealItem
_logger = logging.getLogger(__name__)
@ -60,15 +59,13 @@ class _GateState:
(bank entries are the user's own names) or only fills macros (OFF products).
``last_reference`` is the natural-basis nutrition of the food last picked
or looked up, kept so a grams<->items toggle can re-express it losslessly;
it is cleared the moment a macro is hand-edited. ``meal_items`` accumulates
the parts of a multi-item meal before they are logged as one summed entry.
it is cleared the moment a macro is hand-edited.
"""
source: str = "manual"
suggestions: list[tuple[str, Nutrition]] = field(default_factory=list)
suggestion_mode: str = "bank"
last_reference: Nutrition | None = None
meal_items: list[MealItem] = field(default_factory=list)
class _GateCore:

View File

@ -297,11 +297,6 @@ class GateWidgetsGroup:
"""The per-basis entry, over its shared variable."""
return EntryGroup(self._each("per_entry"), self._vars.entries.per)
@property
def meal_name_entry(self) -> EntryGroup:
"""The meal-name entry, over its shared variable."""
return EntryGroup(self._each("meal_name_entry"), self._vars.entries.meal_name)
@property
def macros(self) -> MacroGroup:
"""The four macro entries, each over its shared variable."""

View File

@ -1,10 +1,10 @@
"""Submit/record/meal-building flow and dashboard for the MealGate gate.
"""Submit/record flow and dashboard for the MealGate gate.
Split out of :mod:`._gatelock` to keep that module under the repo's 500-line
limit. ``_GateMealFlow`` extends
:class:`~diet_guard._gatelock_nutrition._GateNutrition` with the
submit/lookup/log flow for single foods and multi-item meals, the per-slot
input reset, and the running calorie/macro dashboard.
submit/lookup/log flow, the per-slot input reset, and the running
calorie/macro dashboard.
"""
from __future__ import annotations
@ -14,11 +14,10 @@ import tkinter as tk
from typing import TYPE_CHECKING
from diet_guard._budget import BudgetError, daily_budget, protein_target_g
from diet_guard._foodbank import remember_food, remember_meal
from diet_guard._foodbank import remember_food
from diet_guard._gate import due_slots
from diet_guard._gatelock_nutrition import _GateNutrition
from diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS
from diet_guard._meal import MealItem, item_to_component, meal_total
from diet_guard._resolve import lookup_candidates
from diet_guard._slots import slot_label
from diet_guard._state import (
@ -41,17 +40,15 @@ _DASHBOARD_ROWS = 5
_TIME_SLICE = slice(11, 16)
# Width a meal description is truncated to in the dashboard.
_DASH_DESC_WIDTH = 22
# Fallback name for a multi-item meal when the user leaves the name field blank.
_DEFAULT_MEAL_NAME = "meal"
class _GateMealFlow(_GateNutrition):
"""Submit/lookup/log flow for single foods and multi-item meals."""
"""Submit/lookup/log flow for a logged food."""
# -- slot walk (meal-in-progress reset) ----------------------------------
# -- slot walk ------------------------------------------------------------
def _clear_food_inputs(self) -> None:
"""Empty the food fields, picker, preview, and basis (keeps any meal)."""
def _clear_inputs(self) -> None:
"""Empty the food fields, picker, preview, and basis for a new slot."""
self._set_desc("")
self._widgets.amount_entry.delete(0, tk.END)
self._vars.unit.set(UNIT_GRAMS)
@ -66,13 +63,6 @@ class _GateMealFlow(_GateNutrition):
self._vars.preview.set("")
self._refresh_projection()
def _clear_inputs(self) -> None:
"""Empty the food fields and discard any in-progress meal (new slot)."""
self._clear_food_inputs()
self._state.meal_items = []
self._widgets.meal_name_entry.delete(0, tk.END)
self._vars.meal_summary.set("")
# -- behaviour ------------------------------------------------------------
def _set_status(self, text: str, *, error: bool = False) -> None:
@ -85,17 +75,9 @@ class _GateMealFlow(_GateNutrition):
self._on_submit()
def _on_submit(self) -> None:
"""Validate, then look up, or log -- as a single food or a summed meal.
With a meal in progress, an empty form finalizes the accumulated items,
and a completed form adds itself as the meal's last item before logging.
With no meal in progress this is the original single-food path.
"""
"""Validate, then look the food up, or log it."""
description = self._get_desc()
if not description:
if self._state.meal_items:
self._log_meal()
return
self._set_status("Type what you ate first.", error=True)
self._widgets.desc_text.focus_set()
return
@ -114,10 +96,6 @@ class _GateMealFlow(_GateNutrition):
self._set_status("Enter the calories, then submit.", error=True)
self._widgets.macros.kcal.focus_set()
return
if self._state.meal_items:
self._state.meal_items.append(MealItem(description, nutrition))
self._log_meal()
return
self._record(description, nutrition)
def _begin_lookup(self, description: str) -> None:
@ -154,55 +132,6 @@ class _GateMealFlow(_GateNutrition):
remember_food(description, nutrition)
self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})")
def _meal_name(self) -> str:
"""Return the trimmed meal name the user typed (empty if none)."""
return self._widgets.meal_name_entry.get().strip()
def _refresh_meal_summary(self) -> None:
"""Update the running "meal so far" line from the accumulated items."""
if not self._state.meal_items:
self._vars.meal_summary.set("")
return
total = meal_total(self._state.meal_items)
names = ", ".join(item.name for item in self._state.meal_items)
self._vars.meal_summary.set(
f"Meal so far ({len(self._state.meal_items)}): {names} → "
f"{total.kcal:g} kcal · P{total.protein_g:g} "
f"C{total.carbs_g:g} F{total.fat_g:g}",
)
def _on_add_item(self) -> None:
"""Add the current form as one component of a multi-part meal.
Requires a name and resolved calories (a blank calorie field triggers a
lookup first, exactly like submitting). On success the item is appended
to the meal-in-progress, the running total updates, and the food fields
clear for the next item while the meal name is kept.
"""
description = self._get_desc()
if not description:
self._set_status("Type the item first, then add it.", error=True)
self._widgets.desc_text.focus_set()
return
values = self._macro_values()
if values is None:
self._set_status("Macros must be numbers.", error=True)
self._widgets.macros.kcal.focus_set()
return
if values[0] is None:
self._begin_lookup(description)
return
nutrition = self._current_nutrition()
if nutrition is None:
self._set_status("Enter the calories, then add the item.", error=True)
self._widgets.macros.kcal.focus_set()
return
self._state.meal_items.append(MealItem(description, nutrition))
self._refresh_meal_summary()
self._clear_food_inputs()
self._set_status(f"Added {description}. Add another, or Log & Continue.")
self._widgets.desc_text.focus_set()
def _slot_for_log(self) -> int | None:
"""Return the slot to tag a log with -- None in demo (satisfies no slot).
@ -212,31 +141,12 @@ class _GateMealFlow(_GateNutrition):
"""
return None if self.demo_mode else self._pending[0]
def _log_meal(self) -> None:
"""Log the accumulated multi-item meal for the current slot and advance.
Each component and the summed composite are banked (see
:func:`diet_guard._foodbank.remember_meal`), and the slot is
satisfied by the summed total under the meal's name.
"""
name = self._meal_name() or _DEFAULT_MEAL_NAME
count = len(self._state.meal_items)
total = remember_meal(name, list(self._state.meal_items))
log_meal(
name,
total,
self._slot_for_log(),
components=[item_to_component(item) for item in self._state.meal_items],
)
self._state.meal_items = []
self._finish_slot(f"{name}: {total.kcal:g} kcal ({count} items)")
def _finish_slot(self, summary: str) -> None:
"""Advance past the current slot after something was logged for it.
Args:
summary: A short description of what was logged (calories/source, or
the meal name and item count), shown in the confirmation line.
summary: A short description of what was logged (calories and
source), shown in the confirmation line.
"""
slot = self._pending[0]
self._pending.pop(0)

View File

@ -90,12 +90,10 @@ def make_vars(root: tk.Misc) -> GateVars:
projection=tk.StringVar(master=root, value=""),
cal_headline=tk.StringVar(master=root, value=""),
dashboard=tk.StringVar(master=root, value=""),
meal_summary=tk.StringVar(master=root, value=""),
unit=tk.StringVar(master=root, value=UNIT_GRAMS),
entries=GateEntryVars(
amount=tk.StringVar(master=root, value=""),
per=tk.StringVar(master=root, value=f"{DEFAULT_PER_GRAMS:g}"),
meal_name=tk.StringVar(master=root, value=""),
kcal=tk.StringVar(master=root, value=""),
protein=tk.StringVar(master=root, value=""),
carbs=tk.StringVar(master=root, value=""),
@ -296,55 +294,6 @@ def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
).pack(pady=(XS, 0))
def _build_meal_controls(
parent: tk.Frame,
vars_: GateVars,
on_add_item: Callable[[], None],
) -> tk.Entry:
"""Build the optional multi-item meal row; return the meal-name entry.
Logging stays one-tap for a single food; these controls only matter when a
meal has several separately-macroed parts (a dinner of salad + chicken +
rice). "Add item" banks the part onto the meal-in-progress and clears the
form for the next one; "Log & Continue" then logs the summed meal.
"""
row = tk.Frame(parent, bg=BG)
row.pack(pady=(XS, XS))
tk.Label(
row,
text="Meal name (optional):",
font=(_FONT, LABEL),
bg=BG,
fg=FG,
).pack(side="left")
meal_name_entry = tk.Entry(
row,
textvariable=vars_.entries.meal_name,
font=(_FONT, BODY),
width=18,
bg=_FIELD_BG,
fg=FG,
insertbackground=FG,
)
meal_name_entry.pack(side="left", padx=(SM, SM), ipady=XS)
make_button(
row,
text="+ Add item",
variant="secondary",
command=on_add_item,
).pack(side="left")
tk.Label(
parent,
textvariable=vars_.meal_summary,
font=(_FONT, LABEL),
bg=BG,
fg=_MUTED,
wraplength=900,
justify="center",
).pack(pady=(0, XS))
return meal_name_entry
def build_layout(
root: tk.Misc,
vars_: GateVars,
@ -406,8 +355,6 @@ def build_layout(
justify="center",
).pack(pady=(XS, SM))
meal_name_entry = _build_meal_controls(frame, vars_, callbacks.on_add_item)
make_button(
frame,
text="Log & Continue",
@ -455,6 +402,5 @@ def build_layout(
basis_prefix=basis_prefix,
macros=macros,
suggestion_box=suggestion_box,
meal_name_entry=meal_name_entry,
status_label=status_label,
)

View File

@ -37,7 +37,6 @@ class GateEntryVars:
amount: tk.StringVar
per: tk.StringVar
meal_name: tk.StringVar
kcal: tk.StringVar
protein: tk.StringVar
carbs: tk.StringVar
@ -54,7 +53,6 @@ class GateVars:
projection: tk.StringVar
cal_headline: tk.StringVar
dashboard: tk.StringVar
meal_summary: tk.StringVar
unit: tk.StringVar
entries: GateEntryVars
@ -70,7 +68,6 @@ class GateWidgets:
basis_prefix: tk.Label
macros: _MacroEntries
suggestion_box: tk.Listbox
meal_name_entry: tk.Entry
status_label: tk.Label
@ -86,5 +83,4 @@ class GateCallbacks:
on_unit_change: Callable[[str], None]
on_submit: Callable[[], None]
on_close: Callable[[], None]
on_add_item: Callable[[], None]
on_fetch_sync: Callable[[], None]

View File

@ -43,7 +43,7 @@ from pydantic import BaseModel
from diet_guard._budget import BudgetError
from diet_guard._gate import due_slots
from diet_guard._resolve import ManualMacros, resolve_nutrition
from diet_guard._slots import current_slot, day_slots, slot_label
from diet_guard._slots import current_slot, day_slots, slot_for_log, slot_label
from diet_guard._state import (
consumption_band,
logged_slots_today,
@ -234,7 +234,7 @@ def log_meal(
"or Open Food Facts. Pass kcal=<number> to log it manually."
),
}
target_slot = slot if slot is not None else current_slot(now_local())
target_slot = slot if slot is not None else slot_for_log(now_local())
resolved = {
"kcal": nutrition.kcal,
"protein_g": nutrition.protein_g,

View File

@ -1,93 +0,0 @@
"""Composite "meal" support for diet_guard.
A meal is a named group of individually-macroed items -- e.g. a dinner of
salad + chicken + rice, each entered with its own calories and macros. The
meal's nutrition is the sum of its items. Both the individual items and the
composite meal are saved to the food bank (see
:func:`diet_guard._foodbank.remember_meal`), so next time each item
autocompletes on its own and the whole meal can be picked as one summed entry.
This module is deliberately pure (no I/O): the sum is a total function of its
items, which keeps the arithmetic exhaustively unit-testable apart from the
bank persistence and the gate UI that compose it.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from diet_guard._estimator import Nutrition
if TYPE_CHECKING:
from collections.abc import Sequence
# Provenance stamped on a summed meal so the log/UI can tell a composite apart
# from a single looked-up food.
MEAL_SOURCE = "meal"
@dataclass(frozen=True)
class MealItem:
"""One named component of a composite meal, with its own nutrition.
Attributes:
name: The component's food name (e.g. ``"chicken"``).
nutrition: The component's resolved macros for the amount eaten.
"""
name: str
nutrition: Nutrition
def meal_total(items: Sequence[MealItem]) -> Nutrition:
"""Return the summed nutrition of a meal's items.
Every macro and the portion weight are added across the items and rounded to
0.1, and the result is stamped ``source=MEAL_SOURCE`` so it is
distinguishable from a single food. An empty sequence sums to an all-zero
meal rather than raising, so callers need not special-case "no items yet".
Args:
items: The meal's components.
Returns:
A :class:`~diet_guard._estimator.Nutrition` whose fields are
the per-item sums.
"""
return Nutrition(
kcal=round(sum((item.nutrition.kcal for item in items), 0.0), 1),
protein_g=round(sum((item.nutrition.protein_g for item in items), 0.0), 1),
carbs_g=round(sum((item.nutrition.carbs_g for item in items), 0.0), 1),
fat_g=round(sum((item.nutrition.fat_g for item in items), 0.0), 1),
grams=round(sum((item.nutrition.grams for item in items), 0.0), 1),
source=MEAL_SOURCE,
)
def item_to_component(item: MealItem) -> dict[str, object]:
"""Return a composite meal's per-component log record for ``item``.
The food bank's own ``components`` field (see
:func:`diet_guard._foodbank.remember_meal`) stores only component
*names* -- it is rebuilt from the log, not the other way round. This
record carries the component's full macros so a bank rebuilt purely by
replaying the log (the companion phone app's sync model) can recover
each component's standalone nutrition, not just the composite's summed
total.
Args:
item: One component of a composite meal.
Returns:
A plain dict of the component's name and macros, suitable for the
``components`` list passed to :func:`diet_guard._state.log_meal`.
"""
return {
"name": item.name,
"kcal": item.nutrition.kcal,
"protein_g": item.nutrition.protein_g,
"carbs_g": item.nutrition.carbs_g,
"fat_g": item.nutrition.fat_g,
"grams": item.nutrition.grams,
}

View File

@ -93,8 +93,9 @@ def missing_slots(now: datetime, logged: set[int]) -> tuple[int, ...]:
def current_slot(now: datetime) -> int | None:
"""Return the most recent elapsed slot as of ``now``, or None.
Used to tag a meal logged through the plain ``ate`` CLI with the slot it
belongs to, so it counts toward that checkpoint.
Reports the schedule position only. Tagging a *log* with a slot goes
through :func:`slot_for_log`, which additionally clamps off-hours meals
instead of returning None.
Args:
now: Reference local time.
@ -106,6 +107,32 @@ def current_slot(now: datetime) -> int | None:
return elapsed[-1] if elapsed else None
def slot_for_log(now: datetime) -> int:
"""Return the slot a meal logged at ``now`` should be attributed to.
CLAMP RULE (keep byte-identical with ``slot.dart``'s ``slotForLog``): before
the first window, clamp to the first slot; after the last window, clamp to
the last slot; behaviour inside a window is unchanged.
Unlike :func:`current_slot` this never returns None, which is the point: an
off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30
still left the gate firing for that checkpoint. Attribution is deliberately
separate from :func:`elapsed_slots`/:func:`missing_slots` -- widening *those*
would instead make every slot fall due at 23:00.
Args:
now: Reference local time.
Returns:
The slot hour to tag the log with.
"""
slots = day_slots()
if now.hour < GATE_DAY_START_HOUR:
return slots[0]
current = current_slot(now)
return current if current is not None else slots[-1]
def slot_label(slot: int) -> str:
"""Return a human ``HH:00`` label for a slot hour, e.g. ``"08:00"``."""
return f"{slot % _HOURS_PER_DAY:02d}:00"

View File

@ -22,6 +22,11 @@ import logging
from crdt_sync import GitHubSyncClient, GitHubSyncError, Log, merge_logs
from diet_guard._budget import read_raw_record, write_raw_record
from diet_guard._budget_history import (
history_to_json,
load_entries,
write_raw_history,
)
from diet_guard._constants import (
SYNC_DEVICE_ID,
SYNC_REPO_NAME,
@ -29,15 +34,23 @@ from diet_guard._constants import (
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from diet_guard._foodbank import rebuild_food_bank
from diet_guard._foodbank import read_food_bank, rebuild_food_bank, write_food_bank
from diet_guard._foodbank_manual import read_manual_bank, write_manual_bank
from diet_guard._state import DayLog, read_raw_log, resign_entry, write_raw_log
from diet_guard._sync_merge import (
budget_to_log,
daylog_to_log,
food_bank_to_log,
log_to_budget,
log_to_daylog,
log_to_food_bank,
log_to_history,
log_to_manual_bank,
manual_bank_to_log,
parse_remote_budget,
parse_remote_food_bank,
parse_remote_log,
parse_remote_manual_bank,
)
_logger = logging.getLogger(__name__)
@ -54,6 +67,16 @@ def _device_log_path(device_id: str) -> str:
return f"{_DEVICES_DIR}/{device_id}/food_log.json"
def _device_food_bank_path(device_id: str) -> str:
"""Return one device's pushed derived-food-bank path."""
return f"{_DEVICES_DIR}/{device_id}/food_bank.json"
def _device_manual_bank_path(device_id: str) -> str:
"""Return one device's pushed curated-food-bank path."""
return f"{_DEVICES_DIR}/{device_id}/food_bank_manual.json"
def _device_budget_path(device_id: str) -> str:
"""Return the repo-relative path a device's budget is pushed to."""
return f"{_DEVICES_DIR}/{device_id}/budget.json"
@ -103,6 +126,100 @@ def _pull_remote_logs(client: GitHubSyncClient) -> list[Log]:
return remote_logs
def _sync_food_bank(client: GitHubSyncClient) -> None:
"""Pull, merge, persist and push the log-derived food bank.
Runs *after* the local rebuild in :func:`run_sync`, so this device's own
records already reflect the merged log; the merge then unions in whatever
another device knows and max-count wins per food (see
:func:`diet_guard._sync_merge.food_bank_to_log`).
Strictly speaking the bank is derivable from the already-synced log, so
both devices converge on their own eventually. Syncing it makes them
agree *now*, and publishes the bank so a fresh device has autocomplete
before it has replayed anything.
**The log stays authoritative for which foods exist.** A CRDT union
never shrinks, so without this the max-count merge would resurrect a food
whose entries were all undone -- a peer's stale copy would out-clock the
local absence and be written back and re-pushed forever, un-deletable.
Restricting the result to the foods the freshly-rebuilt local bank still
contains fixes that, and stays identical across devices because that
rebuild comes from the *merged* log both devices share.
"""
local = read_food_bank()
merged = food_bank_to_log(local)
for device_id in client.list_directory(_DEVICES_DIR):
if device_id == SYNC_DEVICE_ID:
continue
text = client.get_file_text(_device_food_bank_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_food_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable food bank pushed by device %r, skipping",
device_id,
)
if not merged:
return
resolved = {
name: record
for name, record in log_to_food_bank(merged).items()
if name in local
}
write_food_bank(resolved)
merged = food_bank_to_log(resolved)
client.put_file_text(
_device_food_bank_path(SYNC_DEVICE_ID),
json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
),
message="diet_guard sync",
)
def _sync_manual_bank(client: GitHubSyncClient) -> None:
"""Pull, merge, persist and push the hand-curated food bank.
Curated entries are the one part of the bank that is not derivable from
the food log (see :mod:`diet_guard._foodbank_manual`), so unlike
``food_bank.json`` they need a real merge: last-writer-wins per food name
by edit time, union across devices.
"""
merged = manual_bank_to_log(read_manual_bank())
for device_id in client.list_directory(_DEVICES_DIR):
if device_id == SYNC_DEVICE_ID:
continue
text = client.get_file_text(_device_manual_bank_path(device_id))
if text is None:
continue
try:
merged = merge_logs(merged, parse_remote_manual_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
_logger.warning(
"Unparsable curated food bank pushed by device %r, skipping",
device_id,
)
if not merged:
# No device has curated anything: nothing to persist, and pushing an
# empty object every tick would be pure churn.
return
write_manual_bank(log_to_manual_bank(merged))
client.put_file_text(
_device_manual_bank_path(SYNC_DEVICE_ID),
json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
indent=2,
),
message="diet_guard sync",
)
def _sync_budget(client: GitHubSyncClient) -> None:
"""Pull other devices' budgets, merge, write locally, push this device's.
@ -114,7 +231,7 @@ def _sync_budget(client: GitHubSyncClient) -> None:
to the merge nor overwrites a real budget pulled from elsewhere, and if
*no* device has ever set one, nothing is written or pushed.
"""
merged = budget_to_log(read_raw_record())
merged = budget_to_log(read_raw_record(), load_entries())
for device_id in client.list_directory(_DEVICES_DIR):
if device_id == SYNC_DEVICE_ID:
continue
@ -133,6 +250,13 @@ def _sync_budget(client: GitHubSyncClient) -> None:
if merged_record is None:
return
write_raw_record(merged_record)
merged_history = log_to_history(merged)
# Only write back when the merge actually carried history. A pre-feature
# peer contributes none, and persisting an empty document would look like
# "history already exists" to any presence-based check and stop the local
# seed from ever running.
if merged_history:
write_raw_history(history_to_json(merged_history))
push_json = json.dumps(
{record_id: record.to_dict() for record_id, record in merged.items()},
@ -181,6 +305,8 @@ def run_sync() -> DayLog:
write_raw_log(resigned)
rebuild_food_bank(resigned)
_sync_budget(client)
_sync_food_bank(client)
_sync_manual_bank(client)
push_log = daylog_to_log(resigned)
push_json = json.dumps(

View File

@ -41,9 +41,13 @@ from typing import TYPE_CHECKING
from crdt_sync import Hlc, Record
from diet_guard._budget_history import BudgetEntry
from diet_guard._constants import SYNC_DEVICE_ID
from diet_guard._foodbank_manual import record_edit_time
if TYPE_CHECKING:
from collections.abc import Mapping
from crdt_sync import Log
from diet_guard._state import DayLog
@ -51,6 +55,15 @@ if TYPE_CHECKING:
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
def _wall_time_ms(stamp: str) -> int:
"""Return ``stamp``'s epoch milliseconds, or the epoch when unparsable."""
try:
moment = datetime.fromisoformat(stamp)
except ValueError:
moment = _EPOCH
return int(moment.timestamp() * 1000)
def _entry_hlc(entry: dict[str, object]) -> Hlc:
"""Derive a deterministic Hlc for ``entry`` from its own ``time`` field.
@ -190,6 +203,15 @@ def parse_remote_log(text: str) -> Log:
# Stable id: exactly one budget record per device-pushed budget.json.
_BUDGET_RECORD_ID = "budget"
# Field-name prefix for the effective-from budget history, one field per date.
# A separate *field* rather than a separate document, because merge_record
# unions field names -- see budget_to_log.
_HISTORY_FIELD_PREFIX = "hist:"
# Body weight, as its own field rather than a key inside ``value``. It is
# shared state like everything else -- both devices must see one value -- but
# it needs its own Hlc so a device that never sets it (the phone) cannot
# delete it merely by pushing a budget edit.
_WEIGHT_FIELD = "weight"
def _budget_hlc(record: dict[str, object]) -> Hlc:
@ -210,21 +232,91 @@ def _budget_hlc(record: dict[str, object]) -> Hlc:
return Hlc.new_tick(SYNC_DEVICE_ID, wall_time_ms=wall_time_ms)
def budget_to_log(record: dict[str, object] | None) -> Log:
"""Convert a raw local/remote budget record into a single-record Log.
def _history_hlc(entry: BudgetEntry) -> Hlc:
"""Derive a deterministic Hlc for one history entry from its edit time.
Same trick as :func:`_budget_hlc`: identical inputs always yield the same
clock, so re-syncing unchanged history is a no-op. Two devices that seed
the history independently derive the same ``wall_time_ms`` and the same
value and differ only in node id, so whichever side wins the field-level
LWW, the value is identical and the merge converges in one round.
"""
try:
moment = datetime.fromisoformat(entry.edited_at)
except ValueError:
moment = _EPOCH
return Hlc.new_tick(SYNC_DEVICE_ID, wall_time_ms=int(moment.timestamp() * 1000))
def budget_to_log(
record: dict[str, object] | None,
entries: tuple[BudgetEntry, ...] = (),
) -> Log:
"""Convert a raw local budget record plus its history into a Log.
Returns an empty ``Log`` when ``record`` is None (this device has never
run ``init``), so an uninitialized device contributes nothing to the
merge rather than clobbering another device's real value.
The history rides along as one ``hist:<YYYY-MM-DD>`` field per entry on
the *same* record. ``crdt_sync``'s ``merge_record`` is per-field LWW over
the union of field names, so a device that predates the history neither
clobbers those fields nor blocks them: it merges them in from the remote
and pushes them straight back out (both sides push the *merged* log).
That is what makes this safe to roll out without a coordinated release.
``w`` (body weight) travels the same way, as its own :data:`_WEIGHT_FIELD`
rather than as a key inside ``value``. It is shared state -- both devices
must agree on it -- but inside ``value`` it was collateral damage of the
whole-map LWW: the phone rebuilds that map without ``w``, so any phone
budget edit silently deleted the stored weight and with it the protein
target. As its own field it is protected by per-field LWW, and a device
that never sets it relays it untouched instead of dropping it.
"""
if record is None:
return {}
hlc = _budget_hlc(record)
value = {k: v for k, v in record.items() if k != "t"}
rec = Record(id=_BUDGET_RECORD_ID, fields={"value": (value, hlc)})
value = {k: v for k, v in record.items() if k not in {"t", "w"}}
fields: dict[str, tuple[object, Hlc]] = {"value": (value, hlc)}
weight = record.get("w")
if isinstance(weight, (int, float)) and not isinstance(weight, bool):
fields[_WEIGHT_FIELD] = (float(weight), hlc)
for entry in entries:
fields[f"{_HISTORY_FIELD_PREFIX}{entry.effective_from}"] = (
entry.kcal,
_history_hlc(entry),
)
rec = Record(id=_BUDGET_RECORD_ID, fields=fields)
return {rec.id: rec}
def log_to_history(log: Log) -> tuple[BudgetEntry, ...]:
"""Extract the effective-from history from a merged budget ``Log``.
Each entry's ``edited_at`` is reconstructed from its field Hlc rather than
carried separately, so the stored timestamp and the clock the merge
compared can never drift apart.
"""
record = log.get(_BUDGET_RECORD_ID)
if record is None:
return ()
entries = []
for name, (value, hlc) in record.fields.items():
if not name.startswith(_HISTORY_FIELD_PREFIX):
continue
if isinstance(value, bool) or not isinstance(value, int):
continue
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
entries.append(
BudgetEntry(
effective_from=name[len(_HISTORY_FIELD_PREFIX) :],
kcal=value,
edited_at=edited.astimezone().isoformat(timespec="seconds"),
),
)
return tuple(sorted(entries, key=lambda e: e.effective_from))
def log_to_budget(log: Log) -> dict[str, object] | None:
"""Convert a merged budget ``Log`` back into a raw budget record.
@ -240,9 +332,122 @@ def log_to_budget(log: Log) -> dict[str, object] | None:
if hlc is not None:
winning_time = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
result["t"] = winning_time.astimezone().isoformat(timespec="seconds")
weight, _ = record.fields.get(_WEIGHT_FIELD, (None, None))
if isinstance(weight, (int, float)) and not isinstance(weight, bool):
result["w"] = float(weight)
return result
def food_bank_to_log(bank: Mapping[str, object]) -> Log:
"""Convert the log-derived food bank into a ``crdt_sync.Log``.
The bank is *derived* -- both devices replay the same synced log and
compute the same records -- so this exists to make them agree
**immediately** rather than only after each has replayed, and to let a
device that is missing part of the log still autocomplete the other's
foods.
The clock is the record's own ``count``, not a wall time. That makes
last-writer-wins mean *max-count-wins*, which is the right merge for a
derived counter: a device that has seen more of the log has the higher
count, and re-merging is idempotent because the count does not move
unless the log did. It also avoids inventing an edit timestamp for a
record nobody edits.
"""
log: Log = {}
for name, record in bank.items():
if not isinstance(record, dict):
continue
count = record.get("count")
ticks = int(count) if isinstance(count, (int, float)) else 0
hlc = Hlc.new_tick(SYNC_DEVICE_ID, wall_time_ms=ticks)
log[name] = Record(id=name, fields={"body": (dict(record), hlc)})
return log
def log_to_food_bank(log: Log) -> dict[str, dict[str, object]]:
"""Convert a merged food-bank ``Log`` back into on-disk bank shape."""
bank: dict[str, dict[str, object]] = {}
for name, record in log.items():
if record.deleted:
continue
body, _hlc = record.fields.get("body", ({}, None))
if isinstance(body, dict):
bank[name] = dict(body)
return bank
def parse_remote_food_bank(text: str) -> Log:
"""Parse one device's pushed ``food_bank.json`` into a ``crdt_sync.Log``.
Raises:
TypeError: If the top-level JSON isn't an object.
KeyError: Via ``Record.from_dict``, on a record missing a key.
ValueError: Via ``json.loads``/``Hlc.from_str`` on malformed input.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"top-level food-bank payload is not a JSON object: {raw!r}"
raise TypeError(msg)
return {record_id: Record.from_dict(data) for record_id, data in raw.items()}
def manual_bank_to_log(bank: Mapping[str, object]) -> Log:
"""Convert the hand-curated food bank into a ``crdt_sync.Log``.
One record per normalized food name, carrying the whole bank record as an
opaque ``body`` -- the same shape food-log entries use. Unlike an entry
a curated food is editable, so its Hlc comes from the record's own ``t``
edit stamp (like the budget's) rather than a fixed birth time.
"""
log: Log = {}
for name, record in bank.items():
if not isinstance(record, dict):
continue
hlc = Hlc.new_tick(
SYNC_DEVICE_ID,
wall_time_ms=_wall_time_ms(record_edit_time(record)),
)
body = {k: v for k, v in record.items() if k != "t"}
log[name] = Record(id=name, fields={"body": (body, hlc)})
return log
def log_to_manual_bank(log: Log) -> dict[str, dict[str, object]]:
"""Convert a merged curated-bank ``Log`` back into on-disk bank shape."""
bank: dict[str, dict[str, object]] = {}
for name, record in log.items():
if record.deleted:
continue
body, hlc = record.fields.get("body", ({}, None))
if not isinstance(body, dict):
continue
stored = dict(body)
if hlc is not None:
winning = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
stored["t"] = winning.astimezone().isoformat(timespec="seconds")
bank[name] = stored
return bank
def parse_remote_manual_bank(text: str) -> Log:
"""Parse one device's pushed curated-bank text into a ``crdt_sync.Log``.
Raises on malformed data; the caller logs-and-skips, matching every other
remote parser here.
Raises:
TypeError: If the top-level JSON isn't an object.
KeyError: Via ``Record.from_dict``, on a record missing a key.
ValueError: Via ``json.loads``/``Hlc.from_str`` on malformed input.
"""
raw = json.loads(text)
if not isinstance(raw, dict):
msg = f"top-level curated-bank payload is not a JSON object: {raw!r}"
raise TypeError(msg)
return {record_id: Record.from_dict(data) for record_id, data in raw.items()}
def parse_remote_budget(text: str) -> Log:
"""Parse one device's pushed ``budget.json`` text into a ``crdt_sync.Log``.

110
diet_guard/_test_guard.py Normal file
View File

@ -0,0 +1,110 @@
"""Make it impossible for a test to write to real user state.
``diet_guard/tests/conftest.py`` redirects every known state path into
``tmp_path``, but that only protects tests that *load that conftest*. On
2026-07-26 a throwaway test written into a scratch directory ran outside it,
wrote to the real ``~/.config/diet_guard/sync_token``, and destroyed the
user's GitHub PAT; the same session also created stray files under
``~/.local/share/diet_guard/``.
A redirect is a convention -- something a new test can forget or sidestep.
This is a guard: while pytest is in the process, any ``pathlib.Path`` write
under the real diet_guard data or config directory raises immediately,
whatever conftest is or is not in play, and whatever module forgot to be
patched. Reads are left alone, so a test may still assert that a real path
is absent.
Scope, honestly: it covers ``pathlib.Path`` -- which is what every diet_guard
writer uses -- not builtin ``open()``, ``os.replace`` or ``shutil``. It is a
hardened barrier across the paths this codebase actually takes, not a
kernel-level sandbox.
Inert outside pytest: production never imports pytest, so the single
``sys.modules`` check below is the whole cost.
"""
from __future__ import annotations
import pathlib
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
def _resolved(path: pathlib.Path) -> pathlib.Path:
"""Resolve ``path`` as far as the filesystem allows, never raising."""
try:
return path.expanduser().resolve()
except (OSError, RuntimeError, ValueError):
return path
# Resolved once, from the real home -- deliberately NOT from _constants, whose
# values a test may legitimately have patched to a temp path. Resolving here
# matters: the candidate paths are resolved too, so if $HOME (or .local, or
# .local/share) is a symlink an unresolved root would never match and the
# guard would silently fail *open* -- the worst possible failure for a safety
# net.
_PROTECTED = (
_resolved(pathlib.Path.home() / ".local" / "share" / "diet_guard"),
_resolved(pathlib.Path.home() / ".config" / "diet_guard"),
)
class RealUserStateWriteError(RuntimeError):
"""Raised when a test tries to write to the user's real diet_guard state."""
def __init__(self, path: pathlib.Path) -> None:
"""Name the offending path and how to fix the test."""
super().__init__(
f"test tried to write real user state at {path}. Point the relevant "
"path at tmp_path (see diet_guard/tests/conftest.py's "
"_isolate_state) instead of writing the real file.",
)
def _is_protected(path: pathlib.Path) -> bool:
"""Return True if ``path`` lies inside a real user-state directory."""
resolved = _resolved(pathlib.Path(path))
return any(resolved.is_relative_to(root) for root in _PROTECTED)
# Marks an already-wrapped method, so installing twice is a no-op without
# needing module-level mutable state.
_MARKER = "_diet_guard_write_guarded"
def _guard(name: str, is_write: Callable[..., bool]) -> None:
"""Wrap ``pathlib.Path.<name>`` so protected writes raise.
Idempotent per method: a second call finds the marker and returns, so the
wrapper can never be stacked on itself.
"""
original = getattr(pathlib.Path, name)
if getattr(original, _MARKER, False):
return
def wrapper(self: pathlib.Path, *args: object, **kwargs: object) -> object:
if is_write(*args, **kwargs) and _is_protected(self):
raise RealUserStateWriteError(self)
return original(self, *args, **kwargs)
wrapper.__name__ = name
setattr(wrapper, _MARKER, True)
setattr(pathlib.Path, name, wrapper)
def install() -> None:
"""Install the guard, but only when running under pytest.
Safe to call repeatedly: each :func:`_guard` is individually idempotent.
"""
if "pytest" not in sys.modules:
return
_guard("open", lambda mode="r", *_a, **_k: any(c in mode for c in "wxa+"))
# ``mkdir`` is guarded too: creating the real state directory is half of
# what the 2026-07-26 accident did, and every writer calls it first.
for name in ("write_text", "write_bytes", "unlink", "rename", "replace", "mkdir"):
_guard(name, lambda *_a, **_k: True)

View File

@ -54,6 +54,10 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
"diet_guard._budget.BUDGET_FILE",
tmp_path / ".budget",
),
patch(
"diet_guard._budget_history.BUDGET_HISTORY_FILE",
tmp_path / ".budget_history",
),
patch(
"diet_guard._state.FOOD_LOG_FILE",
tmp_path / "food_log.json",
@ -62,6 +66,10 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]:
"diet_guard._foodbank.FOOD_BANK_FILE",
tmp_path / "food_bank.json",
),
patch(
"diet_guard._foodbank_manual.MANUAL_BANK_FILE",
tmp_path / "food_bank_manual.json",
),
patch(
"diet_guard._gatelock.GATE_LOCK_FILE",
tmp_path / ".gate.lock",

View File

@ -0,0 +1,288 @@
"""Tests for _budget_history.py — the effective-from budget history.
The history file is redirected into ``tmp_path`` by the autouse conftest
fixture, so every read/write here is isolated from real user data.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
from diet_guard import _budget, _budget_history
from diet_guard._budget_history import (
EPOCH_DAY,
BudgetEntry,
BudgetSchedule,
history_from_json,
history_to_json,
load_entries,
read_raw_history,
record_budget_change,
seed_from_budget,
upsert,
write_raw_history,
)
def _at(iso: str) -> datetime:
return datetime.fromisoformat(iso)
def _entry(day: str, kcal: int, t: str = "2026-01-01T00:00:00+00:00") -> BudgetEntry:
return BudgetEntry(effective_from=day, kcal=kcal, edited_at=t)
class TestForDay:
"""Resolving a day to the budget that applied on it."""
def test_empty_history_falls_back_to_the_default(self) -> None:
assert BudgetSchedule((), default=2200).for_day("2026-06-01") == 2200
def test_day_before_every_entry_falls_back_to_the_default(self) -> None:
schedule = BudgetSchedule((_entry("2026-07-26", 2000),), default=2200)
assert schedule.for_day("2026-06-01") == 2200
def test_exact_effective_from_day_uses_the_new_value(self) -> None:
schedule = BudgetSchedule((_entry("2026-07-26", 2000),), default=2200)
assert schedule.for_day("2026-07-26") == 2000
def test_day_after_uses_the_new_value(self) -> None:
schedule = BudgetSchedule((_entry("2026-07-26", 2000),), default=2200)
assert schedule.for_day("2026-07-27") == 2000
def test_between_entries_uses_the_earlier_one(self) -> None:
schedule = BudgetSchedule(
(_entry(EPOCH_DAY, 2200), _entry("2026-07-26", 2000)),
default=1,
)
assert schedule.for_day("2026-07-25") == 2200
assert schedule.for_day("2026-07-26") == 2000
def test_picks_the_latest_of_several_changes(self) -> None:
schedule = BudgetSchedule(
(
_entry(EPOCH_DAY, 2400),
_entry("2026-03-01", 2200),
_entry("2026-07-26", 2000),
),
default=1,
)
assert schedule.for_day("2026-02-28") == 2400
assert schedule.for_day("2026-05-05") == 2200
assert schedule.for_day("2026-12-31") == 2000
class TestParsing:
"""history_from_json tolerates anything it is handed."""
def test_round_trips_entries(self) -> None:
entries = (_entry(EPOCH_DAY, 2200), _entry("2026-07-26", 2000))
assert history_from_json(history_to_json(entries)) == entries
def test_sorts_ascending_regardless_of_stored_order(self) -> None:
raw = {
"v": 1,
"e": {
"2026-07-26": {"b": 2000, "t": "x"},
"1970-01-01": {"b": 2200, "t": "y"},
},
}
assert [e.effective_from for e in history_from_json(raw)] == [
"1970-01-01",
"2026-07-26",
]
def test_non_dict_is_empty(self) -> None:
assert history_from_json([1, 2, 3]) == ()
def test_wrong_version_is_empty(self) -> None:
assert history_from_json({"v": 99, "e": {}}) == ()
def test_non_dict_entries_map_is_empty(self) -> None:
assert history_from_json({"v": 1, "e": "nope"}) == ()
def test_malformed_entry_is_skipped(self) -> None:
raw = {
"v": 1,
"e": {
"2026-07-26": "not a record",
"2026-07-27": {"b": "not an int"},
"2026-07-28": {"b": True},
"2026-07-29": {"b": 2000, "t": "2026-07-29T00:00:00+00:00"},
},
}
entries = history_from_json(raw)
assert [e.effective_from for e in entries] == ["2026-07-29"]
def test_missing_edit_time_falls_back_to_the_epoch(self) -> None:
entries = history_from_json({"v": 1, "e": {"2026-07-26": {"b": 2000}}})
assert entries[0].edited_at.startswith("1970-01-01")
class TestUpsert:
"""Appending and replacing entries."""
def test_appends_a_new_day(self) -> None:
entries = upsert(
(_entry(EPOCH_DAY, 2200),), kcal=2000, when=_at("2026-07-26T10:00:00+02:00")
)
assert [e.effective_from for e in entries] == [EPOCH_DAY, "2026-07-26"]
assert entries[-1].kcal == 2000
def test_second_edit_the_same_day_replaces_rather_than_appends(self) -> None:
entries = upsert((), kcal=2000, when=_at("2026-07-26T10:00:00+02:00"))
entries = upsert(entries, kcal=1900, when=_at("2026-07-26T18:30:00+02:00"))
assert len(entries) == 1
assert entries[0].kcal == 1900
def test_stamps_the_edit_time(self) -> None:
entries = upsert((), kcal=2000, when=_at("2026-07-26T10:00:00+02:00"))
assert entries[0].edited_at == "2026-07-26T10:00:00+02:00"
class TestSeedFromBudget:
"""Grandfathering an existing budget to the beginning of time."""
def test_none_record_seeds_nothing(self) -> None:
assert seed_from_budget(None) == ()
def test_record_without_a_budget_seeds_nothing(self) -> None:
assert seed_from_budget({"v": 2}) == ()
def test_boolean_budget_seeds_nothing(self) -> None:
assert seed_from_budget({"b": True}) == ()
def test_seeds_the_epoch_day_with_the_records_own_timestamp(self) -> None:
seeded = seed_from_budget({"b": 2200, "t": "2026-07-13T21:15:09+02:00"})
assert seeded == (BudgetEntry(EPOCH_DAY, 2200, "2026-07-13T21:15:09+02:00"),)
def test_missing_timestamp_falls_back_to_the_epoch(self) -> None:
seeded = seed_from_budget({"b": 2200})
assert seeded[0].edited_at.startswith("1970-01-01")
class TestFileIO:
"""Reading, writing, and lazily seeding the history file."""
def test_absent_file_reads_as_none(self) -> None:
assert read_raw_history() is None
def test_write_then_read_round_trips(self) -> None:
write_raw_history(history_to_json((_entry(EPOCH_DAY, 2200),)))
assert history_from_json(read_raw_history())[0].kcal == 2200
def test_corrupt_file_reads_as_none(self) -> None:
_budget_history.BUDGET_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
_budget_history.BUDGET_HISTORY_FILE.write_text("not json{{{")
assert read_raw_history() is None
def test_non_object_file_reads_as_none(self) -> None:
_budget_history.BUDGET_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
_budget_history.BUDGET_HISTORY_FILE.write_text("[1, 2, 3]")
assert read_raw_history() is None
def test_load_entries_is_a_pure_read_and_never_seeds(self) -> None:
"""Seeding belongs to ``_budget``; this must not reach back into it."""
_budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"})
assert load_entries() == ()
assert read_raw_history() is None
def test_load_entries_is_empty_without_a_budget(self) -> None:
assert load_entries() == ()
def test_seeding_never_overwrites_a_pulled_history(self) -> None:
write_raw_history(history_to_json((_entry("2026-07-26", 2000),)))
_budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"})
schedule = _budget.current_schedule(default=1)
assert [e.effective_from for e in schedule.entries] == ["2026-07-26"]
def test_record_budget_change_defaults_to_now(self) -> None:
record_budget_change(1900)
entries = load_entries()
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
assert entries[-1].effective_from == today
assert entries[-1].kcal == 1900
class TestEmptyHistoryDocument:
"""An empty-but-present history must still seed.
Regression guard: the seed used to be gated on the *file* being absent,
so an empty ``{"v": 1, "e": {}}`` -- which ``_sync`` could write back
after merging with a pre-feature peer -- permanently disabled seeding and
every past day silently adopted the newest budget.
"""
def test_empty_document_still_grandfathers_on_write(self) -> None:
_budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"})
write_raw_history({"v": 1, "e": {}})
_budget.write_budget(2000)
schedule = _budget.current_schedule(default=1)
assert schedule.for_day("2026-06-01") == 2200
def test_empty_document_still_grandfathers_on_read(self) -> None:
_budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"})
write_raw_history({"v": 1, "e": {}})
assert _budget.current_schedule(default=1).for_day("2026-06-01") == 2200
def test_current_schedule_seeds_a_missing_history(self) -> None:
_budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"})
assert _budget.current_schedule(default=1).for_day("2026-06-01") == 2200
assert read_raw_history() is not None
def test_current_schedule_without_a_budget_uses_the_default(self) -> None:
assert _budget.current_schedule(default=1900).for_day("2026-06-01") == 1900
class TestWriteBudgetIntegration:
"""write_budget is the funnel that keeps the history correct."""
def test_first_write_grandfathers_the_previous_value(self) -> None:
"""The load-bearing ordering test.
A today-only assertion passes even when the seed happens *after* the
upsert; this asserts a PAST day, which is the case that breaks if the
two are ever reordered.
"""
_budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"})
_budget.write_budget(2000)
schedule = _budget.current_schedule(default=1)
assert schedule.for_day("2026-06-01") == 2200
assert (
schedule.for_day(
datetime.now(tz=timezone.utc).astimezone().date().isoformat(),
)
== 2000
)
def test_current_budget_still_reads_back_as_the_new_value(self) -> None:
_budget.write_budget(2000)
assert _budget.daily_budget() == 2000
def test_weight_is_preserved_alongside_the_history(self) -> None:
_budget.write_budget(2000, weight_kg=78.5)
assert _budget.budget_weight() == 78.5
assert load_entries()[-1].kcal == 2000
def test_a_fresh_install_seeds_no_phantom_history(self) -> None:
"""With no prior budget there is nothing to grandfather."""
_budget.write_budget(2000)
entries = load_entries()
assert [e.effective_from for e in entries] == [
datetime.now(tz=timezone.utc).astimezone().date().isoformat(),
]
def test_the_history_file_is_plain_readable_json(self) -> None:
_budget.write_budget(2000)
document = json.loads(
_budget_history.BUDGET_HISTORY_FILE.read_text(encoding="utf-8"),
)
assert document["v"] == 1
assert isinstance(document["e"], dict)

View File

@ -4,7 +4,8 @@ from __future__ import annotations
from datetime import date
from diet_guard import _budget, _daystatus, _state
from diet_guard import _daystatus, _state
from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule
from diet_guard._daystatus import DayStatus
from diet_guard._estimator import Nutrition
@ -13,6 +14,14 @@ def _entry(kcal: float) -> dict[str, object]:
return {"kcal": kcal}
def _flat(budget: int) -> BudgetSchedule:
"""A schedule where one budget has applied since the beginning of time."""
return BudgetSchedule(
(BudgetEntry(EPOCH_DAY, budget, "1970-01-01T00:00:00+00:00"),),
default=budget,
)
class TestDayTotalKcal:
def test_sums_entries(self) -> None:
log = {"2026-01-01": [_entry(100), _entry(50)]}
@ -71,14 +80,36 @@ class TestStatusMap:
"2026-01-01": [_entry(1000)],
"2026-01-02": [_entry(5000)],
}
result = _daystatus.status_map(log, budget=2000)
result = _daystatus.status_map(log, schedule=_flat(2000))
assert result == {
"2026-01-01": DayStatus.GREEN,
"2026-01-02": DayStatus.RED,
}
def test_empty_log_is_empty_map(self) -> None:
assert _daystatus.status_map({}, budget=2000) == {}
assert _daystatus.status_map({}, schedule=_flat(2000)) == {}
def test_each_day_uses_the_budget_in_force_that_day(self) -> None:
"""The whole point: a budget cut must not reclassify earlier days.
Both days total exactly 2100 kcal. Under the old 2200 budget that is
green; under the new 2000 budget it is yellow. The day before the
change must stay green.
"""
log = {
"2026-06-01": [_entry(2100)],
"2026-07-26": [_entry(2100)],
}
schedule = BudgetSchedule(
(
BudgetEntry(EPOCH_DAY, 2200, "1970-01-01T00:00:00+00:00"),
BudgetEntry("2026-07-26", 2000, "2026-07-26T10:00:00+02:00"),
),
default=2200,
)
result = _daystatus.status_map(log, schedule=schedule)
assert result["2026-06-01"] == DayStatus.GREEN
assert result["2026-07-26"] == DayStatus.YELLOW
class TestLoggingStreak:
@ -174,12 +205,3 @@ class TestYearToDateTally:
assert tally.logged_days == 0
assert tally.elapsed_days == 60
assert tally.adherent_days == 0
class TestCurrentStatusMap:
def test_reads_real_log_and_budget(self) -> None:
_budget.write_budget(2000)
_state.log_meal("lunch", Nutrition(500, 30, 40, 10, 100, "manual"))
result = _daystatus.current_status_map()
assert len(result) == 1
assert next(iter(result.values())) == DayStatus.GREEN

View File

@ -12,13 +12,7 @@ from unittest.mock import patch
from diet_guard import _foodbank
from diet_guard._estimator import Nutrition
from diet_guard._foodbank import (
lookup_food,
remember_food,
remember_meal,
search_foods,
)
from diet_guard._meal import MealItem
from diet_guard._foodbank import lookup_food, remember_food, search_foods
_NUT = Nutrition(
kcal=250,
@ -122,39 +116,6 @@ class TestSearch:
assert names == ["applekey"]
class TestRememberMeal:
"""Banking a composite meal and its components."""
def test_banks_each_item_and_the_composite(self) -> None:
"""Every component and the summed meal land in the bank."""
items = [
MealItem("salad", Nutrition(80, 2, 8, 5, 120, "manual")),
MealItem("chicken", Nutrition(330, 62, 0, 7, 200, "manual")),
]
total = remember_meal("dinner", items)
assert total.kcal == 410
assert lookup_food("salad") is not None
assert lookup_food("chicken") is not None
dinner = lookup_food("dinner")
assert dinner is not None
assert dinner.kcal == 410
def test_composite_records_components(self) -> None:
"""The meal entry carries its component names for later use."""
item = MealItem("rice", Nutrition(260, 5, 56, 1, 180, "manual"))
remember_meal("bowl", [item])
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
assert bank["bowl"]["components"] == ["rice"]
def test_blank_name_banks_items_only(self) -> None:
"""A blank meal name still banks items but stores no empty composite."""
item = MealItem("toast", Nutrition(120, 4, 20, 2, 40, "manual"))
remember_meal(" ", [item])
assert lookup_food("toast") is not None
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
assert list(bank) == ["toast"]
class TestCorruptQuarantine:
"""A corrupt bank is moved aside, not re-warned about or overwritten."""

View File

@ -0,0 +1,124 @@
"""Tests for _foodbank_manual.py — the hand-curated, synced bank entries.
The manual-bank file is redirected into ``tmp_path`` by the autouse conftest
fixture, so every read/write here is isolated from real user data.
"""
from __future__ import annotations
import json
from diet_guard import _foodbank, _foodbank_manual
from diet_guard._estimator import Nutrition
from diet_guard._foodbank import lookup_food, remember_food, search_foods
from diet_guard._foodbank_manual import (
add_manual_entry,
read_manual_bank,
record_edit_time,
write_manual_bank,
)
_SKYR = {
"desc": "Skyr",
"kcal": 120.0,
"protein_g": 20.0,
"carbs_g": 5.0,
"fat_g": 0.5,
"grams": 150.0,
"count": 0,
}
class TestFileIO:
"""Reading and writing the curated bank."""
def test_absent_file_reads_as_empty(self) -> None:
assert read_manual_bank() == {}
def test_write_then_read_round_trips(self) -> None:
write_manual_bank({"skyr": dict(_SKYR)})
assert read_manual_bank()["skyr"]["kcal"] == 120.0
def test_unreadable_file_reads_as_empty(self) -> None:
_foodbank_manual.MANUAL_BANK_FILE.parent.mkdir(parents=True, exist_ok=True)
_foodbank_manual.MANUAL_BANK_FILE.write_text("not json{{{")
assert read_manual_bank() == {}
def test_non_object_file_reads_as_empty(self) -> None:
_foodbank_manual.MANUAL_BANK_FILE.parent.mkdir(parents=True, exist_ok=True)
_foodbank_manual.MANUAL_BANK_FILE.write_text("[1, 2, 3]")
assert read_manual_bank() == {}
def test_non_dict_records_are_dropped(self) -> None:
_foodbank_manual.MANUAL_BANK_FILE.parent.mkdir(parents=True, exist_ok=True)
_foodbank_manual.MANUAL_BANK_FILE.write_text(
json.dumps({"skyr": _SKYR, "junk": "not a record"}),
)
assert set(read_manual_bank()) == {"skyr"}
class TestEditTime:
"""The edit stamp the sync merge orders by."""
def test_missing_stamp_reads_as_the_epoch(self) -> None:
assert record_edit_time({}).startswith("1970-01-01")
def test_non_string_stamp_reads_as_the_epoch(self) -> None:
assert record_edit_time({"t": 12345}).startswith("1970-01-01")
def test_add_stamps_the_edit(self) -> None:
add_manual_entry("Skyr", dict(_SKYR))
assert not record_edit_time(read_manual_bank()["skyr"]).startswith("1970")
class TestAddManualEntry:
"""Adding curated foods."""
def test_keys_on_the_normalized_name(self) -> None:
add_manual_entry(" SkYr ", dict(_SKYR))
assert set(read_manual_bank()) == {"skyr"}
def test_repeated_add_replaces_rather_than_duplicating(self) -> None:
add_manual_entry("Skyr", dict(_SKYR))
add_manual_entry("Skyr", {**_SKYR, "kcal": 130.0})
bank = read_manual_bank()
assert len(bank) == 1
assert bank["skyr"]["kcal"] == 130.0
class TestVisibleToTheBank:
"""Curated entries must be usable exactly like logged ones.
This is the whole point of syncing them: a food added by hand on the
phone has to resolve in the PC gate's autocomplete without ever having
been eaten.
"""
def test_lookup_finds_a_curated_food(self) -> None:
add_manual_entry("Skyr", dict(_SKYR))
found = lookup_food("skyr")
assert found is not None
assert found.kcal == 120.0
def test_search_finds_a_curated_food(self) -> None:
add_manual_entry("Skyr", dict(_SKYR))
assert [name for name, _ in search_foods("sky")] == ["Skyr"]
def test_empty_query_lists_curated_foods_too(self) -> None:
add_manual_entry("Skyr", dict(_SKYR))
assert [name for name, _ in search_foods("")] == ["Skyr"]
def test_a_logged_food_wins_a_name_collision(self) -> None:
"""A logged record carries a real count and the macros actually eaten."""
add_manual_entry("Skyr", {**_SKYR, "kcal": 120.0})
remember_food("Skyr", Nutrition(999, 1, 1, 1, 100, "manual"))
found = lookup_food("Skyr")
assert found is not None
assert found.kcal == 999
def test_a_curated_food_survives_a_bank_rebuild(self) -> None:
"""The derived bank is rewritten on every log write; curated entries
live in their own file precisely so that cannot wipe them."""
add_manual_entry("Skyr", dict(_SKYR))
_foodbank.rebuild_food_bank({})
assert lookup_food("Skyr") is not None

View File

@ -14,7 +14,6 @@ from unittest.mock import patch
from diet_guard import _gatelock_mealflow
from diet_guard._budget import write_budget
from diet_guard._meal import MealItem
from diet_guard._state import log_meal
from diet_guard.tests.conftest import _nutrition
@ -278,123 +277,8 @@ class TestDashboard:
assert "after this item" in gate._vars.projection.get()
class TestMealFlow:
"""Building and logging a multi-item composite meal."""
def test_meal_name_trimmed(self, gate: MealGate) -> None:
"""The meal name is read back trimmed."""
gate._widgets.meal_name_entry.insert(0, " dinner ")
assert gate._meal_name() == "dinner"
def test_summary_empty_with_no_items(self, gate: MealGate) -> None:
"""With no accumulated items the running summary is blank."""
gate._refresh_meal_summary()
assert gate._vars.meal_summary.get() == ""
def test_summary_lists_items_and_total(self, gate: MealGate) -> None:
"""The summary shows the item names and the running calorie total."""
gate._state.meal_items = [
MealItem("salad", _nutrition(80, 120)),
MealItem("chicken", _nutrition(330, 200)),
]
gate._refresh_meal_summary()
summary = gate._vars.meal_summary.get()
assert "salad, chicken" in summary
assert "410 kcal" in summary
def test_add_item_requires_description(self, gate: MealGate) -> None:
"""Adding with no description prompts for one."""
gate._on_add_item()
assert "Type the item first" in gate._vars.status.get()
def test_add_item_rejects_non_numeric(self, gate: MealGate) -> None:
"""Non-numeric macros are rejected before adding."""
gate._set_desc("salad")
gate._widgets.macros.kcal.insert(0, "abc")
gate._on_add_item()
assert "must be numbers" in gate._vars.status.get()
def test_add_item_blank_calories_looks_up(self, gate: MealGate) -> None:
"""A blank calorie field looks the item up rather than adding."""
gate._set_desc("salad")
with patch.object(gate, "_begin_lookup") as lookup:
gate._on_add_item()
lookup.assert_called_once()
def test_add_item_defensive_none_nutrition(self, gate: MealGate) -> None:
"""A priced item that will not resolve prompts again (guard)."""
gate._set_desc("salad")
gate._widgets.macros.kcal.insert(0, "80")
with patch.object(gate, "_current_nutrition", return_value=None):
gate._on_add_item()
assert "add the item" in gate._vars.status.get()
def test_add_item_accumulates_and_clears(self, gate: MealGate) -> None:
"""A valid item is appended, the form clears, the meal name is kept."""
gate._widgets.meal_name_entry.insert(0, "dinner")
gate._set_desc("salad")
gate._widgets.macros.kcal.insert(0, "80")
gate._on_add_item()
assert len(gate._state.meal_items) == 1
assert gate._state.meal_items[0].name == "salad"
assert gate._get_desc() == ""
assert gate._meal_name() == "dinner"
assert "Added salad" in gate._vars.status.get()
def test_submit_empty_form_logs_accumulated_meal(self, gate: MealGate) -> None:
"""Submitting an empty form with items finalizes the meal."""
gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))]
with patch.object(gate, "_log_meal") as log_meal_:
gate._on_submit()
log_meal_.assert_called_once()
def test_submit_completes_meal_with_final_item(self, gate: MealGate) -> None:
"""A filled form plus existing items adds the form item, then logs."""
gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))]
gate._set_desc("rice")
gate._widgets.macros.kcal.insert(0, "260")
with patch.object(gate, "_log_meal") as log_meal_:
gate._on_submit()
assert len(gate._state.meal_items) == 2
assert gate._state.meal_items[1].name == "rice"
log_meal_.assert_called_once()
def test_log_meal_calls_remember_and_advances(self, gate: MealGate) -> None:
"""Logging a meal banks it under the typed name and advances the slot."""
gate._pending = [8, 12]
gate._widgets.meal_name_entry.insert(0, "dinner")
gate._state.meal_items = [
MealItem("salad", _nutrition(80, 120)),
MealItem("chicken", _nutrition(330, 200)),
]
with (
patch.object(
_gatelock_mealflow,
"remember_meal",
return_value=_nutrition(410, 320),
) as remember,
patch.object(_gatelock_mealflow, "log_meal") as log,
):
gate._log_meal()
assert remember.call_args.args[0] == "dinner"
assert log.call_args.args[0] == "dinner"
assert gate._state.meal_items == []
assert gate._pending == [12]
def test_log_meal_uses_default_name(self, gate: MealGate) -> None:
"""A blank meal name falls back to the default."""
gate._pending = [8, 12]
gate._state.meal_items = [MealItem("soup", _nutrition(150, 300))]
with (
patch.object(
_gatelock_mealflow,
"remember_meal",
return_value=_nutrition(150, 300),
) as remember,
patch.object(_gatelock_mealflow, "log_meal"),
):
gate._log_meal()
assert remember.call_args.args[0] == _gatelock_mealflow._DEFAULT_MEAL_NAME
class TestSlotWalk:
"""Slot tagging and the per-slot input reset."""
def test_slot_for_log_demo_is_none(self, gate: MealGate) -> None:
"""A demo gate tags logs with no real slot."""
@ -407,15 +291,13 @@ class TestMealFlow:
gate._pending = [12]
assert gate._slot_for_log() == 12
def test_clear_inputs_discards_meal(self, gate: MealGate) -> None:
"""Clearing between slots drops the in-progress meal and its name."""
gate._state.meal_items = [MealItem("salad", _nutrition(80, 120))]
gate._widgets.meal_name_entry.insert(0, "dinner")
gate._vars.meal_summary.set("something")
def test_clear_inputs_resets_the_form(self, gate: MealGate) -> None:
"""Clearing between slots empties the description and the macros."""
gate._set_desc("salad")
gate._widgets.macros.kcal.insert(0, "80")
gate._clear_inputs()
assert gate._state.meal_items == []
assert gate._meal_name() == ""
assert gate._vars.meal_summary.get() == ""
assert gate._get_desc() == ""
assert gate._widgets.macros.kcal.get() == ""
def test_finish_slot_unlocks_on_last(self, gate: MealGate) -> None:
"""Finishing the final slot triggers unlock."""

View File

@ -147,7 +147,7 @@ class TestLogMealGate:
with (
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
patch.object(_mcp, "ManualMacros") as manual,
patch.object(_mcp, "current_slot", return_value=8),
patch.object(_mcp, "slot_for_log", return_value=8),
patch.object(_mcp, "now_local"),
patch.object(_mcp, "record_meal") as record,
):

View File

@ -1,60 +0,0 @@
"""Tests for _meal.py — composite-meal summing."""
from __future__ import annotations
from diet_guard._estimator import Nutrition
from diet_guard._meal import MEAL_SOURCE, MealItem, meal_total
def _item(
name: str,
kcal: float,
macros: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
) -> MealItem:
"""Build a MealItem from a name, calories, and a (protein, carbs, fat, grams)."""
protein, carbs, fat, grams = macros
return MealItem(
name,
Nutrition(
kcal=kcal,
protein_g=protein,
carbs_g=carbs,
fat_g=fat,
grams=grams,
source="manual",
),
)
class TestMealTotal:
"""Summing a meal's items."""
def test_sums_every_field(self) -> None:
"""Each macro, calories, and weight are added across the items."""
items = [
_item("salad", 80, (2, 8, 5, 120)),
_item("chicken", 330, (62, 0, 7, 200)),
_item("rice", 260, (5, 56, 1, 180)),
]
total = meal_total(items)
assert total.kcal == 670
assert total.protein_g == 69
assert total.carbs_g == 64
assert total.fat_g == 13
assert total.grams == 500
assert total.source == MEAL_SOURCE
def test_empty_is_zero(self) -> None:
"""An empty meal sums to an all-zero composite rather than raising."""
assert meal_total([]) == Nutrition(
kcal=0.0,
protein_g=0.0,
carbs_g=0.0,
fat_g=0.0,
grams=0.0,
source=MEAL_SOURCE,
)
def test_rounds_to_one_decimal(self) -> None:
"""Floating sums are rounded to 0.1, like the rest of the log."""
assert meal_total([_item("a", 0.1), _item("b", 0.2)]).kcal == 0.3

View File

@ -13,6 +13,7 @@ from diet_guard._slots import (
day_slots,
elapsed_slots,
missing_slots,
slot_for_log,
slot_label,
within_enforcement_window,
)
@ -99,6 +100,47 @@ class TestCurrentSlot:
assert current_slot(_at(13)) == 12
class TestSlotForLog:
"""Slot attribution for a logged meal, including the off-hours clamp.
Keep in lockstep with ``slot.dart``'s ``slotForLog`` tests: a divergence
here means the PC and the phone disagree about which checkpoint a meal
satisfied.
"""
def test_before_the_first_window_clamps_to_the_first_slot(self) -> None:
"""An early breakfast counts toward 08:00 rather than nothing."""
assert slot_for_log(_at(7)) == 8
def test_midnight_clamps_to_the_first_slot(self) -> None:
"""The small hours are still "before the first window"."""
assert slot_for_log(_at(0)) == 8
def test_first_slot_boundary_is_unchanged(self) -> None:
"""08:00 exactly is inside the window, so no clamp applies."""
assert slot_for_log(_at(8)) == 8
def test_inside_the_window_matches_current_slot(self) -> None:
"""Within the window attribution is just the latest elapsed slot."""
assert slot_for_log(_at(13)) == current_slot(_at(13)) == 12
def test_last_in_window_hour_is_unchanged(self) -> None:
"""21:59 is still inside the window and lands on 20:00."""
assert slot_for_log(_at(21)) == 20
def test_after_the_last_window_clamps_to_the_last_slot(self) -> None:
"""A late dinner counts toward 20:00 rather than nothing."""
assert slot_for_log(_at(22)) == 20
def test_late_evening_clamps_to_the_last_slot(self) -> None:
"""23:00 clamps the same way as the 22:00 boundary."""
assert slot_for_log(_at(23)) == 20
def test_never_returns_none(self) -> None:
"""Every hour of the day attributes to some slot."""
assert all(slot_for_log(_at(hour)) in day_slots() for hour in range(24))
class TestSlotLabel:
"""Human HH:00 labels."""

View File

@ -13,11 +13,14 @@ from unittest.mock import MagicMock, patch
import pytest
from diet_guard import _sync
from diet_guard._budget import daily_budget, write_budget
from diet_guard._budget import budget_weight, daily_budget, write_budget
from diet_guard._budget_history import load_entries
from diet_guard._estimator import Nutrition
from diet_guard._foodbank import lookup_food
from diet_guard._state import load_log, log_meal
from diet_guard._sync_merge import budget_to_log
from diet_guard._sync_merge import (
budget_to_log,
)
def _remote_budget_json(*, kcal: int, t: str, weight_kg: float | None = None) -> str:
@ -90,7 +93,12 @@ class TestRunSync:
merged = _sync.run_sync()
assert sum(len(entries) for entries in merged.values()) == 1
client.put_file_text.assert_called_once()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
# The log push, plus the derived bank rebuilt from that same log.
assert pushed == [
"diet-guard-sync/devices/pc/food_bank.json",
"diet-guard-sync/devices/pc/food_log.json",
]
pushed_path = client.put_file_text.call_args.args[0]
assert pushed_path == "diet-guard-sync/devices/pc/food_log.json"
pushed_json = client.put_file_text.call_args.args[1]
@ -107,12 +115,14 @@ class TestRunSync:
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
# Both the food-log and budget pulls skip "pc" (this device) and
# only ever read "phone"'s files -- never "pc"'s.
# Every pull -- food log, budget, curated bank -- skips "pc" (this
# device) and only ever reads "phone"'s files.
requested_paths = [call.args[0] for call in client.get_file_text.call_args_list]
assert requested_paths == [
"diet-guard-sync/devices/phone/food_log.json",
"diet-guard-sync/devices/phone/budget.json",
"diet-guard-sync/devices/phone/food_bank.json",
"diet-guard-sync/devices/phone/food_bank_manual.json",
]
def test_skips_a_device_with_no_pushed_file_yet(self) -> None:
@ -283,6 +293,34 @@ class TestSyncBudget:
_sync.run_sync()
assert daily_budget() == 1800
def test_a_remote_edit_does_not_delete_the_local_body_weight(self) -> None:
"""The w-loss bug: ``w`` is PC-local and must survive a phone edit.
``w`` never travels (budget_to_log strips it), so before this fix a
winning remote record replaced the whole stored map and silently took
the weight -- and the protein target -- with it.
"""
_write_token()
write_budget(1500, weight_kg=78.5)
remote_json = _remote_budget_json(kcal=1800, t="2999-01-01T09:00:00")
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/budget.json": remote_json},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert daily_budget() == 1800
assert budget_weight() == 78.5
def test_budget_history_survives_a_round_trip(self) -> None:
"""The history is written back locally after every merge."""
_write_token()
write_budget(2000)
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert load_entries()[-1].kcal == 2000
def test_malformed_remote_budget_is_skipped(self) -> None:
"""A corrupt remote budget.json is skipped, not a crash."""
_write_token()

View File

@ -0,0 +1,246 @@
"""Tests for the food bank's half of the sync tick.
Split out of ``test_sync.py`` to keep both files under the repo's 500-line
ceiling. Covers the derived bank (merged max-count-wins, with the log
authoritative for which foods exist) and the hand-curated bank (merged
last-writer-wins per food name).
"""
from __future__ import annotations
import json
from unittest.mock import patch
from diet_guard import _sync
from diet_guard._estimator import Nutrition
from diet_guard._foodbank import lookup_food, read_food_bank
from diet_guard._foodbank_manual import add_manual_entry, read_manual_bank
from diet_guard._state import log_meal
from diet_guard._sync_merge import food_bank_to_log, manual_bank_to_log
from diet_guard.tests.test_sync import _mock_client, _write_token
class TestSyncFoodBank:
"""The log-derived food bank's cross-device merge."""
def test_the_local_bank_is_pushed(self) -> None:
_write_token()
# Through the log, not remember_food: run_sync rebuilds the derived
# bank from the merged log first, so a bank entry with no log entry
# behind it is (correctly) discarded before the push.
log_meal("apple", Nutrition(95, 0.5, 25, 0.3, 180, "manual"), 8)
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert "diet-guard-sync/devices/pc/food_bank.json" in pushed
def test_a_food_the_phone_logged_reaches_the_gate(self) -> None:
"""The payoff: a food eaten on the phone autocompletes on the PC.
It arrives through the *log* (which merges first in the same tick),
and the bank rebuild picks it up -- membership follows the log, so
this is the path that actually delivers cross-device autocomplete.
"""
_write_token()
remote_log_json = json.dumps(
{
"2026-06-22": [
{
"id": "phone-skyr",
"time": "2026-06-22T09:00:00+02:00",
"desc": "Skyr",
"kcal": 120.0,
"protein_g": 20.0,
"carbs_g": 5.0,
"fat_g": 0.5,
"grams": 150.0,
"source": "manual",
},
],
},
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_log.json": remote_log_json},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
found = lookup_food("Skyr")
assert found is not None
assert found.kcal == 120.0
def test_a_deleted_food_is_not_resurrected_by_a_peer(self) -> None:
"""Regression: a CRDT union never shrinks.
Without the log staying authoritative for membership, a peer's stale
copy of a food whose entries were all undone out-clocks the local
absence, gets written back AND re-pushed -- permanently un-deletable.
"""
_write_token()
# This device's log knows nothing of "skyr" (its entries were undone).
log_meal("apple", Nutrition(95, 0.5, 25, 0.3, 180, "manual"), 8)
remote = json.dumps(
{
rid: rec.to_dict()
for rid, rec in food_bank_to_log(
{"skyr": {"desc": "Skyr", "kcal": 120.0, "count": 3}},
).items()
},
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank.json": remote},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert lookup_food("Skyr") is None
pushed = [
call.args[1]
for call in client.put_file_text.call_args_list
if call.args[0].endswith("food_bank.json")
]
assert pushed
assert "skyr" not in pushed[0]
def test_a_higher_remote_count_still_refines_a_known_food(self) -> None:
"""Membership defers to the log; content still takes the better count."""
_write_token()
log_meal("apple", Nutrition(95, 0.5, 25, 0.3, 180, "manual"), 8)
remote = json.dumps(
{
rid: rec.to_dict()
for rid, rec in food_bank_to_log(
{"apple": {"desc": "Apple", "kcal": 95.0, "count": 99}},
).items()
},
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank.json": remote},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert read_food_bank()["apple"]["count"] == 99
def test_unparsable_remote_food_bank_is_skipped(self) -> None:
_write_token()
log_meal("apple", Nutrition(95, 0.5, 25, 0.3, 180, "manual"), 8)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank.json": "{not json"},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert lookup_food("apple") is not None
def test_nothing_pushed_when_no_device_has_a_bank(self) -> None:
_write_token()
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert "diet-guard-sync/devices/pc/food_bank.json" not in pushed
class TestSyncManualBank:
"""The hand-curated food bank's cross-device merge."""
def test_nothing_pushed_when_no_device_has_curated_anything(self) -> None:
_write_token()
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert not any("food_bank_manual" in path for path in pushed)
def test_local_curated_entry_is_pushed(self) -> None:
_write_token()
add_manual_entry("Skyr", {"desc": "Skyr", "kcal": 120.0, "count": 0})
client = _mock_client(devices=())
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
pushed = [call.args[0] for call in client.put_file_text.call_args_list]
assert "diet-guard-sync/devices/pc/food_bank_manual.json" in pushed
def test_a_remote_curated_entry_is_adopted_locally(self) -> None:
"""The point of the whole feature: a food added on the phone is usable
on the PC, in the gate's autocomplete, without ever being eaten."""
_write_token()
remote = json.dumps(
{
record_id: record.to_dict()
for record_id, record in manual_bank_to_log(
{
"skyr": {
"desc": "Skyr",
"kcal": 120.0,
"protein_g": 20.0,
"carbs_g": 5.0,
"fat_g": 0.5,
"grams": 150.0,
"count": 0,
"t": "2026-07-26T10:00:00+02:00",
},
},
).items()
},
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank_manual.json": remote},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert "skyr" in read_manual_bank()
found = lookup_food("Skyr")
assert found is not None
assert found.kcal == 120.0
def test_a_newer_remote_edit_wins(self) -> None:
_write_token()
add_manual_entry("Skyr", {"desc": "Skyr", "kcal": 120.0, "count": 0})
remote = json.dumps(
{
record_id: record.to_dict()
for record_id, record in manual_bank_to_log(
{
"skyr": {
"desc": "Skyr",
"kcal": 999.0,
"count": 0,
"t": "2999-01-01T00:00:00+02:00",
},
},
).items()
},
)
client = _mock_client(
devices=("phone",),
files={"diet-guard-sync/devices/phone/food_bank_manual.json": remote},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert read_manual_bank()["skyr"]["kcal"] == 999.0
def test_unparsable_remote_curated_bank_is_skipped(self) -> None:
_write_token()
add_manual_entry("Skyr", {"desc": "Skyr", "kcal": 120.0, "count": 0})
client = _mock_client(
devices=("phone",),
files={
"diet-guard-sync/devices/phone/food_bank_manual.json": "{not json",
},
)
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert read_manual_bank()["skyr"]["kcal"] == 120.0
def test_a_device_with_no_pushed_curated_bank_is_skipped(self) -> None:
_write_token()
add_manual_entry("Skyr", {"desc": "Skyr", "kcal": 120.0, "count": 0})
client = _mock_client(devices=("phone",))
with patch.object(_sync, "GitHubSyncClient", return_value=client):
_sync.run_sync()
assert read_manual_bank()["skyr"]["kcal"] == 120.0

View File

@ -14,6 +14,7 @@ import json
from crdt_sync import Record, merge_logs
import pytest
from diet_guard._budget_history import BudgetEntry
from diet_guard._sync_merge import (
_budget_hlc,
_entry_hlc,
@ -23,6 +24,7 @@ from diet_guard._sync_merge import (
entry_to_record,
log_to_budget,
log_to_daylog,
log_to_history,
parse_remote_budget,
parse_remote_log,
record_to_entry,
@ -225,6 +227,10 @@ def _budget_record(**overrides: object) -> dict[str, object]:
return record
_HIST_SEED = BudgetEntry("1970-01-01", 2200, "2026-07-13T21:15:09+02:00")
_HIST_CUT = BudgetEntry("2026-07-26", 2000, "2026-07-26T10:00:00+02:00")
class TestBudgetHlc:
def test_same_record_always_yields_the_same_hlc(self) -> None:
record = _budget_record()
@ -244,13 +250,59 @@ class TestBudgetLogRoundTrip:
def test_none_record_yields_an_empty_log(self) -> None:
assert budget_to_log(None) == {}
def test_round_trip_preserves_the_budget_and_weight(self) -> None:
record = _budget_record(w=80.0)
def test_round_trip_preserves_the_budget(self) -> None:
record = _budget_record()
round_tripped = log_to_budget(budget_to_log(record))
assert round_tripped is not None
assert round_tripped["b"] == 2000
def test_weight_travels_as_its_own_field(self) -> None:
"""``w`` is shared state, carried as a field of its own.
Inside ``value`` it was collateral damage of whole-map LWW: the phone
rebuilds that map without ``w``, so any phone budget edit silently
deleted the stored weight and the protein target with it.
"""
log = budget_to_log(_budget_record(w=80.0))
assert "w" not in log["budget"].fields["value"][0]
assert log["budget"].fields["weight"][0] == 80.0
def test_weight_round_trips(self) -> None:
round_tripped = log_to_budget(budget_to_log(_budget_record(w=80.0)))
assert round_tripped is not None
assert round_tripped["w"] == 80.0
def test_no_weight_round_trips_to_no_weight(self) -> None:
round_tripped = log_to_budget(budget_to_log(_budget_record()))
assert round_tripped is not None
assert "w" not in round_tripped
def test_a_non_numeric_weight_is_not_emitted(self) -> None:
assert "weight" not in budget_to_log(_budget_record(w="heavy"))["budget"].fields
def test_a_boolean_weight_is_not_emitted(self) -> None:
assert "weight" not in budget_to_log(_budget_record(w=True))["budget"].fields
def test_a_non_numeric_weight_field_is_ignored_on_read(self) -> None:
log = {
"budget": Record(
id="budget",
fields={"weight": ("heavy", _budget_hlc({}))},
),
}
assert "w" not in (log_to_budget(log) or {})
def test_a_weightless_peer_cannot_delete_the_weight(self) -> None:
"""The whole point: the phone never sets ``w`` and must not drop it."""
ours = budget_to_log(_budget_record(w=80.0))
phone = budget_to_log(
{"v": 2, "b": 1800, "t": "2099-01-01T00:00:00+00:00"},
)
merged = log_to_budget(merge_logs(ours, phone))
assert merged is not None
assert merged["b"] == 1800
assert merged["w"] == 80.0
def test_round_tripped_t_reflects_the_winning_hlc(self) -> None:
record = _budget_record(t="2026-06-22T08:00:00+02:00")
round_tripped = log_to_budget(budget_to_log(record))
@ -270,6 +322,74 @@ class TestBudgetLogRoundTrip:
assert round_tripped == {}
class TestBudgetHistoryFields:
"""The effective-from history riding along on the budget record."""
def test_history_becomes_one_field_per_date(self) -> None:
log = budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
fields = log["budget"].fields
assert "value" in fields
assert fields["hist:1970-01-01"][0] == 2200
assert fields["hist:2026-07-26"][0] == 2000
def test_round_trips_back_to_entries(self) -> None:
entries = log_to_history(
budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
)
assert [e.effective_from for e in entries] == ["1970-01-01", "2026-07-26"]
assert [e.kcal for e in entries] == [2200, 2000]
def test_no_history_round_trips_to_nothing(self) -> None:
assert log_to_history(budget_to_log(_budget_record())) == ()
def test_empty_log_has_no_history(self) -> None:
assert log_to_history({}) == ()
def test_non_history_fields_are_ignored(self) -> None:
log = {
"budget": Record(
id="budget", fields={"value": ({"b": 2000}, _budget_hlc({}))}
)
}
assert log_to_history(log) == ()
def test_non_int_history_value_is_skipped(self) -> None:
log = {
"budget": Record(
id="budget",
fields={"hist:2026-07-26": ("nonsense", _budget_hlc({}))},
),
}
assert log_to_history(log) == ()
def test_unparsable_edit_time_still_yields_a_field(self) -> None:
broken = BudgetEntry("2026-07-26", 2000, "not a timestamp")
log = budget_to_log(_budget_record(), (broken,))
assert "hist:2026-07-26" in log["budget"].fields
def test_history_survives_a_merge_with_a_history_free_peer(self) -> None:
"""The rollout guarantee: an old device cannot clobber the history.
merge_record is per-field LWW over the *union* of field names, so a
peer that only pushes ``value`` leaves ``hist:*`` untouched -- which
is why this needed no coordinated release.
"""
ours = budget_to_log(_budget_record(), (_HIST_SEED, _HIST_CUT))
legacy = {
"budget": Record(
id="budget",
fields={
"value": (
{"v": 2, "b": 2200},
_budget_hlc({"t": "2099-01-01T00:00:00+00:00"}),
)
},
),
}
merged = merge_logs(ours, legacy)
assert len(log_to_history(merged)) == 2
class TestParseRemoteBudget:
def test_parses_pushed_budget_wire_content(self) -> None:
record = _budget_record()

View File

@ -0,0 +1,188 @@
"""Tests for the food-bank Record adapters.
Split out of ``test_sync_merge.py`` to keep both files under the repo's
500-line ceiling. The derived bank clocks on ``count`` (so last-writer-wins
means max-count-wins); the curated bank clocks on an edit stamp.
"""
from __future__ import annotations
import json
from crdt_sync import Record, merge_logs
import pytest
from diet_guard._sync_merge import (
_budget_hlc,
food_bank_to_log,
log_to_food_bank,
log_to_manual_bank,
manual_bank_to_log,
parse_remote_food_bank,
parse_remote_manual_bank,
)
_MANUAL_REC: dict[str, object] = {
"desc": "Skyr",
"kcal": 120.0,
"count": 0,
"t": "2026-07-26T10:00:00+02:00",
}
class TestFoodBankAdapters:
"""The log-derived food bank's max-count merge."""
@staticmethod
def _rec(count: float, kcal: float = 95.0) -> dict[str, object]:
return {"desc": "Apple", "kcal": kcal, "count": count}
def test_round_trips_a_record(self) -> None:
back = log_to_food_bank(food_bank_to_log({"apple": self._rec(5)}))
assert back["apple"]["count"] == 5
def test_count_is_the_clock(self) -> None:
log = food_bank_to_log({"apple": self._rec(5)})
assert log["apple"].fields["body"][1].wall_time_ms == 5
def test_a_missing_count_clocks_at_zero(self) -> None:
log = food_bank_to_log({"apple": {"desc": "Apple"}})
assert log["apple"].fields["body"][1].wall_time_ms == 0
def test_the_higher_count_wins(self) -> None:
"""A device that has replayed more of the log knows the truer count."""
behind = food_bank_to_log({"apple": self._rec(3, kcal=90)})
ahead = food_bank_to_log({"apple": self._rec(9, kcal=95)})
merged = log_to_food_bank(merge_logs(behind, ahead))
assert merged["apple"]["count"] == 9
assert merged["apple"]["kcal"] == 95
def test_the_merge_is_order_independent(self) -> None:
a = food_bank_to_log({"apple": self._rec(3)})
b = food_bank_to_log({"apple": self._rec(9)})
assert log_to_food_bank(merge_logs(a, b)) == log_to_food_bank(
merge_logs(b, a),
)
def test_re_merging_is_idempotent(self) -> None:
"""The count only moves when the log does, so a tick is a no-op."""
once = food_bank_to_log({"apple": self._rec(5)})
assert log_to_food_bank(merge_logs(once, once)) == log_to_food_bank(once)
def test_different_foods_union(self) -> None:
a = food_bank_to_log({"apple": self._rec(1)})
b = food_bank_to_log({"pear": self._rec(1)})
assert set(log_to_food_bank(merge_logs(a, b))) == {"apple", "pear"}
def test_a_non_dict_record_is_skipped(self) -> None:
assert food_bank_to_log({"apple": "nope"}) == {}
def test_a_non_dict_body_is_skipped_on_read(self) -> None:
log = {"apple": Record(id="apple", fields={"body": ("x", _budget_hlc({}))})}
assert log_to_food_bank(log) == {}
def test_a_tombstoned_record_is_dropped_on_read(self) -> None:
"""A tombstone must not be laundered back into a live bank record."""
log = {
"apple": Record(
id="apple",
fields={"body": (self._rec(5), _budget_hlc({}))},
deleted=True,
),
}
assert log_to_food_bank(log) == {}
def test_parses_pushed_wire_content(self) -> None:
pushed = food_bank_to_log({"apple": self._rec(5)})
wire = json.dumps({rid: rec.to_dict() for rid, rec in pushed.items()})
assert "apple" in parse_remote_food_bank(wire)
def test_non_object_top_level_raises_type_error(self) -> None:
with pytest.raises(TypeError):
parse_remote_food_bank("[1, 2, 3]")
def test_invalid_json_raises(self) -> None:
with pytest.raises(json.JSONDecodeError):
parse_remote_food_bank("not json{{{")
_MANUAL_REC: dict[str, object] = {
"desc": "Skyr",
"kcal": 120.0,
"count": 0,
"t": "2026-07-26T10:00:00+02:00",
}
class TestManualBankAdapters:
"""The hand-curated food bank's Record round trip."""
def test_round_trips_a_record(self) -> None:
back = log_to_manual_bank(manual_bank_to_log({"skyr": _MANUAL_REC}))
assert back["skyr"]["desc"] == "Skyr"
assert back["skyr"]["kcal"] == 120.0
def test_edit_time_is_reconstructed_from_the_hlc(self) -> None:
back = log_to_manual_bank(manual_bank_to_log({"skyr": _MANUAL_REC}))
assert back["skyr"]["t"].startswith("2026-07-26T")
def test_the_edit_stamp_does_not_travel_inside_the_body(self) -> None:
log = manual_bank_to_log({"skyr": _MANUAL_REC})
assert "t" not in log["skyr"].fields["body"][0]
def test_a_missing_edit_stamp_falls_back_to_the_epoch(self) -> None:
log = manual_bank_to_log({"skyr": {"desc": "Skyr"}})
assert log["skyr"].fields["body"][1].wall_time_ms == 0
def test_an_unparsable_edit_stamp_falls_back_to_the_epoch(self) -> None:
log = manual_bank_to_log({"skyr": {"desc": "Skyr", "t": "nonsense"}})
assert log["skyr"].fields["body"][1].wall_time_ms == 0
def test_a_non_dict_record_is_skipped(self) -> None:
assert manual_bank_to_log({"skyr": "not a record"}) == {}
def test_a_non_dict_body_is_skipped_on_read(self) -> None:
log = {"skyr": Record(id="skyr", fields={"body": ("nope", _budget_hlc({}))})}
assert log_to_manual_bank(log) == {}
def test_a_record_without_a_body_reads_as_an_empty_entry(self) -> None:
"""Defensive: not produced by manual_bank_to_log, but must not crash."""
assert log_to_manual_bank({"skyr": Record(id="skyr", fields={})}) == {
"skyr": {},
}
def test_a_newer_edit_wins_the_merge(self) -> None:
older = manual_bank_to_log({"skyr": _MANUAL_REC})
newer = manual_bank_to_log(
{"skyr": {"desc": "Skyr", "kcal": 999.0, "t": "2999-01-01T00:00:00+02:00"}},
)
merged = log_to_manual_bank(merge_logs(older, newer))
assert merged["skyr"]["kcal"] == 999.0
def test_different_foods_union_rather_than_replace(self) -> None:
a = manual_bank_to_log({"skyr": _MANUAL_REC})
b = manual_bank_to_log({"kefir": {"desc": "Kefir", "t": _MANUAL_REC["t"]}})
assert set(log_to_manual_bank(merge_logs(a, b))) == {"skyr", "kefir"}
def test_a_tombstoned_record_is_dropped_on_read(self) -> None:
log = {
"skyr": Record(
id="skyr",
fields={"body": (dict(_MANUAL_REC), _budget_hlc({}))},
deleted=True,
),
}
assert log_to_manual_bank(log) == {}
def test_parses_pushed_wire_content(self) -> None:
pushed = manual_bank_to_log({"skyr": _MANUAL_REC})
wire = json.dumps({rid: rec.to_dict() for rid, rec in pushed.items()})
assert "skyr" in parse_remote_manual_bank(wire)
def test_non_object_top_level_raises_type_error(self) -> None:
with pytest.raises(TypeError):
parse_remote_manual_bank("[1, 2, 3]")
def test_invalid_json_raises(self) -> None:
with pytest.raises(json.JSONDecodeError):
parse_remote_manual_bank("not json{{{")

View File

@ -0,0 +1,104 @@
"""Tests for _test_guard.py — the real-user-state write barrier.
Meta-tests: they assert that the guard protecting every *other* test is
itself working. Deliberately exercised against the real protected paths,
because "does a write to the real path raise?" is the whole question -- the
guard raises before any I/O happens, so nothing is created.
"""
from __future__ import annotations
import pathlib
import sys
from unittest.mock import patch
import pytest
from diet_guard import _test_guard
from diet_guard._test_guard import RealUserStateWriteError
_REAL_TOKEN = pathlib.Path.home() / ".config" / "diet_guard" / "sync_token"
_REAL_LOG = pathlib.Path.home() / ".local" / "share" / "diet_guard" / "food_log.json"
class TestBlocksRealWrites:
"""The exact accident that destroyed the user's PAT must be impossible."""
def test_write_text_to_the_real_token_raises(self) -> None:
with pytest.raises(RealUserStateWriteError):
_REAL_TOKEN.write_text("tok")
def test_write_bytes_to_the_real_log_raises(self) -> None:
with pytest.raises(RealUserStateWriteError):
_REAL_LOG.write_bytes(b"{}")
def test_opening_the_real_token_for_writing_raises(self) -> None:
with pytest.raises(RealUserStateWriteError):
_REAL_TOKEN.open("w")
def test_appending_to_the_real_log_raises(self) -> None:
with pytest.raises(RealUserStateWriteError):
_REAL_LOG.open("a")
def test_unlinking_real_state_raises(self) -> None:
with pytest.raises(RealUserStateWriteError):
_REAL_TOKEN.unlink()
def test_renaming_real_state_raises(self) -> None:
with pytest.raises(RealUserStateWriteError):
_REAL_TOKEN.rename(_REAL_TOKEN.with_suffix(".moved"))
def test_the_error_names_the_path_and_the_fix(self) -> None:
with pytest.raises(RealUserStateWriteError) as caught:
_REAL_TOKEN.write_text("tok")
assert "sync_token" in str(caught.value)
assert "tmp_path" in str(caught.value)
class TestAllowsEverythingElse:
"""The guard must not get in the way of legitimate test I/O."""
def test_tmp_path_writes_are_untouched(self, tmp_path: pathlib.Path) -> None:
target = tmp_path / "food_log.json"
target.write_text("{}")
assert target.read_text() == "{}"
def test_reading_real_state_is_allowed(self) -> None:
# Reads are safe and some checks legitimately want them; only writes
# are barred. Absent file is fine -- the point is that it does not raise.
assert _REAL_TOKEN.exists() in (True, False)
def test_opening_real_state_for_reading_is_allowed(self) -> None:
if _REAL_TOKEN.exists():
with _REAL_TOKEN.open() as handle:
handle.read()
class TestGuardInternals:
"""Edge cases in the path check and the install latch."""
def test_an_unresolvable_path_is_not_treated_as_protected(self) -> None:
assert not _test_guard._is_protected(pathlib.Path("\x00bad"))
def test_an_unrelated_path_is_not_protected(self, tmp_path: pathlib.Path) -> None:
assert not _test_guard._is_protected(tmp_path / "anything.json")
def test_install_is_idempotent(self) -> None:
"""A second install must not wrap the already-wrapped methods again."""
before = pathlib.Path.write_text
_test_guard.install()
assert pathlib.Path.write_text is before
def test_install_is_a_no_op_outside_pytest(self) -> None:
"""Production imports diet_guard without pytest; the guard stays off."""
before = pathlib.Path.write_text
with patch.dict(sys.modules):
del sys.modules["pytest"]
_test_guard.install()
assert pathlib.Path.write_text is before
def test_guarding_an_already_guarded_method_is_a_no_op(self) -> None:
"""The per-method marker, not a module flag, is what makes it safe."""
before = pathlib.Path.write_text
_test_guard._guard("write_text", lambda *_a, **_k: True)
assert pathlib.Path.write_text is before