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
@@ -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 {
final SyncState? value = state.asData?.value;
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_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<SyncView> {
),
),
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(
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<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) {
if (value == null) {
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<SyncRunResult> Function(SyncCoordinator coordinator) action,
) async {