import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/storage/local_data_store.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart'; import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; import 'package:uuid/uuid.dart'; /// Input payload for shared-message ingest flow (e.g. WhatsApp share intent). class SharedMessageIngestInput { const SharedMessageIngestInput({ required this.sourceApp, required this.messageText, this.sourceDisplayName, this.sourceUserId, this.sourceThreadId, this.sharedAt, }); final String sourceApp; final String messageText; final String? sourceDisplayName; final String? sourceUserId; final String? sourceThreadId; final DateTime? sharedAt; } /// Result of shared-message ingest with profile resolution metadata. enum SharedMessageIngestStatus { imported, queuedForResolution } class SharedMessageIngestResult { const SharedMessageIngestResult({ required this.status, required this.createdProfile, required this.createdSourceLink, this.profileId, this.profileName, this.momentId, this.inboxEntryId, }); const SharedMessageIngestResult.imported({ required this.profileId, required this.profileName, required this.createdProfile, required this.createdSourceLink, required this.momentId, }) : status = SharedMessageIngestStatus.imported, inboxEntryId = null; const SharedMessageIngestResult.queuedForResolution({ required this.inboxEntryId, }) : status = SharedMessageIngestStatus.queuedForResolution, createdProfile = false, createdSourceLink = false, profileId = null, profileName = null, momentId = null; final SharedMessageIngestStatus status; final String? profileId; final String? profileName; final bool createdProfile; final bool createdSourceLink; final String? momentId; final String? inboxEntryId; bool get isQueuedForResolution => status == SharedMessageIngestStatus.queuedForResolution; } /// Persisted local data source for offline-first product state. class LocalRepository extends AsyncNotifier { static const int _schemaVersion = 3; static const int _syncSchemaVersion = 1; static const Uuid _uuid = Uuid(); @override Future 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 json = jsonDecode(migrated.rawState) as Map; 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 addPerson({ required String name, required String relationship, required String notes, required List tags, DateTime? nextMoment, String? location, }) 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(), location: location?.trim().isEmpty == true ? null : location?.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 ingestSharedMessage( SharedMessageIngestInput input, ) async { final String trimmedMessage = input.messageText.trim(); if (trimmedMessage.isEmpty) { throw ArgumentError('Shared message text cannot be empty'); } final LocalDataState current = _requireState(); final DateTime now = DateTime.now(); final DateTime sharedAt = input.sharedAt ?? now; final String sourceApp = input.sourceApp.trim().toLowerCase(); final String? sourceDisplayName = _trimToNull(input.sourceDisplayName); final String normalizedDisplayName = _normalizeDisplayName( sourceDisplayName, ); final String? sourceUserId = _trimToNull(input.sourceUserId); final String? sourceThreadId = _trimToNull(input.sourceThreadId); final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: sourceApp, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, normalizedDisplayName: normalizedDisplayName, ); PersonProfile? resolvedPerson; if (matchedLink != null) { resolvedPerson = _findPersonById(current.people, matchedLink.profileId); } final List matchingPeople = normalizedDisplayName.isEmpty ? const [] : _findPeopleByNormalizedName(current.people, normalizedDisplayName); if (resolvedPerson == null && matchingPeople.length == 1) { resolvedPerson = matchingPeople.first; } if (resolvedPerson == null && matchingPeople.length > 1) { return _queueSharedInbox( current: current, sourceApp: sourceApp, messageText: trimmedMessage, sharedAt: sharedAt, receivedAt: now, sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.ambiguousProfileMatch, candidateProfileIds: matchingPeople .map((PersonProfile person) => person.id) .toList(growable: false), ); } bool createdProfile = false; final List nextPeople = current.people.toList( growable: true, ); if (resolvedPerson == null) { final String? inferredName = _deriveAutoProfileName( sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, ); if (inferredName == null) { return _queueSharedInbox( current: current, sourceApp: sourceApp, messageText: trimmedMessage, sharedAt: sharedAt, receivedAt: now, sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.missingIdentity, candidateProfileIds: const [], ); } createdProfile = true; resolvedPerson = PersonProfile( id: 'p-${_uuid.v4()}', name: inferredName, relationship: 'WhatsApp Contact', affinityScore: 70, nextMoment: DateTime.now().add(const Duration(days: 2)), tags: const ['whatsapp'], notes: 'Auto-created from shared message.', ); nextPeople.insert(0, resolvedPerson); } return _ingestIntoResolvedProfile( current: current, sourceApp: sourceApp, messageText: trimmedMessage, sharedAt: sharedAt, importedAt: now, sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, normalizedDisplayName: normalizedDisplayName, resolvedPerson: resolvedPerson, matchedLink: matchedLink, createdProfile: createdProfile, people: nextPeople, resolvedAutomatically: true, ); } Future resolveSharedInboxToExistingProfile({ required String inboxEntryId, required String profileId, }) async { final LocalDataState current = _requireState(); final SharedInboxEntry entry = _requireSharedInboxEntry( current, inboxEntryId, ); final PersonProfile? resolvedPerson = _findPersonById( current.people, profileId, ); if (resolvedPerson == null) { throw ArgumentError('Profile not found for inbox entry'); } final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: entry.sourceApp, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, normalizedDisplayName: entry.normalizedDisplayName, ); return _ingestIntoResolvedProfile( current: current, sourceApp: entry.sourceApp, messageText: entry.messageText, sharedAt: entry.sharedAt, importedAt: DateTime.now(), sourceDisplayName: entry.sourceDisplayName, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, normalizedDisplayName: entry.normalizedDisplayName, resolvedPerson: resolvedPerson, matchedLink: matchedLink, createdProfile: false, people: current.people, resolvedAutomatically: false, consumedInboxEntryId: entry.id, ); } Future resolveSharedInboxByCreatingProfile({ required String inboxEntryId, String? name, String relationship = 'WhatsApp Contact', String? location, }) async { final LocalDataState current = _requireState(); final SharedInboxEntry entry = _requireSharedInboxEntry( current, inboxEntryId, ); final String profileName = _trimToNull(name) ?? _deriveAutoProfileName( sourceDisplayName: entry.sourceDisplayName, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, ) ?? 'WhatsApp Contact'; final String normalizedRelationship = relationship.trim().isEmpty ? 'WhatsApp Contact' : relationship.trim(); final PersonProfile createdPerson = PersonProfile( id: 'p-${_uuid.v4()}', name: profileName, relationship: normalizedRelationship, affinityScore: 70, nextMoment: DateTime.now().add(const Duration(days: 2)), tags: const ['whatsapp'], notes: 'Created from Share Inbox resolution.', location: _trimToNull(location), ); final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: entry.sourceApp, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, normalizedDisplayName: entry.normalizedDisplayName, ); final List nextPeople = [ createdPerson, ...current.people, ]; return _ingestIntoResolvedProfile( current: current, sourceApp: entry.sourceApp, messageText: entry.messageText, sharedAt: entry.sharedAt, importedAt: DateTime.now(), sourceDisplayName: entry.sourceDisplayName, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, normalizedDisplayName: entry.normalizedDisplayName, resolvedPerson: createdPerson, matchedLink: matchedLink, createdProfile: true, people: nextPeople, resolvedAutomatically: false, consumedInboxEntryId: entry.id, ); } Future dismissSharedInboxEntry(String inboxEntryId) async { final LocalDataState current = _requireState(); final List nextInbox = current.sharedInbox .where((SharedInboxEntry entry) => entry.id != inboxEntryId) .toList(growable: false); if (nextInbox.length == current.sharedInbox.length) { return; } await _setState(current.copyWith(sharedInbox: nextInbox)); } 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, ), ), ]); } /// Merge one person profile into another and rebind related records. Future mergePersonProfiles({ required String sourcePersonId, required String targetPersonId, }) async { if (sourcePersonId == targetPersonId) { throw ArgumentError('Source and target profile must be different'); } final LocalDataState current = _requireState(); final PersonProfile? source = _findPersonById( current.people, sourcePersonId, ); final PersonProfile? target = _findPersonById( current.people, targetPersonId, ); if (source == null || target == null) { throw ArgumentError('Both source and target profiles must exist'); } final PersonProfile mergedTarget = target.copyWith( affinityScore: target.affinityScore > source.affinityScore ? target.affinityScore : source.affinityScore, nextMoment: source.nextMoment.isBefore(target.nextMoment) ? source.nextMoment : target.nextMoment, tags: _mergeTags(target.tags, source.tags), notes: _mergeNotes( targetNotes: target.notes, sourceName: source.name, sourceNotes: source.notes, ), location: _effectiveLocation(target.location, source.location), ); final List nextPeople = current.people .where((PersonProfile person) => person.id != sourcePersonId) .map( (PersonProfile person) => person.id == targetPersonId ? mergedTarget : person, ) .toList(growable: false); final List updatedMoments = []; final List nextMoments = current.moments .map((RelationshipMoment moment) { if (moment.personId != sourcePersonId) { return moment; } final RelationshipMoment merged = moment.copyWith( personId: targetPersonId, ); updatedMoments.add(merged); return merged; }) .toList(growable: false); final List updatedIdeas = []; final List nextIdeas = current.ideas .map((RelationshipIdea idea) { if (idea.personId != sourcePersonId) { return idea; } final RelationshipIdea merged = idea.copyWith( personId: targetPersonId, ); updatedIdeas.add(merged); return merged; }) .toList(growable: false); final List updatedReminders = []; final List nextReminders = current.reminders .map((ReminderRule reminder) { if (reminder.personId != sourcePersonId) { return reminder; } final ReminderRule merged = reminder.copyWith( personId: targetPersonId, ); updatedReminders.add(merged); return merged; }) .toList(growable: false); final List nextLinks = current.sourceLinks .map((SourceProfileLink link) { if (link.profileId != sourcePersonId) { return link; } return link.copyWith(profileId: targetPersonId); }) .toList(growable: false); final List nextMessages = current.sharedMessages .map((SharedMessageEntry entry) { if (entry.profileId != sourcePersonId) { return entry; } return entry.copyWith(profileId: targetPersonId); }) .toList(growable: false); final List nextInbox = current.sharedInbox .map((SharedInboxEntry entry) { if (!entry.candidateProfileIds.contains(sourcePersonId)) { return entry; } final List candidates = entry.candidateProfileIds .map( (String profileId) => profileId == sourcePersonId ? targetPersonId : profileId, ) .toSet() .toList(growable: false); return entry.copyWith(candidateProfileIds: candidates); }) .toList(growable: false); await _setState( current.copyWith( people: nextPeople, moments: nextMoments, ideas: nextIdeas, reminders: nextReminders, sourceLinks: nextLinks, sharedMessages: nextMessages, sharedInbox: nextInbox, ), ); await _enqueueChanges([ _buildEnvelope( entityType: 'person', entityId: mergedTarget.id, op: ChangeOperation.upsert, payload: _personPayload(mergedTarget), ), _buildEnvelope( entityType: 'person', entityId: sourcePersonId, op: ChangeOperation.delete, ), ...updatedMoments.map( (RelationshipMoment moment) => _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.upsert, payload: _momentPayload(moment), ), ), ...updatedIdeas.map( (RelationshipIdea idea) => _buildEnvelope( entityType: _ideaEntityType(idea.type), entityId: idea.id, op: ChangeOperation.upsert, payload: _ideaPayload(idea), ), ), ...updatedReminders.map( (ReminderRule reminder) => _buildEnvelope( entityType: 'reminderRule', entityId: reminder.id, op: ChangeOperation.upsert, payload: _reminderPayload(reminder), ), ), ]); } 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 ?? ''), location: _stringOrNullField(payload, 'location', existing?.location), ); 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, 'location': person.location, }; } 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; } String? _stringOrNullField( Map payload, String key, String? fallback, ) { final dynamic value = payload[key]; if (value is String) { final String trimmed = value.trim(); if (trimmed.isNotEmpty) { return trimmed; } return null; } 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); 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 _persist(LocalDataState state) async { await ref .read(localDataStoreProvider) .write( rawState: jsonEncode(state.toJson()), schemaVersion: _schemaVersion, ); } Future _reconcileReminderSchedule(LocalDataState state) async { try { await ref.read(reminderSchedulerProvider).reconcile(state.reminders); } catch (_) { // Scheduling failures must not block local-first data updates. } } Future _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, ); } // Migration placeholder for future schema versions. await store.write(rawState: record.rawState, schemaVersion: _schemaVersion); return LocalDataRecord( rawState: record.rawState, schemaVersion: _schemaVersion, ); } List _mergeTags(List primary, List secondary) { final List merged = []; final Set seen = {}; for (final String raw in [...primary, ...secondary]) { final String tag = raw.trim(); if (tag.isEmpty) { continue; } final String normalized = tag.toLowerCase(); if (seen.add(normalized)) { merged.add(tag); } } return merged; } String _mergeNotes({ required String targetNotes, required String sourceName, required String sourceNotes, }) { final String target = targetNotes.trim(); final String source = sourceNotes.trim(); if (target.isEmpty) { return source; } if (source.isEmpty) { return target; } if (target == source) { return target; } return '$target\n\nMerged from $sourceName:\n$source'; } String? _effectiveLocation(String? target, String? source) { final String? targetValue = _trimToNull(target); if (targetValue != null) { return targetValue; } return _trimToNull(source); } Future _queueSharedInbox({ required LocalDataState current, required String sourceApp, required String messageText, required DateTime sharedAt, required DateTime receivedAt, required SharedInboxReason reason, required List candidateProfileIds, required String normalizedDisplayName, String? sourceDisplayName, String? sourceUserId, String? sourceThreadId, }) async { final String summary = messageText.length > 1800 ? messageText.substring(0, 1800) : messageText; final SharedInboxEntry entry = SharedInboxEntry( id: 'si-${_uuid.v4()}', sourceApp: sourceApp, messageText: summary, sharedAt: sharedAt, receivedAt: receivedAt, reason: reason, candidateProfileIds: candidateProfileIds, normalizedDisplayName: normalizedDisplayName, sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, ); final List nextInbox = [ entry, ...current.sharedInbox, ]; await _setState(current.copyWith(sharedInbox: nextInbox)); return SharedMessageIngestResult.queuedForResolution( inboxEntryId: entry.id, ); } Future _ingestIntoResolvedProfile({ required LocalDataState current, required String sourceApp, required String messageText, required DateTime sharedAt, required DateTime importedAt, required String normalizedDisplayName, required PersonProfile resolvedPerson, required bool createdProfile, required List people, required bool resolvedAutomatically, SourceProfileLink? matchedLink, String? sourceDisplayName, String? sourceUserId, String? sourceThreadId, String? consumedInboxEntryId, }) async { final bool createdSourceLink = matchedLink == null; final List nextLinks = current.sourceLinks.toList( growable: true, ); if (matchedLink == null) { nextLinks.insert( 0, SourceProfileLink( id: 'sl-${_uuid.v4()}', sourceApp: sourceApp, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, normalizedDisplayName: normalizedDisplayName, profileId: resolvedPerson.id, firstSeenAt: sharedAt, lastSeenAt: sharedAt, ), ); } else { final int index = nextLinks.indexWhere( (SourceProfileLink link) => link.id == matchedLink.id, ); if (index >= 0) { nextLinks[index] = matchedLink.copyWith( sourceUserId: sourceUserId ?? matchedLink.sourceUserId, sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId, normalizedDisplayName: normalizedDisplayName.isEmpty ? matchedLink.normalizedDisplayName : normalizedDisplayName, profileId: resolvedPerson.id, lastSeenAt: sharedAt, ); } } final String summary = messageText.length > 1800 ? messageText.substring(0, 1800) : messageText; final RelationshipMoment moment = RelationshipMoment( id: 'm-${_uuid.v4()}', personId: resolvedPerson.id, title: _titleFromSummary(summary), summary: summary, at: sharedAt, type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared', ); final List nextMoments = [ moment, ...current.moments, ]; final List nextMessages = [ SharedMessageEntry( id: 'sm-${_uuid.v4()}', sourceApp: sourceApp, profileId: resolvedPerson.id, messageText: summary, sharedAt: sharedAt, importedAt: importedAt, resolvedAutomatically: resolvedAutomatically, sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, ), ...current.sharedMessages, ]; final List nextInbox = consumedInboxEntryId == null ? current.sharedInbox : current.sharedInbox .where( (SharedInboxEntry entry) => entry.id != consumedInboxEntryId, ) .toList(growable: false); await _setState( current.copyWith( people: people, moments: nextMoments, sourceLinks: nextLinks, sharedMessages: nextMessages, sharedInbox: nextInbox, ), ); final List outbound = [ if (createdProfile) _buildEnvelope( entityType: 'person', entityId: resolvedPerson.id, op: ChangeOperation.upsert, payload: _personPayload(resolvedPerson), ), _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.upsert, payload: _momentPayload(moment), ), ]; await _enqueueChanges(outbound); return SharedMessageIngestResult.imported( profileId: resolvedPerson.id, profileName: resolvedPerson.name, createdProfile: createdProfile, createdSourceLink: createdSourceLink, momentId: moment.id, ); } 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(' '); } SourceProfileLink? _findExistingSourceLink({ required List links, required String sourceApp, required String? sourceUserId, required String? sourceThreadId, required String normalizedDisplayName, }) { if (sourceUserId != null && sourceUserId.isNotEmpty) { for (final SourceProfileLink link in links) { if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) { return link; } } } if (sourceThreadId != null && sourceThreadId.isNotEmpty) { for (final SourceProfileLink link in links) { if (link.sourceApp == sourceApp && link.sourceThreadId == sourceThreadId) { return link; } } } if (normalizedDisplayName.isNotEmpty) { for (final SourceProfileLink link in links) { if (link.sourceApp == sourceApp && link.normalizedDisplayName == normalizedDisplayName) { return link; } } } return null; } PersonProfile? _findPersonById(List people, String id) { for (final PersonProfile person in people) { if (person.id == id) { return person; } } return null; } List _findPeopleByNormalizedName( List people, String normalizedDisplayName, ) { return people .where( (PersonProfile person) => _normalizeDisplayName(person.name) == normalizedDisplayName, ) .toList(growable: false); } SharedInboxEntry _requireSharedInboxEntry( LocalDataState current, String inboxEntryId, ) { for (final SharedInboxEntry entry in current.sharedInbox) { if (entry.id == inboxEntryId) { return entry; } } throw ArgumentError('Shared inbox entry not found'); } String? _deriveAutoProfileName({ required String? sourceDisplayName, required String? sourceUserId, required String? sourceThreadId, }) { return _trimToNull(sourceDisplayName) ?? _trimToNull(sourceUserId) ?? _trimToNull(sourceThreadId); } String _normalizeDisplayName(String? raw) { if (raw == null) { return ''; } final String compact = raw .toLowerCase() .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') .trim(); return compact.replaceAll(RegExp(r'\s+'), ' '); } String? _trimToNull(String? value) { if (value == null) { return null; } final String trimmed = value.trim(); return trimmed.isEmpty ? null : trimmed; } } final AsyncNotifierProvider localRepositoryProvider = AsyncNotifierProvider(LocalRepository.new);