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