2154 lines
66 KiB
Dart
2154 lines
66 KiB
Dart
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<LocalDataState> {
|
|
static const int _schemaVersion = 3;
|
|
static const int _syncSchemaVersion = 1;
|
|
|
|
static const Uuid _uuid = Uuid();
|
|
|
|
@override
|
|
Future<LocalDataState> build() async {
|
|
final LocalDataStore store = ref.read(localDataStoreProvider);
|
|
LocalDataRecord? record = await store.read();
|
|
|
|
if ((record == null || record.rawState.isEmpty) &&
|
|
AppConfig.useHiveLocalDb &&
|
|
store is HiveLocalDataStore) {
|
|
final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore();
|
|
final LocalDataRecord? legacy = await legacyStore.read();
|
|
if (legacy != null && legacy.rawState.isNotEmpty) {
|
|
await store.write(
|
|
rawState: legacy.rawState,
|
|
schemaVersion: legacy.schemaVersion,
|
|
);
|
|
await legacyStore.clear();
|
|
record = await store.read();
|
|
}
|
|
}
|
|
|
|
if (record == null || record.rawState.isEmpty) {
|
|
final LocalDataState seeded = LocalDataState.seed();
|
|
await _persist(seeded);
|
|
await _reconcileReminderSchedule(seeded);
|
|
return seeded;
|
|
}
|
|
|
|
final LocalDataRecord migrated = await _migrateIfNeeded(record);
|
|
try {
|
|
final Map<String, dynamic> json =
|
|
jsonDecode(migrated.rawState) as Map<String, dynamic>;
|
|
final LocalDataState loaded = LocalDataState.fromJson(json);
|
|
await _reconcileReminderSchedule(loaded);
|
|
return loaded;
|
|
} on FormatException {
|
|
final LocalDataState fallback = LocalDataState.seed();
|
|
await _persist(fallback);
|
|
await _reconcileReminderSchedule(fallback);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
Future<void> addPerson({
|
|
required String name,
|
|
required String relationship,
|
|
required String notes,
|
|
required List<String> 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<PersonProfile> people = <PersonProfile>[
|
|
person,
|
|
...current.people,
|
|
];
|
|
await _setState(current.copyWith(people: people));
|
|
await _enqueueChange(
|
|
_buildEnvelope(
|
|
entityType: 'person',
|
|
entityId: person.id,
|
|
op: ChangeOperation.upsert,
|
|
payload: _personPayload(person),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<SharedMessageIngestResult> 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<PersonProfile> matchingPeople = normalizedDisplayName.isEmpty
|
|
? const <PersonProfile>[]
|
|
: _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<PersonProfile> nearMatchPeople = resolvedPerson == null
|
|
? _findNearMatchPeople(
|
|
current.people,
|
|
normalizedDisplayName: normalizedDisplayName,
|
|
)
|
|
: const <PersonProfile>[];
|
|
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<PersonProfile> 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 <String>[],
|
|
);
|
|
}
|
|
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 <String>['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<SharedMessageIngestResult> 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<SharedMessageIngestResult> 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 <String>['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<PersonProfile> nextPeople = <PersonProfile>[
|
|
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<void> dismissSharedInboxEntry(String inboxEntryId) async {
|
|
final LocalDataState current = _requireState();
|
|
final List<SharedInboxEntry> 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<void> 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<PersonProfile> 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<void> deletePerson(String personId) async {
|
|
final LocalDataState current = _requireState();
|
|
final List<RelationshipMoment> removedMoments = current.moments
|
|
.where((RelationshipMoment moment) => moment.personId == personId)
|
|
.toList(growable: false);
|
|
final List<RelationshipIdea> removedIdeas = current.ideas
|
|
.where((RelationshipIdea idea) => idea.personId == personId)
|
|
.toList(growable: false);
|
|
final List<ReminderRule> removedReminders = current.reminders
|
|
.where((ReminderRule reminder) => reminder.personId == personId)
|
|
.toList(growable: false);
|
|
|
|
final List<PersonProfile> people = current.people
|
|
.where((PersonProfile person) => person.id != personId)
|
|
.toList(growable: false);
|
|
final List<RelationshipMoment> moments = current.moments
|
|
.where((RelationshipMoment moment) => moment.personId != personId)
|
|
.toList(growable: false);
|
|
final List<RelationshipIdea> ideas = current.ideas
|
|
.where((RelationshipIdea idea) => idea.personId != personId)
|
|
.toList(growable: false);
|
|
final List<ReminderRule> reminders = current.reminders
|
|
.where((ReminderRule reminder) => reminder.personId != personId)
|
|
.toList(growable: false);
|
|
final List<PersonPreferenceSignal> 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(<ChangeEnvelope>[
|
|
_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<void> 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<PersonProfile> nextPeople = current.people
|
|
.where((PersonProfile person) => person.id != sourcePersonId)
|
|
.map(
|
|
(PersonProfile person) =>
|
|
person.id == targetPersonId ? mergedTarget : person,
|
|
)
|
|
.toList(growable: false);
|
|
|
|
final List<RelationshipMoment> updatedMoments = <RelationshipMoment>[];
|
|
final List<RelationshipMoment> 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<RelationshipIdea> updatedIdeas = <RelationshipIdea>[];
|
|
final List<RelationshipIdea> 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<ReminderRule> updatedReminders = <ReminderRule>[];
|
|
final List<ReminderRule> 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<SourceProfileLink> nextLinks = current.sourceLinks
|
|
.map((SourceProfileLink link) {
|
|
if (link.profileId != sourcePersonId) {
|
|
return link;
|
|
}
|
|
return link.copyWith(profileId: targetPersonId);
|
|
})
|
|
.toList(growable: false);
|
|
|
|
final List<SharedMessageEntry> nextMessages = current.sharedMessages
|
|
.map((SharedMessageEntry entry) {
|
|
if (entry.profileId != sourcePersonId) {
|
|
return entry;
|
|
}
|
|
return entry.copyWith(profileId: targetPersonId);
|
|
})
|
|
.toList(growable: false);
|
|
|
|
final List<SharedInboxEntry> nextInbox = current.sharedInbox
|
|
.map((SharedInboxEntry entry) {
|
|
if (!entry.candidateProfileIds.contains(sourcePersonId)) {
|
|
return entry;
|
|
}
|
|
final List<String> candidates = entry.candidateProfileIds
|
|
.map(
|
|
(String profileId) =>
|
|
profileId == sourcePersonId ? targetPersonId : profileId,
|
|
)
|
|
.toSet()
|
|
.toList(growable: false);
|
|
return entry.copyWith(candidateProfileIds: candidates);
|
|
})
|
|
.toList(growable: false);
|
|
final List<PersonPreferenceSignal> 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(<ChangeEnvelope>[
|
|
_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<void> 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<RelationshipMoment> moments = <RelationshipMoment>[
|
|
moment,
|
|
...current.moments,
|
|
];
|
|
await _setState(current.copyWith(moments: moments));
|
|
await _enqueueChange(
|
|
_buildEnvelope(
|
|
entityType: 'capture',
|
|
entityId: moment.id,
|
|
op: ChangeOperation.upsert,
|
|
payload: _momentPayload(moment),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> updateMoment(RelationshipMoment moment) async {
|
|
if (moment.summary.trim().isEmpty) {
|
|
throw ArgumentError('Capture summary cannot be empty');
|
|
}
|
|
|
|
final LocalDataState current = _requireState();
|
|
final List<RelationshipMoment> 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<void> deleteMoment(String momentId) async {
|
|
final LocalDataState current = _requireState();
|
|
final List<RelationshipMoment> 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<void> 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<RelationshipIdea> ideas = <RelationshipIdea>[
|
|
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<void> updateIdea(RelationshipIdea idea) async {
|
|
if (idea.title.trim().isEmpty) {
|
|
throw ArgumentError('Idea title is required');
|
|
}
|
|
|
|
final LocalDataState current = _requireState();
|
|
final List<RelationshipIdea> 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<void> toggleIdeaArchived(String ideaId) async {
|
|
final LocalDataState current = _requireState();
|
|
RelationshipIdea? updatedIdea;
|
|
final List<RelationshipIdea> 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<void> deleteIdea(String ideaId) async {
|
|
final LocalDataState current = _requireState();
|
|
final List<RelationshipIdea> 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<void> 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<ReminderRule> reminders = <ReminderRule>[
|
|
reminder,
|
|
...current.reminders,
|
|
];
|
|
await _setState(current.copyWith(reminders: reminders));
|
|
await _enqueueChange(
|
|
_buildEnvelope(
|
|
entityType: 'reminderRule',
|
|
entityId: reminder.id,
|
|
op: ChangeOperation.upsert,
|
|
payload: _reminderPayload(reminder),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> updateReminder(ReminderRule reminder) async {
|
|
if (reminder.title.trim().isEmpty) {
|
|
throw ArgumentError('Reminder title is required');
|
|
}
|
|
|
|
final LocalDataState current = _requireState();
|
|
final List<ReminderRule> 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<void> toggleReminderEnabled(String reminderId) async {
|
|
final LocalDataState current = _requireState();
|
|
ReminderRule? updatedReminder;
|
|
final List<ReminderRule> 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<void> deleteReminder(String reminderId) async {
|
|
final LocalDataState current = _requireState();
|
|
final List<ReminderRule> 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<PersonPreferenceSignal> 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<PersonPreferenceSignal> 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 <String>[] : <String>[source],
|
|
evidenceMessageIds: messageId == null
|
|
? const <String>[]
|
|
: <String>[messageId],
|
|
evidenceSnippets: snippet == null
|
|
? const <String>[]
|
|
: <String>[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<void> 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<PersonPreferenceSignal> nextSignals = _upsertItem(
|
|
items: current.preferenceSignals,
|
|
item: signal,
|
|
idOf: (PersonPreferenceSignal value) => value.id,
|
|
);
|
|
await _setState(current.copyWith(preferenceSignals: nextSignals));
|
|
}
|
|
|
|
Future<void> setPreferenceSignalStatus({
|
|
required String signalId,
|
|
required PreferenceSignalStatus status,
|
|
}) async {
|
|
final LocalDataState current = _requireState();
|
|
final List<PersonPreferenceSignal> 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<void> confirmPreferenceSignal(String signalId) {
|
|
return setPreferenceSignalStatus(
|
|
signalId: signalId,
|
|
status: PreferenceSignalStatus.confirmed,
|
|
);
|
|
}
|
|
|
|
Future<void> dismissPreferenceSignal(String signalId) {
|
|
return setPreferenceSignalStatus(
|
|
signalId: signalId,
|
|
status: PreferenceSignalStatus.dismissed,
|
|
);
|
|
}
|
|
|
|
Future<void> toggleTaskDone(String taskId) async {
|
|
final LocalDataState current = _requireState();
|
|
final List<DashboardTask> 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<void> applyRemoteChanges(List<ChangeEnvelope> 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<String, dynamic> payload = change.payload ?? <String, dynamic>{};
|
|
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 ?? <String>[]),
|
|
notes: _stringField(payload, 'notes', existing?.notes ?? ''),
|
|
location: _stringOrNullField(payload, 'location', existing?.location),
|
|
);
|
|
final List<PersonProfile> 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<String, dynamic> payload = change.payload ?? <String, dynamic>{};
|
|
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<RelationshipMoment> 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<String, dynamic> payload = change.payload ?? <String, dynamic>{};
|
|
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<RelationshipIdea> 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<String, dynamic> payload = change.payload ?? <String, dynamic>{};
|
|
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<ReminderRule> reminders = _upsertItem(
|
|
items: current.reminders,
|
|
item: next,
|
|
idOf: (ReminderRule reminder) => reminder.id,
|
|
);
|
|
return current.copyWith(reminders: reminders);
|
|
}
|
|
|
|
Future<void> _enqueueChange(ChangeEnvelope change) async {
|
|
await ref.read(syncQueueRepositoryProvider.notifier).enqueue(change);
|
|
}
|
|
|
|
Future<void> _enqueueChanges(List<ChangeEnvelope> changes) async {
|
|
await ref.read(syncQueueRepositoryProvider.notifier).enqueueAll(changes);
|
|
}
|
|
|
|
ChangeEnvelope _buildEnvelope({
|
|
required String entityType,
|
|
required String entityId,
|
|
required ChangeOperation op,
|
|
Map<String, dynamic>? payload,
|
|
}) {
|
|
return ChangeEnvelope(
|
|
schemaVersion: _syncSchemaVersion,
|
|
entityType: entityType,
|
|
entityId: entityId,
|
|
op: op,
|
|
modifiedAt: DateTime.now().toUtc(),
|
|
clientMutationId: _uuid.v4(),
|
|
payload: payload,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> _personPayload(PersonProfile person) {
|
|
return <String, dynamic>{
|
|
'name': person.name,
|
|
'relationship': person.relationship,
|
|
'affinityScore': person.affinityScore,
|
|
'nextMoment': person.nextMoment.toUtc().toIso8601String(),
|
|
'tags': person.tags,
|
|
'notes': person.notes,
|
|
'location': person.location,
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> _momentPayload(RelationshipMoment moment) {
|
|
return <String, dynamic>{
|
|
'personId': moment.personId,
|
|
'title': moment.title,
|
|
'summary': moment.summary,
|
|
'at': moment.at.toUtc().toIso8601String(),
|
|
'type': moment.type,
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> _ideaPayload(RelationshipIdea idea) {
|
|
return <String, dynamic>{
|
|
'personId': idea.personId,
|
|
'type': idea.type.name,
|
|
'title': idea.title,
|
|
'details': idea.details,
|
|
'createdAt': idea.createdAt.toUtc().toIso8601String(),
|
|
'isArchived': idea.isArchived,
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> _reminderPayload(ReminderRule reminder) {
|
|
return <String, dynamic>{
|
|
'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<RelationshipIdea> 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<T> _upsertItem<T>({
|
|
required List<T> 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 <T>[item, ...items];
|
|
}
|
|
|
|
final List<T> copy = items.toList();
|
|
copy[index] = item;
|
|
return copy;
|
|
}
|
|
|
|
String _stringField(
|
|
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> payload, String key, bool fallback) {
|
|
final dynamic value = payload[key];
|
|
if (value is bool) {
|
|
return value;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
List<String> _stringListField(
|
|
Map<String, dynamic> payload,
|
|
String key,
|
|
List<String> fallback,
|
|
) {
|
|
final dynamic value = payload[key];
|
|
if (value is! List<dynamic>) {
|
|
return fallback;
|
|
}
|
|
|
|
return value.map((dynamic item) => '$item').toList(growable: false);
|
|
}
|
|
|
|
DateTime _dateField(
|
|
Map<String, dynamic> 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<PersonProfile> 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<String> _mergeUniqueStrings(
|
|
List<String> existing,
|
|
String? incoming, {
|
|
int maxItems = 12,
|
|
}) {
|
|
final List<String> values = <String>[];
|
|
final Set<String> seen = <String>{};
|
|
|
|
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<void> _setState(LocalDataState next) async {
|
|
state = AsyncData<LocalDataState>(next);
|
|
await _persist(next);
|
|
await _reconcileReminderSchedule(next);
|
|
}
|
|
|
|
LocalDataState _requireState() {
|
|
final LocalDataState? value = state.asData?.value;
|
|
if (value == null) {
|
|
throw StateError('Local data state is not ready yet');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
Future<void> _persist(LocalDataState state) async {
|
|
await ref
|
|
.read(localDataStoreProvider)
|
|
.write(
|
|
rawState: jsonEncode(state.toJson()),
|
|
schemaVersion: _schemaVersion,
|
|
);
|
|
}
|
|
|
|
Future<void> _reconcileReminderSchedule(LocalDataState state) async {
|
|
try {
|
|
await ref.read(reminderSchedulerProvider).reconcile(state.reminders);
|
|
} catch (_) {
|
|
// Scheduling failures must not block local-first data updates.
|
|
}
|
|
}
|
|
|
|
Future<LocalDataRecord> _migrateIfNeeded(LocalDataRecord record) async {
|
|
final int currentVersion = record.schemaVersion;
|
|
if (currentVersion == _schemaVersion) {
|
|
return record;
|
|
}
|
|
|
|
final LocalDataStore store = ref.read(localDataStoreProvider);
|
|
if (currentVersion <= 0) {
|
|
final LocalDataState seeded = LocalDataState.seed();
|
|
await store.clear();
|
|
await _persist(seeded);
|
|
return LocalDataRecord(
|
|
rawState: jsonEncode(seeded.toJson()),
|
|
schemaVersion: _schemaVersion,
|
|
);
|
|
}
|
|
|
|
// Migration placeholder for future schema versions.
|
|
await store.write(rawState: record.rawState, schemaVersion: _schemaVersion);
|
|
return LocalDataRecord(
|
|
rawState: record.rawState,
|
|
schemaVersion: _schemaVersion,
|
|
);
|
|
}
|
|
|
|
List<String> _mergeTags(List<String> primary, List<String> secondary) {
|
|
final List<String> merged = <String>[];
|
|
final Set<String> seen = <String>{};
|
|
for (final String raw in <String>[...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<SharedMessageIngestResult> _queueSharedInbox({
|
|
required LocalDataState current,
|
|
required String sourceApp,
|
|
required String messageText,
|
|
required DateTime sharedAt,
|
|
required DateTime receivedAt,
|
|
required SharedInboxReason reason,
|
|
required List<String> 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<SharedInboxEntry> nextInbox = <SharedInboxEntry>[
|
|
entry,
|
|
...current.sharedInbox,
|
|
];
|
|
await _setState(current.copyWith(sharedInbox: nextInbox));
|
|
return SharedMessageIngestResult.queuedForResolution(
|
|
inboxEntryId: entry.id,
|
|
);
|
|
}
|
|
|
|
Future<SharedMessageIngestResult> _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<PersonProfile> people,
|
|
required bool resolvedAutomatically,
|
|
SourceProfileLink? matchedLink,
|
|
String? sourceDisplayName,
|
|
String? sourceUserId,
|
|
String? sourceThreadId,
|
|
String? consumedInboxEntryId,
|
|
}) async {
|
|
final bool createdSourceLink = matchedLink == null;
|
|
final List<SourceProfileLink> 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<RelationshipMoment> nextMoments = <RelationshipMoment>[
|
|
moment,
|
|
...current.moments,
|
|
];
|
|
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
|
|
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<SharedInboxEntry> 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<ChangeEnvelope> outbound = <ChangeEnvelope>[
|
|
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<String> words = summary.split(RegExp(r'\s+'));
|
|
final int take = words.length < 5 ? words.length : 5;
|
|
return words.take(take).join(' ');
|
|
}
|
|
|
|
SourceProfileLink? _findExistingSourceLink({
|
|
required List<SourceProfileLink> 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<PersonProfile> people, String id) {
|
|
for (final PersonProfile person in people) {
|
|
if (person.id == id) {
|
|
return person;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<PersonProfile> _findPeopleByNormalizedName(
|
|
List<PersonProfile> people,
|
|
String normalizedDisplayName,
|
|
) {
|
|
return people
|
|
.where(
|
|
(PersonProfile person) =>
|
|
_normalizeDisplayName(person.name) == normalizedDisplayName,
|
|
)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
List<PersonProfile> _findNearMatchPeople(
|
|
List<PersonProfile> people, {
|
|
required String normalizedDisplayName,
|
|
}) {
|
|
if (normalizedDisplayName.isEmpty) {
|
|
return const <PersonProfile>[];
|
|
}
|
|
|
|
final Map<String, int> distanceById = <String, int>{};
|
|
final List<PersonProfile> candidates = <PersonProfile>[];
|
|
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<int> previous = List<int>.generate(
|
|
right.length + 1,
|
|
(int index) => index,
|
|
growable: false,
|
|
);
|
|
for (int i = 1; i <= left.length; i += 1) {
|
|
final List<int> current = List<int>.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<String> parts = <String>[
|
|
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<LocalRepository, LocalDataState>
|
|
localRepositoryProvider =
|
|
AsyncNotifierProvider<LocalRepository, LocalDataState>(LocalRepository.new);
|