diet-guard/app/lib/services/sync_service.dart
Krzysztof kuhy Rudnicki 957769ff0e
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Add budget-adherence calendar, streaks, and freely-editable synced budget
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
2026-07-13 21:31:45 +02:00

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);
}