mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 17:33:55 +01:00
Both platforms gain a calendar tab/screen (green/yellow/red/not-logged day coloring, logging/adherence streaks, year-to-date tally), and tapping a day drills into that day's logged entries. This requires the daily budget to become a normal, freely-editable value synced between devices like the food log — replaces the previous chattr +i seal (deliberately, per user decision) with plain last-edit-wins sync via a new budget.json alongside food_log.json. The budget field defaults to 2200 kcal, starts read-only, and unlocks via an Edit/Save toggle on both the PC gate's History tab and the phone app's Calendar screen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
138 lines
4.9 KiB
Dart
138 lines
4.9 KiB
Dart
/// Cross-device log sync orchestration for the diet_guard companion app.
|
|
///
|
|
/// Pulls every other device's pushed log from GitHub-backed dumb storage via
|
|
/// 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).
|
|
///
|
|
/// The daily budget syncs alongside the food log in the same tick (see
|
|
/// [_syncBudget]): a sibling `budget.json` per device, merged
|
|
/// last-writer-wins by edit time rather than union-of-immutable-entries,
|
|
/// since a budget (unlike a food-log entry) can be edited repeatedly.
|
|
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/foodbank_service.dart';
|
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
|
import 'package:diet_guard_app/services/sync_merge.dart';
|
|
|
|
const _devicesDir = 'diet-guard-sync/devices';
|
|
|
|
/// This device's id in the `diet-guard-sync/devices/<id>/food_log.json`
|
|
/// layout. The PC pushes under `pc` (`SYNC_DEVICE_ID` in
|
|
/// `diet_guard/_constants.py`); the phone is the only other device in this
|
|
/// design.
|
|
const phoneDeviceId = 'phone';
|
|
|
|
/// Runs one full sync tick: pull, merge, preserve photos, 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
|
|
/// "Sync now" action) to decide how to report.
|
|
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,
|
|
deviceId: phoneDeviceId,
|
|
pathPrefix: _devicesDir,
|
|
localLog: dayLogToLog(local),
|
|
encode: encodeLogForPush,
|
|
decode: parseRemoteLog,
|
|
filename: 'food_log.json',
|
|
commitMessage: 'diet_guard_app sync',
|
|
);
|
|
|
|
var merged = logToDayLog(mergedLog);
|
|
merged = _preserveLocalImagePaths(merged, localImagePaths);
|
|
|
|
await logService.writeLog(merged);
|
|
await FoodBankService.instance.rebuildAndPersist(merged);
|
|
await _syncBudget(client);
|
|
return merged;
|
|
}
|
|
|
|
/// Pulls other devices' budgets, merges, applies the winner locally, pushes.
|
|
///
|
|
/// Reuses [syncLog] (it always pushes the merged result, even an empty
|
|
/// one -- harmless and consistent with the food-log sync above). This
|
|
/// device contributes nothing to the merge until the goal has been
|
|
/// explicitly set at least once (see
|
|
/// [AppSettingsService.dailyKcalGoalUpdatedAt]), so a fresh install's
|
|
/// unset 2200 default can never spuriously outrank a real budget synced
|
|
/// from elsewhere.
|
|
Future<void> _syncBudget(GitHubClient client) async {
|
|
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
|
|
final localRecord = updatedAt == null
|
|
? null
|
|
: <String, dynamic>{
|
|
'v': 2,
|
|
'b': AppSettingsService.dailyKcalGoal,
|
|
't': updatedAt.toIso8601String(),
|
|
};
|
|
|
|
final mergedBudgetLog = await syncLog(
|
|
client: client,
|
|
deviceId: phoneDeviceId,
|
|
pathPrefix: _devicesDir,
|
|
localLog: budgetToLog(localRecord),
|
|
encode: encodeBudgetForPush,
|
|
decode: parseRemoteBudget,
|
|
filename: 'budget.json',
|
|
commitMessage: 'diet_guard_app sync',
|
|
);
|
|
|
|
final merged = logToBudget(mergedBudgetLog);
|
|
final mergedKcal = merged?['b'];
|
|
if (merged == null || mergedKcal is! int) return;
|
|
await AppSettingsService.instance.applySyncedBudget(
|
|
mergedKcal,
|
|
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);
|
|
}
|