diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index c900263..f97d34d 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -26,6 +26,16 @@ class AppConfig { static bool get useHiveLocalDb => 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). static void overrideBackendBaseUrl(String? baseUrl) { _baseUrlOverride = baseUrl; diff --git a/lib/features/sync/sync_auto_trigger_controller.dart b/lib/features/sync/sync_auto_trigger_controller.dart new file mode 100644 index 0000000..dacc98f --- /dev/null +++ b/lib/features/sync/sync_auto_trigger_controller.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; +import 'package:relationship_saver/features/sync/sync_coordinator.dart'; + +typedef SyncNowRunner = Future 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 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; + } + } +} diff --git a/lib/features/sync/sync_background_runner.dart b/lib/features/sync/sync_background_runner.dart new file mode 100644 index 0000000..f5d78d2 --- /dev/null +++ b/lib/features/sync/sync_background_runner.dart @@ -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 createState() => + _SyncBackgroundRunnerState(); +} + +class _SyncBackgroundRunnerState extends ConsumerState + 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()); + }); + } +} diff --git a/lib/features/sync/sync_queue_repository.dart b/lib/features/sync/sync_queue_repository.dart index d3a7a02..96b2f82 100644 --- a/lib/features/sync/sync_queue_repository.dart +++ b/lib/features/sync/sync_queue_repository.dart @@ -127,6 +127,22 @@ class SyncQueueRepository extends AsyncNotifier { ); } + /// Clears the latest rejection payload shown to the user. + Future 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 [], + lastError: lastError, + ), + ); + } + Future _currentState() async { final SyncState? value = state.asData?.value; if (value != null) { diff --git a/lib/features/sync/sync_view.dart b/lib/features/sync/sync_view.dart index 65b1b08..6497be7 100644 --- a/lib/features/sync/sync_view.dart +++ b/lib/features/sync/sync_view.dart @@ -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_queue_repository.dart'; import 'package:relationship_saver/features/sync/sync_state.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; class SyncView extends ConsumerStatefulWidget { const SyncView({super.key}); @@ -157,6 +158,39 @@ class _SyncViewState extends ConsumerState { ), ), const SizedBox(height: 16), + if (state.lastRejected.isNotEmpty) ...[ + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + 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( 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', @@ -210,6 +244,53 @@ class _SyncViewState extends ConsumerState { ); } + 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: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + 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) { if (value == null) { return 'never'; @@ -254,6 +335,25 @@ class _SyncViewState extends ConsumerState { } } + Future _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 _run( Future Function(SyncCoordinator coordinator) action, ) async { diff --git a/lib/main.dart b/lib/main.dart index 1b705ac..e4ef309 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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/sign_in_view.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'; void main() { @@ -25,8 +26,9 @@ class RelationshipSaverApp extends ConsumerWidget { theme: AppTheme.light(), home: Scaffold( body: sessionState.when( - data: (session) => - session == null ? const SignInView() : const AppShell(), + data: (session) => session == null + ? const SignInView() + : const SyncBackgroundRunner(child: AppShell()), loading: () => const Center(child: CircularProgressIndicator()), error: (Object error, StackTrace stackTrace) => const SignInView(), ), diff --git a/test/features/sync/sync_auto_trigger_controller_test.dart b/test/features/sync/sync_auto_trigger_controller_test.dart new file mode 100644 index 0000000..e6afe60 --- /dev/null +++ b/test/features/sync/sync_auto_trigger_controller_test.dart @@ -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 completer = Completer(); + + final SyncAutoTriggerController controller = SyncAutoTriggerController( + syncNow: () async { + callCount += 1; + await completer.future; + return _result(success: true); + }, + now: DateTime.now, + minimumInterval: Duration.zero, + ); + + final Future 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, + ); +} diff --git a/test/features/sync/sync_queue_repository_test.dart b/test/features/sync/sync_queue_repository_test.dart index 705d2cf..0ca66ee 100644 --- a/test/features/sync/sync_queue_repository_test.dart +++ b/test/features/sync/sync_queue_repository_test.dart @@ -70,6 +70,43 @@ void main() { 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 [], + rejected: const [ + 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 {