Files
rely/lib/app/data/relationship_repository.dart
T
Rijad Zuzo f655adfbea 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.
2026-05-17 00:17:20 +02:00

177 lines
6.6 KiB
Dart

import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/local/chat_preference_extractor.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
import 'package:relationship_saver/features/share_intake/application/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
import 'package:relationship_saver/features/sync/data/sync_queue_repository.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import 'package:uuid/uuid.dart';
part 'relationship_repository_activity.dart';
part 'relationship_repository_ai_digest.dart';
part 'relationship_repository_people.dart';
part 'relationship_repository_share.dart';
part 'relationship_repository_sync.dart';
const int _schemaVersion = 5;
const int _syncSchemaVersion = 1;
const Uuid _uuid = Uuid();
const ChatPreferenceExtractor _chatPreferenceExtractor =
ChatPreferenceExtractor();
/// Persisted local-first app store used by the current prototype.
///
/// The storage backend is still a single local record because that keeps the
/// prototype stable, but the repository implementation is now split by feature
/// behavior so it reads like vertical slices instead of one monolithic file.
class LocalRepository extends AsyncNotifier<LocalDataState> {
@override
Future<LocalDataState> build() async {
final LocalDataStore store = ref.read(localDataStoreProvider);
LocalDataRecord? record = await store.read();
if ((record == null || record.rawState.isEmpty) &&
AppConfig.useHiveLocalDb &&
store is HiveLocalDataStore) {
final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore();
final LocalDataRecord? legacy = await legacyStore.read();
if (legacy != null && legacy.rawState.isNotEmpty) {
await store.write(
rawState: legacy.rawState,
schemaVersion: legacy.schemaVersion,
);
await legacyStore.clear();
record = await store.read();
}
}
if (record == null || record.rawState.isEmpty) {
final LocalDataState seeded = LocalDataState.seed();
await _persist(seeded);
await _reconcileReminderSchedule(seeded);
return seeded;
}
final LocalDataRecord migrated = await _migrateIfNeeded(record);
try {
final Map<String, dynamic> json =
jsonDecode(migrated.rawState) as Map<String, dynamic>;
final LocalDataState loaded = LocalDataState.fromJson(json);
await _reconcileReminderSchedule(loaded);
return loaded;
} on FormatException {
final LocalDataState fallback = LocalDataState.seed();
await _persist(fallback);
await _reconcileReminderSchedule(fallback);
return fallback;
}
}
Future<void> _setState(LocalDataState next) async {
state = AsyncData<LocalDataState>(next);
await _persist(next);
await _reconcileReminderSchedule(next);
}
LocalDataState _requireState() {
final LocalDataState? value = state.asData?.value;
if (value == null) {
throw StateError('Local data state is not ready yet');
}
return value;
}
Future<void> _persist(LocalDataState state) async {
await ref
.read(localDataStoreProvider)
.write(
rawState: jsonEncode(state.toJson()),
schemaVersion: _schemaVersion,
);
}
Future<void> _reconcileReminderSchedule(LocalDataState state) async {
try {
await ref.read(reminderSchedulerProvider).reconcile(state.reminders);
} catch (_) {
// Scheduling failures must not block local-first data updates.
}
}
Future<LocalDataRecord> _migrateIfNeeded(LocalDataRecord record) async {
final int currentVersion = record.schemaVersion;
if (currentVersion == _schemaVersion) {
return record;
}
final LocalDataStore store = ref.read(localDataStoreProvider);
if (currentVersion <= 0) {
final LocalDataState seeded = LocalDataState.seed();
await store.clear();
await _persist(seeded);
return LocalDataRecord(
rawState: jsonEncode(seeded.toJson()),
schemaVersion: _schemaVersion,
);
}
try {
final Map<String, dynamic> json =
jsonDecode(record.rawState) as Map<String, dynamic>;
final LocalDataState migratedState = LocalDataState.fromJson(json);
final String migratedRawState = jsonEncode(migratedState.toJson());
await store.write(
rawState: migratedRawState,
schemaVersion: _schemaVersion,
);
return LocalDataRecord(
rawState: migratedRawState,
schemaVersion: _schemaVersion,
);
} on FormatException {
final LocalDataState seeded = LocalDataState.seed();
await store.clear();
await _persist(seeded);
return LocalDataRecord(
rawState: jsonEncode(seeded.toJson()),
schemaVersion: _schemaVersion,
);
}
}
Future<void> _enqueueChange(ChangeEnvelope change) async {
await ref.read(syncQueueRepositoryProvider.notifier).enqueue(change);
}
Future<void> _enqueueChanges(List<ChangeEnvelope> changes) async {
await ref.read(syncQueueRepositoryProvider.notifier).enqueueAll(changes);
}
}
String? _trimToNull(String? value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
final AsyncNotifierProvider<LocalRepository, LocalDataState>
localRepositoryProvider =
AsyncNotifierProvider<LocalRepository, LocalDataState>(LocalRepository.new);