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
@@ -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;
}