f655adfbea
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.
177 lines
6.6 KiB
Dart
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);
|