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/chat_preference_extractor.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(); static const ChatPreferenceExtractor _chatPreferenceExtractor = ChatPreferenceExtractor(); @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 String? sourceFingerprint = _buildStableSourceFingerprint( sourceApp: sourceApp, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, ); final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: sourceApp, sourceFingerprint: sourceFingerprint, 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, sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.ambiguousProfileMatch, candidateProfileIds: matchingPeople .map((PersonProfile person) => person.id) .toList(growable: false), ); } final List nearMatchPeople = resolvedPerson == null ? _findNearMatchPeople( current.people, normalizedDisplayName: normalizedDisplayName, ) : const []; if (resolvedPerson == null && nearMatchPeople.isNotEmpty) { return _queueSharedInbox( current: current, sourceApp: sourceApp, messageText: trimmedMessage, sharedAt: sharedAt, receivedAt: now, sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.nearProfileConflict, candidateProfileIds: nearMatchPeople .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, sourceFingerprint: sourceFingerprint, 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, sourceFingerprint: sourceFingerprint, 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, sourceFingerprint: entry.sourceFingerprint, 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, sourceFingerprint: entry.sourceFingerprint, 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, sourceFingerprint: entry.sourceFingerprint, 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, sourceFingerprint: entry.sourceFingerprint, 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); final List preferenceSignals = current .preferenceSignals .where((PersonPreferenceSignal signal) => signal.personId != personId) .toList(growable: false); await _setState( current.copyWith( people: people, moments: moments, ideas: ideas, reminders: reminders, preferenceSignals: preferenceSignals, ), ); 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); final List nextPreferenceSignals = current .preferenceSignals .map((PersonPreferenceSignal signal) { if (signal.personId != sourcePersonId) { return signal; } return signal.copyWith(personId: targetPersonId); }) .toList(growable: false); await _setState( current.copyWith( people: nextPeople, moments: nextMoments, ideas: nextIdeas, reminders: nextReminders, sourceLinks: nextLinks, sharedMessages: nextMessages, sharedInbox: nextInbox, preferenceSignals: nextPreferenceSignals, ), ); 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, ), ); } /// Upserts a profile preference signal observation inferred from chat context. /// /// This is intentionally local-only for now. We keep inferred signals separate /// from manual tags until the user confirms them. Future upsertInferredPreferenceSignalObservation({ required String personId, required String key, required String label, required String category, required PreferenceSignalPolarity polarity, required double confidence, String? sourceApp, String? evidenceMessageId, String? evidenceSnippet, DateTime? observedAt, }) async { final LocalDataState current = _requireState(); final DateTime observed = observedAt ?? DateTime.now(); final String normalizedKey = _normalizePreferenceKey(key); if (normalizedKey.isEmpty) { throw ArgumentError('Preference signal key cannot be empty'); } if (!_personExists(current.people, personId)) { throw ArgumentError('Profile does not exist for preference signal'); } final int existingIndex = current.preferenceSignals.indexWhere( (PersonPreferenceSignal signal) => signal.personId == personId && signal.key == normalizedKey && signal.polarity == polarity, ); final double normalizedConfidence = _clampUnit(confidence); final String normalizedLabel = label.trim().isEmpty ? normalizedKey : label.trim(); final String normalizedCategory = category.trim().isEmpty ? 'general' : category.trim().toLowerCase(); final String? snippet = _trimToNull(evidenceSnippet); final String? messageId = _trimToNull(evidenceMessageId); final String? source = _trimToNull(sourceApp)?.toLowerCase(); late final PersonPreferenceSignal nextSignal; final List nextSignals = current.preferenceSignals .toList(growable: true); if (existingIndex < 0) { nextSignal = PersonPreferenceSignal( id: 'ps-${_uuid.v4()}', personId: personId, key: normalizedKey, label: normalizedLabel, category: normalizedCategory, polarity: polarity, confidence: normalizedConfidence, status: PreferenceSignalStatus.inferred, firstSeenAt: observed, lastSeenAt: observed, occurrenceCount: 1, sourceApps: source == null ? const [] : [source], evidenceMessageIds: messageId == null ? const [] : [messageId], evidenceSnippets: snippet == null ? const [] : [snippet], ); nextSignals.insert(0, nextSignal); } else { final PersonPreferenceSignal existing = nextSignals[existingIndex]; final int currentCount = existing.occurrenceCount < 1 ? 1 : existing.occurrenceCount; final int nextCount = currentCount + 1; final double mergedConfidence = ((existing.confidence * currentCount) + normalizedConfidence) / nextCount; nextSignal = existing.copyWith( label: normalizedLabel, category: normalizedCategory, confidence: _clampUnit(mergedConfidence), lastSeenAt: observed, occurrenceCount: nextCount, sourceApps: _mergeUniqueStrings(existing.sourceApps, source), evidenceMessageIds: _mergeUniqueStrings( existing.evidenceMessageIds, messageId, maxItems: 8, ), evidenceSnippets: _mergeUniqueStrings( existing.evidenceSnippets, snippet == null ? null : _truncate(snippet, 220), maxItems: 6, ), ); nextSignals[existingIndex] = nextSignal; } await _setState(current.copyWith(preferenceSignals: nextSignals)); return nextSignal; } Future upsertPreferenceSignal(PersonPreferenceSignal signal) async { final LocalDataState current = _requireState(); if (!_personExists(current.people, signal.personId)) { throw ArgumentError('Profile does not exist for preference signal'); } final List nextSignals = _upsertItem( items: current.preferenceSignals, item: signal, idOf: (PersonPreferenceSignal value) => value.id, ); await _setState(current.copyWith(preferenceSignals: nextSignals)); } Future setPreferenceSignalStatus({ required String signalId, required PreferenceSignalStatus status, }) async { final LocalDataState current = _requireState(); final List nextSignals = current.preferenceSignals .map((PersonPreferenceSignal signal) { if (signal.id != signalId) { return signal; } return signal.copyWith(status: status); }) .toList(growable: false); await _setState(current.copyWith(preferenceSignals: nextSignals)); } Future confirmPreferenceSignal(String signalId) { return setPreferenceSignalStatus( signalId: signalId, status: PreferenceSignalStatus.confirmed, ); } Future dismissPreferenceSignal(String signalId) { return setPreferenceSignalStatus( signalId: signalId, status: PreferenceSignalStatus.dismissed, ); } 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), preferenceSignals: current.preferenceSignals .where( (PersonPreferenceSignal signal) => signal.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; } bool _personExists(List people, String personId) { return people.any((PersonProfile person) => person.id == personId); } String _normalizePreferenceKey(String value) { return value .trim() .toLowerCase() .replaceAll(RegExp(r'[^a-z0-9:_ -]+'), '') .replaceAll(RegExp(r'\s+'), ' '); } double _clampUnit(double value) { if (value.isNaN) { return 0; } return value.clamp(0.0, 1.0).toDouble(); } List _mergeUniqueStrings( List existing, String? incoming, { int maxItems = 12, }) { final List values = []; final Set seen = {}; for (final String raw in existing) { final String value = raw.trim(); if (value.isEmpty) { continue; } final String key = value.toLowerCase(); if (seen.add(key)) { values.add(value); } } final String? newValue = _trimToNull(incoming); if (newValue != null) { final String key = newValue.toLowerCase(); if (!seen.contains(key)) { values.insert(0, newValue); } } if (values.length <= maxItems) { return values; } return values.take(maxItems).toList(growable: false); } String _truncate(String value, int maxChars) { if (value.length <= maxChars) { return value; } return value.substring(0, maxChars); } 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? sourceFingerprint, 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, sourceFingerprint: sourceFingerprint, 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 String? sourceFingerprint, 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, sourceFingerprint: sourceFingerprint, 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, sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint, 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 SharedMessageEntry sharedEntry = SharedMessageEntry( id: 'sm-${_uuid.v4()}', sourceApp: sourceApp, profileId: resolvedPerson.id, messageText: summary, sharedAt: sharedAt, importedAt: importedAt, resolvedAutomatically: resolvedAutomatically, sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, ); final List nextMessages = [ sharedEntry, ...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); final List extractedSignals = _chatPreferenceExtractor.extract(summary); for (final ExtractedPreferenceSignalCandidate candidate in extractedSignals) { await upsertInferredPreferenceSignalObservation( personId: resolvedPerson.id, key: candidate.key, label: candidate.label, category: candidate.category, polarity: candidate.polarity, confidence: candidate.confidence, sourceApp: sourceApp, evidenceMessageId: sharedEntry.id, evidenceSnippet: candidate.evidenceSnippet, observedAt: sharedAt, ); } 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? sourceFingerprint, required String? sourceUserId, required String? sourceThreadId, required String normalizedDisplayName, }) { if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) { for (final SourceProfileLink link in links) { final String? linkFingerprint = link.sourceFingerprint ?? _buildStableSourceFingerprint( sourceApp: link.sourceApp, sourceUserId: link.sourceUserId, sourceThreadId: link.sourceThreadId, ); if (link.sourceApp == sourceApp && linkFingerprint == sourceFingerprint) { return link; } } } 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); } List _findNearMatchPeople( List people, { required String normalizedDisplayName, }) { if (normalizedDisplayName.isEmpty) { return const []; } final Map distanceById = {}; final List candidates = []; for (final PersonProfile person in people) { final String personName = _normalizeDisplayName(person.name); if (personName.isEmpty || personName == normalizedDisplayName) { continue; } final int distance = _levenshteinDistance( normalizedDisplayName, personName, ); if (_isNearDisplayName( normalizedDisplayName, personName, distance: distance, )) { distanceById[person.id] = distance; candidates.add(person); } } candidates.sort((PersonProfile a, PersonProfile b) { final int left = distanceById[a.id] ?? 999; final int right = distanceById[b.id] ?? 999; if (left != right) { return left.compareTo(right); } return a.name.length.compareTo(b.name.length); }); if (candidates.length <= 5) { return candidates; } return candidates.take(5).toList(growable: false); } bool _isNearDisplayName(String left, String right, {required int distance}) { final int maxLength = left.length > right.length ? left.length : right.length; final int minLength = left.length < right.length ? left.length : right.length; if (minLength >= 4 && (left.contains(right) || right.contains(left))) { return true; } final int threshold = maxLength >= 14 ? 3 : 2; if (distance <= threshold) { return true; } return maxLength >= 8 && (distance / maxLength) <= 0.22; } int _levenshteinDistance(String left, String right) { if (left == right) { return 0; } if (left.isEmpty) { return right.length; } if (right.isEmpty) { return left.length; } List previous = List.generate( right.length + 1, (int index) => index, growable: false, ); for (int i = 1; i <= left.length; i += 1) { final List current = List.filled(right.length + 1, 0); current[0] = i; for (int j = 1; j <= right.length; j += 1) { final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1; final int deletion = previous[j] + 1; final int insertion = current[j - 1] + 1; final int substitution = previous[j - 1] + substitutionCost; final int value = deletion < insertion ? deletion : insertion; current[j] = value < substitution ? value : substitution; } previous = current; } return previous[right.length]; } 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? _buildStableSourceFingerprint({ required String sourceApp, required String? sourceUserId, required String? sourceThreadId, }) { final String? userId = _trimToNull(sourceUserId); final String? threadId = _trimToNull(sourceThreadId); if (userId == null && threadId == null) { return null; } final List parts = [ sourceApp.trim().toLowerCase(), if (userId != null) 'u:${userId.toLowerCase()}', if (threadId != null) 't:${threadId.toLowerCase()}', ]; return parts.join('|'); } 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);