diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index ec50825..857e678 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -3,6 +3,7 @@ library; import 'dart:async'; +import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:diet_guard_app/models/food_suggestion.dart'; @@ -12,6 +13,7 @@ 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/background_sync_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_service.dart'; @@ -22,6 +24,7 @@ import 'package:diet_guard_app/widgets/photo_attach_field.dart'; import 'package:diet_guard_app/widgets/slot_selector_row.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:workmanager/workmanager.dart'; /// Lets the user log one food item, with food-bank autocomplete and /// today's slot status, or hop into [MealBuilderScreen] for a composite @@ -133,6 +136,27 @@ class _LogMealScreenState extends State } } + /// Queues a connectivity-gated WorkManager push so a meal logged while + /// offline still uploads on reconnect, without the app being reopened. The + /// in-process [_autoSync] covers the online case; this is the backstop. + /// [ExistingWorkPolicy.replace] coalesces a burst of logs into one pending + /// job, and backoff retries a push that fails once connectivity returns. + /// No-op off mobile (WorkManager ships only on Android/iOS). + // coverage:ignore-start + Future _enqueueSyncBackstop() async { + if (!Platform.isAndroid && !Platform.isIOS) return; + await Workmanager().registerOneOffTask( + syncPushTaskName, + syncPushTaskName, + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingWorkPolicy.replace, + backoffPolicy: BackoffPolicy.linear, + backoffPolicyDelay: const Duration(minutes: 1), + ); + } + + // coverage:ignore-end + Future _refreshSlots() async { final logged = await LogStorageService.instance.loggedSlotsToday(); if (!mounted) return; @@ -197,6 +221,9 @@ class _LogMealScreenState extends State // so the PC gate can see it in seconds. Fire-and-forget and best-effort: // _autoSync is single-flight and swallows offline/transient failures. unawaited(_autoSync()); + // Offline backstop: if the push above fails (no connectivity), a + // connectivity-gated WorkManager task uploads the meal on reconnect. + unawaited(_enqueueSyncBackstop()); if (!mounted) return; _descController.clear(); _macros.clear(); @@ -215,8 +242,10 @@ class _LogMealScreenState extends State MaterialPageRoute(builder: (_) => const MealBuilderScreen()), ); await _refreshSlots(); - // A meal built and logged in the builder should push right away too. + // A meal built and logged in the builder should push right away too, + // with the same offline backstop. unawaited(_autoSync()); + unawaited(_enqueueSyncBackstop()); } void _onOpenHistory() { diff --git a/app/lib/services/background_check_service.dart b/app/lib/services/background_check_service.dart index 3a476b5..fd32990 100644 --- a/app/lib/services/background_check_service.dart +++ b/app/lib/services/background_check_service.dart @@ -9,6 +9,7 @@ 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:workmanager/workmanager.dart'; @@ -42,6 +43,11 @@ Future checkAndNotify({DateTime? now}) async { @pragma('vm:entry-point') void backgroundCheckCallbackDispatcher() { Workmanager().executeTask((taskName, inputData) async { + if (taskName == syncPushTaskName) { + // Return the push's own success flag so a still-offline / failed push + // is retried with backoff rather than silently dropped. + return backgroundSyncPush(); + } if (taskName == backgroundCheckTaskName) { await checkAndNotify(); } diff --git a/app/lib/services/background_sync_service.dart b/app/lib/services/background_sync_service.dart new file mode 100644 index 0000000..2bed973 --- /dev/null +++ b/app/lib/services/background_sync_service.dart @@ -0,0 +1,53 @@ +/// Connectivity-gated background push: the offline backstop for the +/// immediate-push-on-log behaviour. When a meal is logged while the device is +/// offline the in-process auto-sync fails silently; a WorkManager one-off task +/// with a `NetworkType.connected` constraint (enqueued on every log) fires on +/// reconnect and uploads the log without the app being reopened. +library; + +import 'package:crdt_sync/crdt_sync.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_service.dart'; +import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:http/http.dart' as http; + +/// Unique WorkManager task name for the connectivity-gated push. +const String syncPushTaskName = 'diet_guard.sync_push'; + +/// Loads sync settings and pushes the local log, returning WorkManager's +/// success flag: `true` when there was nothing to push or the push +/// succeeded, `false` to ask WorkManager to retry (with backoff) after a +/// transient failure -- an offline moment or a GitHub hiccup. +/// +/// Extracted from the WorkManager dispatcher so it is unit-testable without +/// the real plugin (which only runs as a background isolate on-device), +/// exactly like [checkAndNotify]. [httpClient] is injectable for the same +/// reason. The service singletons are (re)initialised here because a fresh +/// background isolate has none; [LogStorageService.init] is idempotent, so +/// this is a no-op when a test has already pointed it at a temp dir. +Future backgroundSyncPush({http.Client? httpClient}) async { + await LogStorageService.init(); + await FoodBankService.init(); + final SyncSettings settings; + try { + settings = await SyncSettings.load(); + } on Exception { + return false; // couldn't read config in-isolate; let WorkManager retry + } + if (!settings.isConfigured) return true; // nothing to push; don't retry + final client = GitHubClient( + owner: settings.owner, + repo: settings.repo, + token: settings.token, + httpClient: httpClient, + ); + try { + await runSync(client); + return true; + } on Exception { + return false; // offline / transient GitHub error -> retry with backoff + } finally { + client.close(); + } +} diff --git a/app/lib/services/log_storage_service.dart b/app/lib/services/log_storage_service.dart index d93b2ad..ff05d7c 100644 --- a/app/lib/services/log_storage_service.dart +++ b/app/lib/services/log_storage_service.dart @@ -95,7 +95,14 @@ class LogStorageService { for (final mapEntry in log.entries) mapEntry.key: mapEntry.value.map((e) => e.toLocalJson()).toList(), }; - await _file.writeAsString(jsonEncode(encoded)); + // Write to a per-process temp file then atomically rename it over the + // real one, so a concurrent reader never sees a half-written file. The + // connectivity-gated background push (see background_sync_service.dart) + // can `runSync` -> writeLog in a separate isolate while the foreground + // app also writes; the pid in the temp name keeps the two from colliding. + final tmp = File('${_file.path}.$pid.tmp'); + await tmp.writeAsString(jsonEncode(encoded)); + await tmp.rename(_file.path); } /// Appends a signed-on-PC-eventually entry for [desc] to today's log. diff --git a/app/test/services/background_sync_service_test.dart b/app/test/services/background_sync_service_test.dart new file mode 100644 index 0000000..ee3ec9e --- /dev/null +++ b/app/test/services/background_sync_service_test.dart @@ -0,0 +1,86 @@ +// `backgroundSyncPush` is the unit-testable half of the connectivity-gated +// WorkManager backstop; the dispatcher branch and the one-off registration +// itself are integration-only (real WorkManager isolate, on-device smoke +// test) and excluded from coverage, exactly like the periodic check. + +import 'dart:io'; + +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'; +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_secure_storage.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_bg_sync_'); + LogStorageService.resetForTesting(testDir: tempDir); + FoodBankService.resetForTesting(testDir: tempDir); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + test('does nothing and does not retry when sync is unconfigured', () async { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + var puts = 0; + final mock = MockClient((req) async { + if (req.method == 'PUT') puts++; + return http.Response('', 404); + }); + + final ok = await backgroundSyncPush(httpClient: mock); + + expect(ok, isTrue); // nothing to do -> success, no retry + expect(puts, 0); + }); + + test('pushes and reports success when configured', () 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++; + return http.Response('{}', 200); // Contents-API PUT succeeded + } + // Bare `/repos//` GET is the repo-exists probe. + if (req.method == 'GET' && req.url.pathSegments.length == 3) { + return http.Response('{}', 200); + } + return http.Response('', 404); + }); + + final ok = await backgroundSyncPush(httpClient: mock); + + expect(ok, isTrue); + expect(puts, 1); + }); + + test('reports failure (retry) when the push errors', () async { + SharedPreferences.setMockInitialValues({ + 'sync.owner': 'o', + 'sync.repo': 'r', + }); + installFakeSecureStorage(initial: {'sync.token': 't'}); + final mock = MockClient((_) async => http.Response('boom', 500)); + + final ok = await backgroundSyncPush(httpClient: mock); + + expect(ok, isFalse); // transient -> WorkManager should retry + }); +}