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
+2 -220
View File
@@ -1,220 +1,2 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store_hive.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store_shared_prefs.dart';
import 'package:relationship_saver/features/sync/sync_state.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Persists queued local mutations and sync cursor metadata.
class SyncQueueRepository extends AsyncNotifier<SyncState> {
@override
Future<SyncState> build() async {
final SyncStateStore store = ref.read(syncStateStoreProvider);
SyncStateRecord? record = await store.read();
if ((record == null || record.rawState.isEmpty) &&
AppConfig.useHiveLocalDb &&
store is HiveSyncStateStore) {
final SharedPrefsSyncStateStore legacyStore = SharedPrefsSyncStateStore();
final SyncStateRecord? legacy = await legacyStore.read();
if (legacy != null && legacy.rawState.isNotEmpty) {
await store.write(rawState: legacy.rawState);
await legacyStore.clear();
record = await store.read();
}
}
if (record == null || record.rawState.isEmpty) {
return SyncState.empty;
}
try {
final Map<String, dynamic> json =
jsonDecode(record.rawState) as Map<String, dynamic>;
return SyncState.fromJson(json);
} on FormatException {
await store.clear();
return SyncState.empty;
}
}
Future<void> enqueue(ChangeEnvelope change) async {
final SyncState current = await _currentState();
final List<ChangeEnvelope> pending = <ChangeEnvelope>[
change,
...current.pendingChanges,
];
await _setState(current.copyWith(pendingChanges: pending));
}
Future<void> enqueueAll(Iterable<ChangeEnvelope> changes) async {
final List<ChangeEnvelope> values = changes.toList(growable: false);
if (values.isEmpty) {
return;
}
final SyncState current = await _currentState();
final List<ChangeEnvelope> pending = <ChangeEnvelope>[
...values,
...current.pendingChanges,
];
await _setState(current.copyWith(pendingChanges: pending));
}
Future<void> applyPushResult(SyncPushResult result, {DateTime? at}) async {
final SyncState current = await _currentState();
final DateTime now = at ?? DateTime.now();
final Set<String> rejectedMutationIds = <String>{
...result.rejected.map(
(MutationRejection rejection) => rejection.clientMutationId,
),
};
final Set<String> completedMutationIds = <String>{
...result.accepted.map((MutationAck ack) => ack.clientMutationId),
...rejectedMutationIds,
};
final List<ChangeEnvelope> rejectedChanges = current.pendingChanges
.where(
(ChangeEnvelope change) =>
rejectedMutationIds.contains(change.clientMutationId),
)
.toList(growable: false);
final List<ChangeEnvelope> pending = current.pendingChanges
.where(
(ChangeEnvelope change) =>
!completedMutationIds.contains(change.clientMutationId),
)
.toList(growable: false);
final List<ChangeEnvelope> dedupedRejectedChanges = rejectedChanges
.where(
(ChangeEnvelope change) => !pending.any(
(ChangeEnvelope pendingChange) =>
pendingChange.clientMutationId == change.clientMutationId,
),
)
.toList(growable: false);
final String? rejectionMessage = result.rejected.isEmpty
? null
: 'Push rejected ${result.rejected.length} change(s). Fix locally and retry.';
await _setState(
current.copyWith(
cursor: result.cursor,
pendingChanges: pending,
lastAttemptAt: now,
lastSyncAt: now,
lastError: rejectionMessage,
lastRejected: result.rejected,
lastRejectedChanges: dedupedRejectedChanges,
),
);
}
/// Requeues rejected changes to pending sync list for manual retry.
Future<void> requeueRejectedChanges() async {
final SyncState current = await _currentState();
if (current.lastRejectedChanges.isEmpty) {
return;
}
final List<ChangeEnvelope> pending = <ChangeEnvelope>[
...current.lastRejectedChanges,
...current.pendingChanges,
];
final List<ChangeEnvelope> dedupedPending = _dedupeByMutationId(pending);
await _setState(
current.copyWith(
pendingChanges: dedupedPending,
lastRejected: const <MutationRejection>[],
lastRejectedChanges: const <ChangeEnvelope>[],
lastError: null,
),
);
}
Future<void> applyPullResult(SyncPullResult result, {DateTime? at}) async {
final SyncState current = await _currentState();
final DateTime now = at ?? DateTime.now();
await _setState(
current.copyWith(
cursor: result.cursor,
lastAttemptAt: now,
lastSyncAt: now,
lastError: null,
lastRejected: const <MutationRejection>[],
lastRejectedChanges: const <ChangeEnvelope>[],
),
);
}
Future<void> markFailure(Object error, {DateTime? at}) async {
final SyncState current = await _currentState();
final DateTime now = at ?? DateTime.now();
await _setState(current.copyWith(lastAttemptAt: now, lastError: '$error'));
}
Future<void> clearQueue() async {
final SyncState current = await _currentState();
await _setState(
current.copyWith(
pendingChanges: const <ChangeEnvelope>[],
lastRejected: const <MutationRejection>[],
lastRejectedChanges: const <ChangeEnvelope>[],
),
);
}
/// 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>[],
lastRejectedChanges: const <ChangeEnvelope>[],
lastError: lastError,
),
);
}
List<ChangeEnvelope> _dedupeByMutationId(List<ChangeEnvelope> changes) {
final Set<String> seen = <String>{};
final List<ChangeEnvelope> deduped = <ChangeEnvelope>[];
for (final ChangeEnvelope change in changes) {
if (seen.add(change.clientMutationId)) {
deduped.add(change);
}
}
return deduped;
}
Future<SyncState> _currentState() async {
final SyncState? value = state.asData?.value;
if (value != null) {
return value;
}
return future;
}
Future<void> _setState(SyncState next) async {
state = AsyncData<SyncState>(next);
await ref
.read(syncStateStoreProvider)
.write(rawState: jsonEncode(next.toJson()));
}
}
final AsyncNotifierProvider<SyncQueueRepository, SyncState>
syncQueueRepositoryProvider =
AsyncNotifierProvider<SyncQueueRepository, SyncState>(
SyncQueueRepository.new,
);
// Legacy compatibility export for the sync queue data store.
export 'package:relationship_saver/features/sync/data/sync_queue_repository.dart';