app: offline backstop — connectivity-gated push on reconnect
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

Immediate push on log only helps when the device is online. If a meal is
logged while offline, the in-process auto-sync fails silently and the meal
waited for the next app lifecycle event. Now each log also enqueues a one-off
WorkManager task constrained to NetworkType.connected, so the log uploads as
soon as connectivity returns, without the app being reopened.

- background_sync_service.dart: backgroundSyncPush() loads SyncSettings and
  runs runSync, returning WorkManager's success/retry flag (true when nothing
  to push or the push succeeded; false to retry after a transient failure).
  Unit-testable half; the dispatcher branch and the one-off registration are
  integration-only (real background isolate) and coverage-ignored, matching
  the existing periodic check.
- log_meal_screen.dart: _enqueueSyncBackstop() registers the task (replace
  policy coalesces a burst of logs; linear backoff retries) from both log
  paths. No-op off mobile.
- background_check_service.dart: dispatcher routes the new task name.
- log_storage_service.dart: writeLog is now atomic (temp file + rename) — the
  background push's runSync can write the log in a separate isolate while the
  foreground app also writes, so a torn read must be impossible.

Verified on device (BL9000EEA0000102): airplane mode on -> log -> background;
airplane mode off -> the phone's remote food_log.json commit advanced within
~15s, app never reopened (also confirms the token loads in the background
isolate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd
This commit is contained in:
Krzysztof Rudnicki 2026-07-11 19:11:54 +02:00
parent 16ca5227f6
commit 67bc98c7e0
5 changed files with 183 additions and 2 deletions

View File

@ -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<LogMealScreen>
}
}
/// 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<void> _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<void> _refreshSlots() async {
final logged = await LogStorageService.instance.loggedSlotsToday();
if (!mounted) return;
@ -197,6 +221,9 @@ class _LogMealScreenState extends State<LogMealScreen>
// 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<LogMealScreen>
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() {

View File

@ -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<void> 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();
}

View File

@ -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<bool> 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();
}
}

View File

@ -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.

View File

@ -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/<owner>/<repo>` 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
});
}