Files
rely/lib/features/local/local_repository.dart
T

1541 lines
47 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 SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: sourceApp,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName,
);
PersonProfile? resolvedPerson;
if (matchedLink != null) {
resolvedPerson = _findPersonById(current.people, matchedLink.profileId);
}
final List<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,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.ambiguousProfileMatch,
candidateProfileIds: matchingPeople
.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,
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,
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,
sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName,
);
return _ingestIntoResolvedProfile(
current: current,
sourceApp: entry.sourceApp,
messageText: entry.messageText,
sharedAt: entry.sharedAt,
importedAt: DateTime.now(),
sourceDisplayName: entry.sourceDisplayName,
sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: false,
people: current.people,
resolvedAutomatically: false,
consumedInboxEntryId: entry.id,
);
}
Future<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,
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,
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);
await _setState(
current.copyWith(
people: people,
moments: moments,
ideas: ideas,
reminders: reminders,
),
);
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,
),
),
]);
}
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,
),
);
}
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),
);
}
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;
}
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,
);
}
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? sourceDisplayName,
String? sourceUserId,
String? sourceThreadId,
}) async {
final String summary = messageText.length > 1800
? messageText.substring(0, 1800)
: messageText;
final SharedInboxEntry entry = SharedInboxEntry(
id: 'si-${_uuid.v4()}',
sourceApp: sourceApp,
messageText: summary,
sharedAt: sharedAt,
receivedAt: receivedAt,
reason: reason,
candidateProfileIds: candidateProfileIds,
normalizedDisplayName: normalizedDisplayName,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
);
final List<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 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,
normalizedDisplayName: normalizedDisplayName,
profileId: resolvedPerson.id,
firstSeenAt: sharedAt,
lastSeenAt: sharedAt,
),
);
} else {
final int index = nextLinks.indexWhere(
(SourceProfileLink link) => link.id == matchedLink.id,
);
if (index >= 0) {
nextLinks[index] = matchedLink.copyWith(
sourceUserId: sourceUserId ?? matchedLink.sourceUserId,
sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId,
normalizedDisplayName: normalizedDisplayName.isEmpty
? matchedLink.normalizedDisplayName
: normalizedDisplayName,
profileId: resolvedPerson.id,
lastSeenAt: sharedAt,
);
}
}
final String summary = messageText.length > 1800
? messageText.substring(0, 1800)
: messageText;
final RelationshipMoment moment = RelationshipMoment(
id: 'm-${_uuid.v4()}',
personId: resolvedPerson.id,
title: _titleFromSummary(summary),
summary: summary,
at: sharedAt,
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
);
final List<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? sourceUserId,
required String? sourceThreadId,
required String normalizedDisplayName,
}) {
if (sourceUserId != null && sourceUserId.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) {
return link;
}
}
}
if (sourceThreadId != null && sourceThreadId.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp &&
link.sourceThreadId == sourceThreadId) {
return link;
}
}
}
if (normalizedDisplayName.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp &&
link.normalizedDisplayName == normalizedDisplayName) {
return link;
}
}
}
return null;
}
PersonProfile? _findPersonById(List<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);
}
SharedInboxEntry _requireSharedInboxEntry(
LocalDataState current,
String inboxEntryId,
) {
for (final SharedInboxEntry entry in current.sharedInbox) {
if (entry.id == inboxEntryId) {
return entry;
}
}
throw ArgumentError('Shared inbox entry not found');
}
String? _deriveAutoProfileName({
required String? sourceDisplayName,
required String? sourceUserId,
required String? sourceThreadId,
}) {
return _trimToNull(sourceDisplayName) ??
_trimToNull(sourceUserId) ??
_trimToNull(sourceThreadId);
}
String _normalizeDisplayName(String? raw) {
if (raw == null) {
return '';
}
final String compact = raw
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.trim();
return compact.replaceAll(RegExp(r'\s+'), ' ');
}
String? _trimToNull(String? value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
}
final AsyncNotifierProvider<LocalRepository, LocalDataState>
localRepositoryProvider =
AsyncNotifierProvider<LocalRepository, LocalDataState>(LocalRepository.new);