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:
@@ -0,0 +1,4 @@
|
||||
# Sync Data
|
||||
|
||||
Sync repositories and storage adapters live here. This is the persistence layer
|
||||
for sync state, separate from the app's main local relationship store.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Sync Data Storage
|
||||
|
||||
This folder contains the concrete persistence adapters for sync-specific state.
|
||||
Keep it storage-focused and free of higher-level retry policy logic.
|
||||
@@ -0,0 +1,18 @@
|
||||
/// Raw persisted payload for sync queue state.
|
||||
class SyncStateRecord {
|
||||
const SyncStateRecord({required this.rawState});
|
||||
|
||||
final String rawState;
|
||||
}
|
||||
|
||||
/// Persistence boundary for sync queue metadata and pending changes.
|
||||
abstract interface class SyncStateStore {
|
||||
/// Reads stored sync state payload.
|
||||
Future<SyncStateRecord?> read();
|
||||
|
||||
/// Writes sync state payload.
|
||||
Future<void> write({required String rawState});
|
||||
|
||||
/// Clears stored sync state payload.
|
||||
Future<void> clear();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart';
|
||||
|
||||
/// Hive-backed sync state store.
|
||||
class HiveSyncStateStore implements SyncStateStore {
|
||||
static const String _boxName = 'relationship_saver_sync';
|
||||
static const String _stateKey = 'sync_state_json';
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final Box<dynamic> box = await _openBox();
|
||||
await box.delete(_stateKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncStateRecord?> read() async {
|
||||
final Box<dynamic> box = await _openBox();
|
||||
final String? rawState = box.get(_stateKey) as String?;
|
||||
if (rawState == null || rawState.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SyncStateRecord(rawState: rawState);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({required String rawState}) async {
|
||||
final Box<dynamic> box = await _openBox();
|
||||
await box.put(_stateKey, rawState);
|
||||
}
|
||||
|
||||
Future<Box<dynamic>> _openBox() async {
|
||||
if (!_initialized) {
|
||||
await Hive.initFlutter();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
if (!Hive.isBoxOpen(_boxName)) {
|
||||
return Hive.openBox<dynamic>(_boxName);
|
||||
}
|
||||
|
||||
return Hive.box<dynamic>(_boxName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart';
|
||||
|
||||
/// In-memory sync state store for tests.
|
||||
class InMemorySyncStateStore implements SyncStateStore {
|
||||
SyncStateRecord? _record;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
_record = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncStateRecord?> read() async => _record;
|
||||
|
||||
@override
|
||||
Future<void> write({required String rawState}) async {
|
||||
_record = SyncStateRecord(rawState: rawState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_hive.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_shared_prefs.dart';
|
||||
|
||||
/// Chooses sync queue persistence backend.
|
||||
final Provider<SyncStateStore> syncStateStoreProvider =
|
||||
Provider<SyncStateStore>((Ref ref) {
|
||||
if (AppConfig.useHiveLocalDb) {
|
||||
return HiveSyncStateStore();
|
||||
}
|
||||
return SharedPrefsSyncStateStore();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// shared_preferences-backed sync state store.
|
||||
class SharedPrefsSyncStateStore implements SyncStateStore {
|
||||
SharedPrefsSyncStateStore({this.stateKey = 'sync_queue_state_v1'});
|
||||
|
||||
final String stateKey;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(stateKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncStateRecord?> read() async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
final String? rawState = prefs.getString(stateKey);
|
||||
if (rawState == null || rawState.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SyncStateRecord(rawState: rawState);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({required String rawState}) async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(stateKey, rawState);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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/data/storage/sync_state_store.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_hive.dart';
|
||||
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart';
|
||||
import 'package:relationship_saver/features/sync/data/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,
|
||||
);
|
||||
Reference in New Issue
Block a user