feat: add local-first private AI digest workflow

Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
+4
View File
@@ -0,0 +1,4 @@
# Sync Application
Sync coordination, background triggers, and orchestration logic live here. Use
this folder when you need to change how local changes are pushed or retried.
@@ -0,0 +1,84 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
typedef SyncNowRunner = Future<SyncRunResult> Function();
typedef TriggerGate = Future<bool> Function();
/// Coordinates auto-triggered sync calls with cooldown and concurrency guards.
class SyncAutoTriggerController {
SyncAutoTriggerController({
required SyncNowRunner syncNow,
required DateTime Function() now,
TriggerGate? canTrigger,
this.minimumInterval = const Duration(minutes: 3),
}) : _syncNow = syncNow,
_now = now,
_canTrigger = canTrigger ?? _alwaysAllowed;
final SyncNowRunner _syncNow;
final DateTime Function() _now;
final TriggerGate _canTrigger;
final Duration minimumInterval;
bool _running = false;
DateTime? _lastTriggeredAt;
int _consecutiveFailures = 0;
@visibleForTesting
bool get isRunning => _running;
@visibleForTesting
DateTime? get lastTriggeredAt => _lastTriggeredAt;
@visibleForTesting
int get consecutiveFailures => _consecutiveFailures;
/// 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) < _cooldownForCurrentState()) {
return false;
}
if (!await _canTrigger()) {
return false;
}
_running = true;
_lastTriggeredAt = now;
try {
final SyncRunResult result = await _syncNow();
if (result.success) {
_consecutiveFailures = 0;
} else {
_consecutiveFailures += 1;
}
return true;
} catch (_) {
_consecutiveFailures += 1;
return true;
} finally {
_running = false;
}
}
Duration _cooldownForCurrentState() {
if (_consecutiveFailures <= 0) {
return minimumInterval;
}
final int cappedFailures = math.min(_consecutiveFailures, 4);
final int multiplier = 1 << cappedFailures;
return minimumInterval * multiplier;
}
static Future<bool> _alwaysAllowed() async => true;
}
@@ -0,0 +1,107 @@
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/core/network/reachability/network_reachability.dart';
import 'package:relationship_saver/core/network/reachability/network_reachability_provider.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;
StreamSubscription<bool>? _reachabilitySubscription;
bool? _lastReachable;
@override
void initState() {
super.initState();
final reachability = ref.read(networkReachabilityProvider);
_controller = SyncAutoTriggerController(
syncNow: () => ref.read(syncCoordinatorProvider).syncNow(),
now: DateTime.now,
minimumInterval: Duration(
seconds: AppConfig.backgroundSyncIntervalSeconds,
),
canTrigger: () async {
if (AppConfig.useFakeBackend) {
return true;
}
return reachability.isReachable();
},
);
WidgetsBinding.instance.addObserver(this);
_configurePeriodicRunner();
_configureReachabilityRunner(reachability);
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();
_reachabilitySubscription?.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());
});
}
void _configureReachabilityRunner(NetworkReachability reachability) {
if (!AppConfig.enableBackgroundSync) {
return;
}
_reachabilitySubscription = reachability.watch().listen((bool reachable) {
final bool? previous = _lastReachable;
_lastReachable = reachable;
if (reachable && previous == false) {
unawaited(_controller.trigger(ignoreCooldown: true));
}
});
unawaited(_primeReachabilityState(reachability));
}
Future<void> _primeReachabilityState(NetworkReachability reachability) async {
_lastReachable = await reachability.isReachable();
}
}
@@ -0,0 +1,179 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/local/local_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/integrations/backend/backend_gateway_provider.dart';
/// Result details for one sync action.
@immutable
class SyncRunResult {
const SyncRunResult({
required this.success,
required this.operation,
required this.message,
required this.pendingAfter,
this.cursor,
this.accepted = 0,
this.rejected = 0,
this.pulled = 0,
this.error,
});
final bool success;
final String operation;
final String message;
final int pendingAfter;
final String? cursor;
final int accepted;
final int rejected;
final int pulled;
final Object? error;
}
/// Coordinates push/pull sync operations across queue, gateway, and local store.
class SyncCoordinator {
const SyncCoordinator(this._ref);
final Ref _ref;
Future<SyncRunResult> pushPending() async {
final SyncState queueState = await _ref.read(
syncQueueRepositoryProvider.future,
);
if (queueState.pendingChanges.isEmpty) {
return SyncRunResult(
success: true,
operation: 'push',
message: 'No pending local changes to push.',
pendingAfter: 0,
cursor: queueState.cursor,
);
}
final DateTime now = DateTime.now();
final syncQueue = _ref.read(syncQueueRepositoryProvider.notifier);
try {
final result = await _ref
.read(backendGatewayProvider)
.pushChanges(
changes: queueState.pendingChanges,
cursor: queueState.cursor,
);
await syncQueue.applyPushResult(result, at: now);
final SyncState after = await _ref.read(
syncQueueRepositoryProvider.future,
);
return SyncRunResult(
success: true,
operation: 'push',
message:
'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}.',
accepted: result.accepted.length,
rejected: result.rejected.length,
pendingAfter: after.pendingChanges.length,
cursor: after.cursor,
);
} catch (error) {
await syncQueue.markFailure(error, at: now);
final SyncState after = await _ref.read(
syncQueueRepositoryProvider.future,
);
return SyncRunResult(
success: false,
operation: 'push',
message: 'Push failed. Local changes stay queued.',
pendingAfter: after.pendingChanges.length,
cursor: after.cursor,
error: error,
);
}
}
Future<SyncRunResult> pullRemote() async {
final SyncState queueState = await _ref.read(
syncQueueRepositoryProvider.future,
);
final DateTime now = DateTime.now();
final syncQueue = _ref.read(syncQueueRepositoryProvider.notifier);
try {
final result = await _ref
.read(backendGatewayProvider)
.pullChanges(cursor: queueState.cursor);
await _ref
.read(localRepositoryProvider.notifier)
.applyRemoteChanges(result.changes);
await syncQueue.applyPullResult(result, at: now);
final SyncState after = await _ref.read(
syncQueueRepositoryProvider.future,
);
return SyncRunResult(
success: true,
operation: 'pull',
message: 'Pulled ${result.changes.length} change(s) from backend.',
pulled: result.changes.length,
pendingAfter: after.pendingChanges.length,
cursor: after.cursor,
);
} catch (error) {
await syncQueue.markFailure(error, at: now);
final SyncState after = await _ref.read(
syncQueueRepositoryProvider.future,
);
return SyncRunResult(
success: false,
operation: 'pull',
message: 'Pull failed. Staying in local-first mode.',
pendingAfter: after.pendingChanges.length,
cursor: after.cursor,
error: error,
);
}
}
Future<SyncRunResult> syncNow() async {
final SyncRunResult pushResult = await pushPending();
if (!pushResult.success) {
return SyncRunResult(
success: false,
operation: 'sync',
message: 'Sync paused at push step. ${pushResult.message}',
pendingAfter: pushResult.pendingAfter,
cursor: pushResult.cursor,
accepted: pushResult.accepted,
rejected: pushResult.rejected,
error: pushResult.error,
);
}
final SyncRunResult pullResult = await pullRemote();
if (!pullResult.success) {
return SyncRunResult(
success: false,
operation: 'sync',
message: 'Sync paused at pull step. ${pullResult.message}',
pendingAfter: pullResult.pendingAfter,
cursor: pullResult.cursor,
accepted: pushResult.accepted,
rejected: pushResult.rejected,
error: pullResult.error,
);
}
return SyncRunResult(
success: true,
operation: 'sync',
message:
'Sync complete. accepted=${pushResult.accepted}, rejected=${pushResult.rejected}, pulled=${pullResult.pulled}.',
pendingAfter: pullResult.pendingAfter,
cursor: pullResult.cursor,
accepted: pushResult.accepted,
rejected: pushResult.rejected,
pulled: pullResult.pulled,
);
}
}
final Provider<SyncCoordinator> syncCoordinatorProvider =
Provider<SyncCoordinator>(SyncCoordinator.new);