Add background sync runner and rejected-sync UX

This commit is contained in:
Rijad Zuzo
2026-02-15 20:13:12 +01:00
parent add0105e6b
commit 497805ed3b
8 changed files with 361 additions and 2 deletions
+10
View File
@@ -26,6 +26,16 @@ class AppConfig {
static bool get useHiveLocalDb => static bool get useHiveLocalDb =>
const bool.fromEnvironment('USE_HIVE_LOCAL_DB', defaultValue: true); const bool.fromEnvironment('USE_HIVE_LOCAL_DB', defaultValue: true);
/// Enables periodic startup/resume sync triggers while authenticated.
static bool get enableBackgroundSync =>
const bool.fromEnvironment('ENABLE_BACKGROUND_SYNC', defaultValue: true);
/// Interval used for periodic background sync ticks.
static int get backgroundSyncIntervalSeconds => const int.fromEnvironment(
'BACKGROUND_SYNC_INTERVAL_SECONDS',
defaultValue: 180,
);
/// Runtime override for backend URL (e.g. local settings screen). /// Runtime override for backend URL (e.g. local settings screen).
static void overrideBackendBaseUrl(String? baseUrl) { static void overrideBackendBaseUrl(String? baseUrl) {
_baseUrlOverride = baseUrl; _baseUrlOverride = baseUrl;
@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
typedef SyncNowRunner = Future<SyncRunResult> Function();
/// Coordinates auto-triggered sync calls with cooldown and concurrency guards.
class SyncAutoTriggerController {
SyncAutoTriggerController({
required SyncNowRunner syncNow,
required DateTime Function() now,
this.minimumInterval = const Duration(minutes: 3),
}) : _syncNow = syncNow,
_now = now;
final SyncNowRunner _syncNow;
final DateTime Function() _now;
final Duration minimumInterval;
bool _running = false;
DateTime? _lastTriggeredAt;
@visibleForTesting
bool get isRunning => _running;
@visibleForTesting
DateTime? get lastTriggeredAt => _lastTriggeredAt;
/// Triggers `syncNow` when not already running and outside cooldown window.
Future<bool> trigger({bool ignoreCooldown = false}) async {
if (_running) {
return false;
}
final DateTime now = _now();
final DateTime? last = _lastTriggeredAt;
if (!ignoreCooldown &&
last != null &&
now.difference(last) < minimumInterval) {
return false;
}
_running = true;
_lastTriggeredAt = now;
try {
await _syncNow();
return true;
} catch (_) {
return true;
} finally {
_running = false;
}
}
}
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/sync/sync_auto_trigger_controller.dart';
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
/// Runs lightweight background sync triggers while authenticated.
class SyncBackgroundRunner extends ConsumerStatefulWidget {
const SyncBackgroundRunner({required this.child, super.key});
final Widget child;
@override
ConsumerState<SyncBackgroundRunner> createState() =>
_SyncBackgroundRunnerState();
}
class _SyncBackgroundRunnerState extends ConsumerState<SyncBackgroundRunner>
with WidgetsBindingObserver {
late final SyncAutoTriggerController _controller;
Timer? _periodicTimer;
@override
void initState() {
super.initState();
_controller = SyncAutoTriggerController(
syncNow: () => ref.read(syncCoordinatorProvider).syncNow(),
now: DateTime.now,
minimumInterval: Duration(
seconds: AppConfig.backgroundSyncIntervalSeconds,
),
);
WidgetsBinding.instance.addObserver(this);
_configurePeriodicRunner();
if (AppConfig.enableBackgroundSync) {
unawaited(_controller.trigger(ignoreCooldown: true));
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!AppConfig.enableBackgroundSync) {
return;
}
if (state == AppLifecycleState.resumed) {
unawaited(_controller.trigger(ignoreCooldown: true));
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_periodicTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
void _configurePeriodicRunner() {
if (!AppConfig.enableBackgroundSync) {
return;
}
final int seconds = AppConfig.backgroundSyncIntervalSeconds;
if (seconds <= 0) {
return;
}
_periodicTimer = Timer.periodic(Duration(seconds: seconds), (_) {
unawaited(_controller.trigger());
});
}
}
@@ -127,6 +127,22 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
); );
} }
/// Clears the latest rejection payload shown to the user.
Future<void> clearRejections() async {
final SyncState current = await _currentState();
final String? lastError =
current.lastError != null &&
current.lastError!.startsWith('Push rejected')
? null
: current.lastError;
await _setState(
current.copyWith(
lastRejected: const <MutationRejection>[],
lastError: lastError,
),
);
}
Future<SyncState> _currentState() async { Future<SyncState> _currentState() async {
final SyncState? value = state.asData?.value; final SyncState? value = state.asData?.value;
if (value != null) { if (value != null) {
+100
View File
@@ -5,6 +5,7 @@ import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/features/sync/sync_coordinator.dart'; import 'package:relationship_saver/features/sync/sync_coordinator.dart';
import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart';
import 'package:relationship_saver/features/sync/sync_state.dart'; import 'package:relationship_saver/features/sync/sync_state.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SyncView extends ConsumerStatefulWidget { class SyncView extends ConsumerStatefulWidget {
const SyncView({super.key}); const SyncView({super.key});
@@ -157,6 +158,39 @@ class _SyncViewState extends ConsumerState<SyncView> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (state.lastRejected.isNotEmpty) ...<Widget>[
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Text(
'Rejected Changes',
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: _busy ? null : _dismissRejections,
icon: const Icon(Icons.done_all_rounded),
label: const Text('Dismiss'),
),
],
),
const SizedBox(height: 8),
...state.lastRejected.map(
(MutationRejection rejection) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _rejectionTile(rejection),
),
),
],
),
),
const SizedBox(height: 16),
],
FrostedCard( FrostedCard(
child: Text( child: Text(
'Sync policy\n• Core usage never blocks on backend\n• Local changes queue first and remain safe offline\n• Pull applies backend envelopes into local state', 'Sync policy\n• Core usage never blocks on backend\n• Local changes queue first and remain safe offline\n• Pull applies backend envelopes into local state',
@@ -210,6 +244,53 @@ class _SyncViewState extends ConsumerState<SyncView> {
); );
} }
Widget _rejectionTile(MutationRejection rejection) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFEAF7FB),
borderRadius: BorderRadius.circular(999),
),
child: Text(
rejection.code,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(color: AppTheme.primary),
),
),
Text(
'Mutation ${rejection.clientMutationId}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
),
const SizedBox(height: 6),
Text(
rejection.message,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
String _formatDateTime(DateTime? value) { String _formatDateTime(DateTime? value) {
if (value == null) { if (value == null) {
return 'never'; return 'never';
@@ -254,6 +335,25 @@ class _SyncViewState extends ConsumerState<SyncView> {
} }
} }
Future<void> _dismissRejections() async {
setState(() {
_busy = true;
});
try {
await ref.read(syncQueueRepositoryProvider.notifier).clearRejections();
setState(() {
_status = 'Dismissed latest rejected-mutation details.';
});
} finally {
if (mounted) {
setState(() {
_busy = false;
});
}
}
}
Future<void> _run( Future<void> _run(
Future<SyncRunResult> Function(SyncCoordinator coordinator) action, Future<SyncRunResult> Function(SyncCoordinator coordinator) action,
) async { ) async {
+4 -2
View File
@@ -4,6 +4,7 @@ import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart'; import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/auth/sign_in_view.dart'; import 'package:relationship_saver/features/auth/sign_in_view.dart';
import 'package:relationship_saver/features/home/app_shell.dart'; import 'package:relationship_saver/features/home/app_shell.dart';
import 'package:relationship_saver/features/sync/sync_background_runner.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
void main() { void main() {
@@ -25,8 +26,9 @@ class RelationshipSaverApp extends ConsumerWidget {
theme: AppTheme.light(), theme: AppTheme.light(),
home: Scaffold( home: Scaffold(
body: sessionState.when( body: sessionState.when(
data: (session) => data: (session) => session == null
session == null ? const SignInView() : const AppShell(), ? const SignInView()
: const SyncBackgroundRunner(child: AppShell()),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) => const SignInView(), error: (Object error, StackTrace stackTrace) => const SignInView(),
), ),
@@ -0,0 +1,65 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/features/sync/sync_auto_trigger_controller.dart';
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
void main() {
test('triggers sync once and respects cooldown interval', () async {
int callCount = 0;
DateTime now = DateTime(2026, 2, 15, 12);
final SyncAutoTriggerController controller = SyncAutoTriggerController(
syncNow: () async {
callCount += 1;
return _result(success: true);
},
now: () => now,
minimumInterval: const Duration(seconds: 60),
);
final bool first = await controller.trigger();
final bool second = await controller.trigger();
now = now.add(const Duration(seconds: 61));
final bool third = await controller.trigger();
expect(first, isTrue);
expect(second, isFalse);
expect(third, isTrue);
expect(callCount, 2);
expect(controller.lastTriggeredAt, now);
});
test('does not run a second sync while a run is still in progress', () async {
int callCount = 0;
final Completer<void> completer = Completer<void>();
final SyncAutoTriggerController controller = SyncAutoTriggerController(
syncNow: () async {
callCount += 1;
await completer.future;
return _result(success: true);
},
now: DateTime.now,
minimumInterval: Duration.zero,
);
final Future<bool> first = controller.trigger();
final bool second = await controller.trigger();
completer.complete();
final bool firstCompleted = await first;
expect(firstCompleted, isTrue);
expect(second, isFalse);
expect(callCount, 1);
});
}
SyncRunResult _result({required bool success}) {
return SyncRunResult(
success: success,
operation: 'sync',
message: success ? 'ok' : 'fail',
pendingAfter: 0,
);
}
@@ -70,6 +70,43 @@ void main() {
expect(state.lastError, contains('Push rejected')); expect(state.lastError, contains('Push rejected'));
}, },
); );
test(
'clearRejections removes last rejected payload and rejection error',
() async {
final ProviderContainer container = ProviderContainer(
overrides: [
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
],
);
addTearDown(container.dispose);
final SyncQueueRepository notifier = container.read(
syncQueueRepositoryProvider.notifier,
);
await notifier.enqueue(_change(id: 'p-1', mutationId: 'cm-r'));
await notifier.applyPushResult(
SyncPushResult(
cursor: 'cursor-3',
accepted: const <MutationAck>[],
rejected: const <MutationRejection>[
MutationRejection(
clientMutationId: 'cm-r',
code: 'VALIDATION_FAILED',
message: 'invalid payload',
),
],
),
at: DateTime(2026, 2, 15, 13),
);
await notifier.clearRejections();
final SyncStateData state = await _readState(container);
expect(state.rejected, isEmpty);
expect(state.lastError, isNull);
},
);
} }
class SyncStateData { class SyncStateData {