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,176 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user