import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; /// Persisted local data source for offline-first product state. class LocalRepository extends AsyncNotifier { static const String _storageKey = 'local_data_state_v1'; static const String _schemaVersionKey = 'local_data_schema_version'; static const int _schemaVersion = 2; static const int _syncSchemaVersion = 1; static const Uuid _uuid = Uuid(); @override Future build() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); await _migrateIfNeeded(prefs); final String? raw = prefs.getString(_storageKey); if (raw == null || raw.isEmpty) { final LocalDataState seeded = LocalDataState.seed(); await _persist(seeded); return seeded; } try { final Map json = jsonDecode(raw) as Map; return LocalDataState.fromJson(json); } on FormatException { final LocalDataState fallback = LocalDataState.seed(); await _persist(fallback); return fallback; } } Future addPerson({ required String name, required String relationship, required String notes, required List tags, DateTime? nextMoment, }) async { final String normalizedName = name.trim(); final String normalizedRelationship = relationship.trim(); if (normalizedName.isEmpty || normalizedRelationship.isEmpty) { throw ArgumentError('Name and relationship are required'); } final LocalDataState current = _requireState(); final PersonProfile person = PersonProfile( id: 'p-${_uuid.v4()}', name: normalizedName, relationship: normalizedRelationship, affinityScore: 75, nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)), tags: tags, notes: notes.trim(), ); final List people = [ person, ...current.people, ]; await _setState(current.copyWith(people: people)); await _enqueueChange( _buildEnvelope( entityType: 'person', entityId: person.id, op: ChangeOperation.upsert, payload: _personPayload(person), ), ); } Future updatePerson(PersonProfile person) async { if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) { throw ArgumentError('Name and relationship are required'); } final LocalDataState current = _requireState(); final List updated = current.people .map((PersonProfile item) => item.id == person.id ? person : item) .toList(growable: false); await _setState(current.copyWith(people: updated)); await _enqueueChange( _buildEnvelope( entityType: 'person', entityId: person.id, op: ChangeOperation.upsert, payload: _personPayload(person), ), ); } Future deletePerson(String personId) async { final LocalDataState current = _requireState(); final List removedMoments = current.moments .where((RelationshipMoment moment) => moment.personId == personId) .toList(growable: false); final List removedIdeas = current.ideas .where((RelationshipIdea idea) => idea.personId == personId) .toList(growable: false); final List removedReminders = current.reminders .where((ReminderRule reminder) => reminder.personId == personId) .toList(growable: false); final List people = current.people .where((PersonProfile person) => person.id != personId) .toList(growable: false); final List moments = current.moments .where((RelationshipMoment moment) => moment.personId != personId) .toList(growable: false); final List ideas = current.ideas .where((RelationshipIdea idea) => idea.personId != personId) .toList(growable: false); final List reminders = current.reminders .where((ReminderRule reminder) => reminder.personId != personId) .toList(growable: false); await _setState( current.copyWith( people: people, moments: moments, ideas: ideas, reminders: reminders, ), ); await _enqueueChanges([ _buildEnvelope( entityType: 'person', entityId: personId, op: ChangeOperation.delete, ), ...removedMoments.map( (RelationshipMoment moment) => _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.delete, ), ), ...removedIdeas.map( (RelationshipIdea idea) => _buildEnvelope( entityType: _ideaEntityType(idea.type), entityId: idea.id, op: ChangeOperation.delete, ), ), ...removedReminders.map( (ReminderRule reminder) => _buildEnvelope( entityType: 'reminderRule', entityId: reminder.id, op: ChangeOperation.delete, ), ), ]); } Future addMoment({ required String personId, required String summary, String type = 'capture', }) async { final LocalDataState current = _requireState(); final String normalized = summary.trim(); if (normalized.isEmpty) { throw ArgumentError('Capture summary cannot be empty'); } final String payload = normalized.length > 280 ? normalized.substring(0, 280) : normalized; final String title = _titleFromSummary(payload); final RelationshipMoment moment = RelationshipMoment( id: 'm-${_uuid.v4()}', personId: personId, title: title, summary: payload, at: DateTime.now(), type: type, ); final List moments = [ moment, ...current.moments, ]; await _setState(current.copyWith(moments: moments)); await _enqueueChange( _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.upsert, payload: _momentPayload(moment), ), ); } Future updateMoment(RelationshipMoment moment) async { if (moment.summary.trim().isEmpty) { throw ArgumentError('Capture summary cannot be empty'); } final LocalDataState current = _requireState(); final List updated = current.moments .map((RelationshipMoment item) => item.id == moment.id ? moment : item) .toList(growable: false); await _setState(current.copyWith(moments: updated)); await _enqueueChange( _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.upsert, payload: _momentPayload(moment), ), ); } Future deleteMoment(String momentId) async { final LocalDataState current = _requireState(); final List moments = current.moments .where((RelationshipMoment moment) => moment.id != momentId) .toList(growable: false); await _setState(current.copyWith(moments: moments)); await _enqueueChange( _buildEnvelope( entityType: 'capture', entityId: momentId, op: ChangeOperation.delete, ), ); } Future addIdea({ required IdeaType type, required String title, required String details, String? personId, }) async { final String normalizedTitle = title.trim(); if (normalizedTitle.isEmpty) { throw ArgumentError('Idea title is required'); } final LocalDataState current = _requireState(); final RelationshipIdea idea = RelationshipIdea( id: 'i-${_uuid.v4()}', personId: personId, type: type, title: normalizedTitle, details: details.trim(), createdAt: DateTime.now(), ); final List ideas = [ idea, ...current.ideas, ]; await _setState(current.copyWith(ideas: ideas)); await _enqueueChange( _buildEnvelope( entityType: _ideaEntityType(idea.type), entityId: idea.id, op: ChangeOperation.upsert, payload: _ideaPayload(idea), ), ); } Future updateIdea(RelationshipIdea idea) async { if (idea.title.trim().isEmpty) { throw ArgumentError('Idea title is required'); } final LocalDataState current = _requireState(); final List ideas = current.ideas .map((RelationshipIdea item) => item.id == idea.id ? idea : item) .toList(growable: false); await _setState(current.copyWith(ideas: ideas)); await _enqueueChange( _buildEnvelope( entityType: _ideaEntityType(idea.type), entityId: idea.id, op: ChangeOperation.upsert, payload: _ideaPayload(idea), ), ); } Future toggleIdeaArchived(String ideaId) async { final LocalDataState current = _requireState(); RelationshipIdea? updatedIdea; final List ideas = current.ideas .map((RelationshipIdea item) { if (item.id != ideaId) { return item; } final RelationshipIdea toggled = item.copyWith( isArchived: !item.isArchived, ); updatedIdea = toggled; return toggled; }) .toList(growable: false); await _setState(current.copyWith(ideas: ideas)); if (updatedIdea != null) { await _enqueueChange( _buildEnvelope( entityType: _ideaEntityType(updatedIdea!.type), entityId: updatedIdea!.id, op: ChangeOperation.upsert, payload: _ideaPayload(updatedIdea!), ), ); } } Future deleteIdea(String ideaId) async { final LocalDataState current = _requireState(); final List ideas = current.ideas .where((RelationshipIdea idea) => idea.id != ideaId) .toList(growable: false); await _setState(current.copyWith(ideas: ideas)); await _enqueueChange( _buildEnvelope( entityType: _ideaEntityTypeFromId(current.ideas, ideaId), entityId: ideaId, op: ChangeOperation.delete, ), ); } Future addReminder({ required String title, required ReminderCadence cadence, required DateTime nextAt, String? personId, }) async { final String normalizedTitle = title.trim(); if (normalizedTitle.isEmpty) { throw ArgumentError('Reminder title is required'); } final LocalDataState current = _requireState(); final ReminderRule reminder = ReminderRule( id: 'r-${_uuid.v4()}', personId: personId, title: normalizedTitle, cadence: cadence, nextAt: nextAt, enabled: true, ); final List reminders = [ reminder, ...current.reminders, ]; await _setState(current.copyWith(reminders: reminders)); await _enqueueChange( _buildEnvelope( entityType: 'reminderRule', entityId: reminder.id, op: ChangeOperation.upsert, payload: _reminderPayload(reminder), ), ); } Future updateReminder(ReminderRule reminder) async { if (reminder.title.trim().isEmpty) { throw ArgumentError('Reminder title is required'); } final LocalDataState current = _requireState(); final List reminders = current.reminders .map((ReminderRule item) => item.id == reminder.id ? reminder : item) .toList(growable: false); await _setState(current.copyWith(reminders: reminders)); await _enqueueChange( _buildEnvelope( entityType: 'reminderRule', entityId: reminder.id, op: ChangeOperation.upsert, payload: _reminderPayload(reminder), ), ); } Future toggleReminderEnabled(String reminderId) async { final LocalDataState current = _requireState(); ReminderRule? updatedReminder; final List reminders = current.reminders .map((ReminderRule item) { if (item.id != reminderId) { return item; } final ReminderRule toggled = item.copyWith(enabled: !item.enabled); updatedReminder = toggled; return toggled; }) .toList(growable: false); await _setState(current.copyWith(reminders: reminders)); if (updatedReminder != null) { await _enqueueChange( _buildEnvelope( entityType: 'reminderRule', entityId: updatedReminder!.id, op: ChangeOperation.upsert, payload: _reminderPayload(updatedReminder!), ), ); } } Future deleteReminder(String reminderId) async { final LocalDataState current = _requireState(); final List reminders = current.reminders .where((ReminderRule reminder) => reminder.id != reminderId) .toList(growable: false); await _setState(current.copyWith(reminders: reminders)); await _enqueueChange( _buildEnvelope( entityType: 'reminderRule', entityId: reminderId, op: ChangeOperation.delete, ), ); } Future toggleTaskDone(String taskId) async { final LocalDataState current = _requireState(); final List tasks = current.tasks .map( (DashboardTask task) => task.id == taskId ? task.copyWith(done: !task.done) : task, ) .toList(growable: false); await _setState(current.copyWith(tasks: tasks)); } Future applyRemoteChanges(List changes) async { if (changes.isEmpty) { return; } LocalDataState next = _requireState(); for (final ChangeEnvelope change in changes) { next = _applyRemoteChange(next, change); } await _setState(next); } LocalDataState _applyRemoteChange( LocalDataState current, ChangeEnvelope change, ) { switch (change.entityType) { case 'person': return _applyPersonChange(current, change); case 'capture': return _applyMomentChange(current, change); case 'giftIdea': case 'eventIdea': return _applyIdeaChange(current, change); case 'reminderRule': return _applyReminderChange(current, change); default: return current; } } LocalDataState _applyPersonChange( LocalDataState current, ChangeEnvelope change, ) { if (change.op == ChangeOperation.delete) { final String personId = change.entityId; return current.copyWith( people: current.people .where((PersonProfile person) => person.id != personId) .toList(growable: false), moments: current.moments .where((RelationshipMoment moment) => moment.personId != personId) .toList(growable: false), ideas: current.ideas .where((RelationshipIdea idea) => idea.personId != personId) .toList(growable: false), reminders: current.reminders .where((ReminderRule reminder) => reminder.personId != personId) .toList(growable: false), ); } final int existingIndex = current.people.indexWhere( (PersonProfile person) => person.id == change.entityId, ); final PersonProfile? existing = existingIndex >= 0 ? current.people[existingIndex] : null; final Map payload = change.payload ?? {}; final PersonProfile next = PersonProfile( id: change.entityId, name: _stringField(payload, 'name', existing?.name ?? 'Unknown'), relationship: _stringField( payload, 'relationship', existing?.relationship ?? 'Relationship', ), affinityScore: _intField( payload, 'affinityScore', existing?.affinityScore ?? 70, ), nextMoment: _dateField( payload, 'nextMoment', existing?.nextMoment ?? DateTime.now().add(const Duration(days: 3)), ), tags: _stringListField(payload, 'tags', existing?.tags ?? []), notes: _stringField(payload, 'notes', existing?.notes ?? ''), ); final List people = _upsertItem( items: current.people, item: next, idOf: (PersonProfile person) => person.id, ); return current.copyWith(people: people); } LocalDataState _applyMomentChange( LocalDataState current, ChangeEnvelope change, ) { if (change.op == ChangeOperation.delete) { return current.copyWith( moments: current.moments .where((RelationshipMoment moment) => moment.id != change.entityId) .toList(growable: false), ); } final int existingIndex = current.moments.indexWhere( (RelationshipMoment moment) => moment.id == change.entityId, ); final RelationshipMoment? existing = existingIndex >= 0 ? current.moments[existingIndex] : null; final Map payload = change.payload ?? {}; final String summary = _stringField( payload, 'summary', existing?.summary ?? '', ); final RelationshipMoment next = RelationshipMoment( id: change.entityId, personId: _stringField( payload, 'personId', existing?.personId ?? (current.people.isEmpty ? 'person-unknown' : current.people.first.id), ), title: _stringField( payload, 'title', existing?.title ?? _titleFromSummary(summary), ), summary: summary, at: _dateField(payload, 'at', existing?.at ?? DateTime.now()), type: _stringField(payload, 'type', existing?.type ?? 'capture'), ); final List moments = _upsertItem( items: current.moments, item: next, idOf: (RelationshipMoment moment) => moment.id, ); return current.copyWith(moments: moments); } LocalDataState _applyIdeaChange( LocalDataState current, ChangeEnvelope change, ) { if (change.op == ChangeOperation.delete) { return current.copyWith( ideas: current.ideas .where((RelationshipIdea idea) => idea.id != change.entityId) .toList(growable: false), ); } final int existingIndex = current.ideas.indexWhere( (RelationshipIdea idea) => idea.id == change.entityId, ); final RelationshipIdea? existing = existingIndex >= 0 ? current.ideas[existingIndex] : null; final Map payload = change.payload ?? {}; final IdeaType fallbackType = _ideaTypeFromEntityType( change.entityType, fallback: existing?.type ?? IdeaType.gift, ); final IdeaType type = _ideaTypeFromRaw( payload['type'], fallback: fallbackType, ); final RelationshipIdea next = RelationshipIdea( id: change.entityId, personId: payload['personId'] as String? ?? existing?.personId, type: type, title: _stringField(payload, 'title', existing?.title ?? 'Untitled idea'), details: _stringField(payload, 'details', existing?.details ?? ''), createdAt: _dateField( payload, 'createdAt', existing?.createdAt ?? DateTime.now(), ), isArchived: _boolField( payload, 'isArchived', existing?.isArchived ?? false, ), ); final List ideas = _upsertItem( items: current.ideas, item: next, idOf: (RelationshipIdea idea) => idea.id, ); return current.copyWith(ideas: ideas); } LocalDataState _applyReminderChange( LocalDataState current, ChangeEnvelope change, ) { if (change.op == ChangeOperation.delete) { return current.copyWith( reminders: current.reminders .where((ReminderRule reminder) => reminder.id != change.entityId) .toList(growable: false), ); } final int existingIndex = current.reminders.indexWhere( (ReminderRule reminder) => reminder.id == change.entityId, ); final ReminderRule? existing = existingIndex >= 0 ? current.reminders[existingIndex] : null; final Map payload = change.payload ?? {}; final ReminderRule next = ReminderRule( id: change.entityId, personId: payload['personId'] as String? ?? existing?.personId, title: _stringField(payload, 'title', existing?.title ?? 'Reminder'), cadence: _cadenceFromRaw( payload['cadence'], fallback: existing?.cadence ?? ReminderCadence.weekly, ), nextAt: _dateField(payload, 'nextAt', existing?.nextAt ?? DateTime.now()), enabled: _boolField(payload, 'enabled', existing?.enabled ?? true), ); final List reminders = _upsertItem( items: current.reminders, item: next, idOf: (ReminderRule reminder) => reminder.id, ); return current.copyWith(reminders: reminders); } Future _enqueueChange(ChangeEnvelope change) async { await ref.read(syncQueueRepositoryProvider.notifier).enqueue(change); } Future _enqueueChanges(List changes) async { await ref.read(syncQueueRepositoryProvider.notifier).enqueueAll(changes); } ChangeEnvelope _buildEnvelope({ required String entityType, required String entityId, required ChangeOperation op, Map? payload, }) { return ChangeEnvelope( schemaVersion: _syncSchemaVersion, entityType: entityType, entityId: entityId, op: op, modifiedAt: DateTime.now().toUtc(), clientMutationId: _uuid.v4(), payload: payload, ); } Map _personPayload(PersonProfile person) { return { 'name': person.name, 'relationship': person.relationship, 'affinityScore': person.affinityScore, 'nextMoment': person.nextMoment.toUtc().toIso8601String(), 'tags': person.tags, 'notes': person.notes, }; } Map _momentPayload(RelationshipMoment moment) { return { 'personId': moment.personId, 'title': moment.title, 'summary': moment.summary, 'at': moment.at.toUtc().toIso8601String(), 'type': moment.type, }; } Map _ideaPayload(RelationshipIdea idea) { return { 'personId': idea.personId, 'type': idea.type.name, 'title': idea.title, 'details': idea.details, 'createdAt': idea.createdAt.toUtc().toIso8601String(), 'isArchived': idea.isArchived, }; } Map _reminderPayload(ReminderRule reminder) { return { 'personId': reminder.personId, 'title': reminder.title, 'cadence': reminder.cadence.name, 'nextAt': reminder.nextAt.toUtc().toIso8601String(), 'enabled': reminder.enabled, }; } String _ideaEntityType(IdeaType type) { return type == IdeaType.event ? 'eventIdea' : 'giftIdea'; } String _ideaEntityTypeFromId(List ideas, String ideaId) { final int index = ideas.indexWhere( (RelationshipIdea idea) => idea.id == ideaId, ); final RelationshipIdea? existing = index >= 0 ? ideas[index] : null; if (existing == null) { return 'giftIdea'; } return _ideaEntityType(existing.type); } IdeaType _ideaTypeFromEntityType( String entityType, { required IdeaType fallback, }) { switch (entityType) { case 'eventIdea': return IdeaType.event; case 'giftIdea': return IdeaType.gift; default: return fallback; } } IdeaType _ideaTypeFromRaw(dynamic value, {required IdeaType fallback}) { if (value is! String || value.isEmpty) { return fallback; } return IdeaType.values.firstWhere( (IdeaType type) => type.name == value, orElse: () => fallback, ); } ReminderCadence _cadenceFromRaw( dynamic value, { required ReminderCadence fallback, }) { if (value is! String || value.isEmpty) { return fallback; } return ReminderCadence.values.firstWhere( (ReminderCadence cadence) => cadence.name == value, orElse: () => fallback, ); } List _upsertItem({ required List items, required T item, required String Function(T value) idOf, }) { final int index = items.indexWhere((T value) => idOf(value) == idOf(item)); if (index < 0) { return [item, ...items]; } final List copy = items.toList(); copy[index] = item; return copy; } String _stringField( Map payload, String key, String fallback, ) { final dynamic value = payload[key]; if (value is String && value.trim().isNotEmpty) { return value.trim(); } return fallback; } int _intField(Map payload, String key, int fallback) { final dynamic value = payload[key]; if (value is int) { return value; } if (value is num) { return value.toInt(); } return fallback; } bool _boolField(Map payload, String key, bool fallback) { final dynamic value = payload[key]; if (value is bool) { return value; } return fallback; } List _stringListField( Map payload, String key, List fallback, ) { final dynamic value = payload[key]; if (value is! List) { return fallback; } return value.map((dynamic item) => '$item').toList(growable: false); } DateTime _dateField( Map payload, String key, DateTime fallback, ) { final dynamic value = payload[key]; if (value is String) { final DateTime? parsed = DateTime.tryParse(value); if (parsed != null) { return parsed.toLocal(); } } return fallback; } Future _setState(LocalDataState next) async { state = AsyncData(next); await _persist(next); } LocalDataState _requireState() { final LocalDataState? value = state.asData?.value; if (value == null) { throw StateError('Local data state is not ready yet'); } return value; } Future _persist(LocalDataState state) async { final SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.setString(_storageKey, jsonEncode(state.toJson())); await prefs.setInt(_schemaVersionKey, _schemaVersion); } Future _migrateIfNeeded(SharedPreferences prefs) async { final int currentVersion = prefs.getInt(_schemaVersionKey) ?? 0; if (currentVersion == _schemaVersion) { return; } if (currentVersion <= 0) { await prefs.remove(_storageKey); await prefs.setInt(_schemaVersionKey, _schemaVersion); return; } // Migration placeholder for future schema versions. await prefs.setInt(_schemaVersionKey, _schemaVersion); } String _titleFromSummary(String summary) { final List words = summary.split(RegExp(r'\s+')); final int take = words.length < 5 ? words.length : 5; return words.take(take).join(' '); } } final AsyncNotifierProvider localRepositoryProvider = AsyncNotifierProvider(LocalRepository.new);