mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
Remove unused features; fix reminder false alarms; share all state
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:
parent
94081233f2
commit
f7f06aee53
68
CLAUDE.md
68
CLAUDE.md
@ -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" —
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
@ -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`.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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!),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@ -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!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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';
|
||||
@ -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');
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
133
app/lib/services/budget_history_service.dart
Normal file
133
app/lib/services/budget_history_service.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
167
app/lib/services/budget_schedule.dart
Normal file
167
app/lib/services/budget_schedule.dart
Normal 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';
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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/';
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
142
app/lib/widgets/today_progress_card.dart
Normal file
142
app/lib/widgets/today_progress_card.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
app/pubspec.lock
120
app/pubspec.lock
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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()));
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
}
|
||||
133
app/test/services/budget_history_service_test.dart
Normal file
133
app/test/services/budget_history_service_test.dart
Normal 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>());
|
||||
});
|
||||
}
|
||||
168
app/test/services/budget_schedule_test.dart
Normal file
168
app/test/services/budget_schedule_test.dart
Normal 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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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>{});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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)));
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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', () {
|
||||
|
||||
@ -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',
|
||||
|
||||
93
app/test/widgets/today_progress_card_test.dart
Normal file
93
app/test/widgets/today_progress_card_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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]:
|
||||
|
||||
233
diet_guard/_budget_history.py
Normal file
233
diet_guard/_budget_history.py
Normal 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)))
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
87
diet_guard/_foodbank_manual.py
Normal file
87
diet_guard/_foodbank_manual.py
Normal 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)
|
||||
@ -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()
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
110
diet_guard/_test_guard.py
Normal 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)
|
||||
@ -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",
|
||||
|
||||
288
diet_guard/tests/test_budget_history.py
Normal file
288
diet_guard/tests/test_budget_history.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
124
diet_guard/tests/test_foodbank_manual.py
Normal file
124
diet_guard/tests/test_foodbank_manual.py
Normal 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
|
||||
@ -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."""
|
||||
|
||||
@ -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,
|
||||
):
|
||||
|
||||
@ -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
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
246
diet_guard/tests/test_sync_banks.py
Normal file
246
diet_guard/tests/test_sync_banks.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
188
diet_guard/tests/test_sync_merge_banks.py
Normal file
188
diet_guard/tests/test_sync_merge_banks.py
Normal 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{{{")
|
||||
104
diet_guard/tests/test_test_guard.py
Normal file
104
diet_guard/tests/test_test_guard.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user