Add background sync runner and rejected-sync UX
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user