feat: add local-first private AI digest workflow

Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
+15
View File
@@ -0,0 +1,15 @@
# App Data
This folder contains the prototype app store and persistence adapters.
`relationship_repository.dart` is the single local-first write boundary for the
prototype. It now delegates behavior into part files so the code is grouped by
slice instead of one giant file:
- `relationship_repository_people.dart`: people CRUD and profile merging.
- `relationship_repository_share.dart`: share intake, inbox, and person resolution.
- `relationship_repository_activity.dart`: captures, facts, ideas, reminders, and task toggles.
- `relationship_repository_sync.dart`: sync envelopes and remote-change application.
If you are changing persistence behavior, start with the matching part file and
only touch the root repository file when the shared lifecycle changes.
+176
View File
@@ -0,0 +1,176 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/local/chat_preference_extractor.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
import 'package:relationship_saver/features/share_intake/application/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
import 'package:relationship_saver/features/sync/data/sync_queue_repository.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import 'package:uuid/uuid.dart';
part 'relationship_repository_activity.dart';
part 'relationship_repository_ai_digest.dart';
part 'relationship_repository_people.dart';
part 'relationship_repository_share.dart';
part 'relationship_repository_sync.dart';
const int _schemaVersion = 5;
const int _syncSchemaVersion = 1;
const Uuid _uuid = Uuid();
const ChatPreferenceExtractor _chatPreferenceExtractor =
ChatPreferenceExtractor();
/// Persisted local-first app store used by the current prototype.
///
/// The storage backend is still a single local record because that keeps the
/// prototype stable, but the repository implementation is now split by feature
/// behavior so it reads like vertical slices instead of one monolithic file.
class LocalRepository extends AsyncNotifier<LocalDataState> {
@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> _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,
);
}
try {
final Map<String, dynamic> json =
jsonDecode(record.rawState) as Map<String, dynamic>;
final LocalDataState migratedState = LocalDataState.fromJson(json);
final String migratedRawState = jsonEncode(migratedState.toJson());
await store.write(
rawState: migratedRawState,
schemaVersion: _schemaVersion,
);
return LocalDataRecord(
rawState: migratedRawState,
schemaVersion: _schemaVersion,
);
} on FormatException {
final LocalDataState seeded = LocalDataState.seed();
await store.clear();
await _persist(seeded);
return LocalDataRecord(
rawState: jsonEncode(seeded.toJson()),
schemaVersion: _schemaVersion,
);
}
}
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);
}
}
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);
@@ -0,0 +1,667 @@
part of 'relationship_repository.dart';
/// Capture, idea, reminder, and lightweight dashboard state persistence.
extension LocalRepositoryActivityOperations on LocalRepository {
Future<void> addMoment({
required String personId,
required String summary,
String type = 'capture',
}) async {
final String normalized = summary.trim();
if (normalized.isEmpty) {
throw ArgumentError('Capture summary cannot be empty');
}
final LocalDataState current = _requireState();
final String payload = _truncate(normalized, 280);
final RelationshipMoment moment = RelationshipMoment(
id: 'm-${_uuid.v4()}',
personId: personId,
title: _titleFromSummary(payload),
summary: payload,
at: DateTime.now(),
type: type,
);
await _setState(
current.copyWith(
moments: <RelationshipMoment>[moment, ...current.moments],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'capture',
entityId: moment.id,
op: ChangeOperation.upsert,
payload: _momentPayload(moment),
),
);
}
Future<void> addPersonFact({
required String personId,
required CapturedFactType type,
required String text,
String? label,
CaptureSourceKind sourceKind = CaptureSourceKind.manual,
String? sourceApp,
String? sourceUrl,
String? sharedMessageId,
String? inboxEntryId,
double? confidence,
bool needsReview = false,
bool isSensitive = false,
DateTime? createdAt,
}) async {
final LocalDataState current = _requireState();
final String normalizedText = text.trim();
if (normalizedText.isEmpty) {
throw ArgumentError('Fact text is required');
}
final DateTime now = createdAt ?? DateTime.now();
final PersonFact fact = PersonFact(
id: 'pf-${_uuid.v4()}',
personId: personId,
type: type,
text: normalizedText,
label: _trimToNull(label),
sourceKind: sourceKind,
sourceApp: _trimToNull(sourceApp),
sourceUrl: _trimToNull(sourceUrl),
sharedMessageId: sharedMessageId,
inboxEntryId: inboxEntryId,
confidence: confidence,
needsReview: needsReview,
isSensitive: isSensitive,
createdAt: now,
updatedAt: now,
);
await _setState(
current.copyWith(
personFacts: <PersonFact>[fact, ...current.personFacts],
people: _touchPeople(
current.people,
personId: personId,
updatedAt: now,
interactedAt: now,
),
),
);
}
Future<void> updatePersonFact(PersonFact fact) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final List<PersonFact> updated = current.personFacts
.map(
(PersonFact item) =>
item.id == fact.id ? fact.copyWith(updatedAt: now) : item,
)
.toList(growable: false);
await _setState(
current.copyWith(
personFacts: updated,
people: _touchPeople(
current.people,
personId: fact.personId,
updatedAt: now,
),
),
);
}
Future<void> deletePersonFact(String factId) async {
final LocalDataState current = _requireState();
final List<PersonFact> updated = current.personFacts
.where((PersonFact item) => item.id != factId)
.toList(growable: false);
await _setState(current.copyWith(personFacts: updated));
}
Future<void> addImportantDate({
required String personId,
required String label,
required DateTime date,
String classification = 'important',
CaptureSourceKind sourceKind = CaptureSourceKind.manual,
String? sourceApp,
String? inboxEntryId,
bool isSensitive = false,
DateTime? createdAt,
}) async {
final LocalDataState current = _requireState();
final String normalizedLabel = label.trim();
if (normalizedLabel.isEmpty) {
throw ArgumentError('Date label is required');
}
final DateTime now = createdAt ?? DateTime.now();
final PersonImportantDate importantDate = PersonImportantDate(
id: 'pd-${_uuid.v4()}',
personId: personId,
label: normalizedLabel,
date: date,
classification: classification.trim().isEmpty
? 'important'
: classification.trim(),
sourceKind: sourceKind,
sourceApp: _trimToNull(sourceApp),
inboxEntryId: inboxEntryId,
isSensitive: isSensitive,
createdAt: now,
updatedAt: now,
);
await _setState(
current.copyWith(
importantDates: <PersonImportantDate>[
importantDate,
...current.importantDates,
],
people: _touchPeople(
current.people,
personId: personId,
updatedAt: now,
interactedAt: now,
),
),
);
}
Future<void> updateImportantDate(PersonImportantDate value) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final List<PersonImportantDate> updated = current.importantDates
.map(
(PersonImportantDate item) =>
item.id == value.id ? value.copyWith(updatedAt: now) : item,
)
.toList(growable: false);
await _setState(
current.copyWith(
importantDates: updated,
people: _touchPeople(
current.people,
personId: value.personId,
updatedAt: now,
),
),
);
}
Future<void> deleteImportantDate(String dateId) async {
final LocalDataState current = _requireState();
final List<PersonImportantDate> updated = current.importantDates
.where((PersonImportantDate item) => item.id != dateId)
.toList(growable: false);
await _setState(current.copyWith(importantDates: updated));
}
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(),
);
await _setState(
current.copyWith(ideas: <RelationshipIdea>[idea, ...current.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,
);
await _setState(
current.copyWith(
reminders: <ReminderRule>[reminder, ...current.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<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> dismissLocalSignal(String signalId) async {
final String normalizedId = signalId.trim();
if (normalizedId.isEmpty) {
return;
}
final LocalDataState current = _requireState();
if (current.dismissedSignalIds.contains(normalizedId)) {
return;
}
await _setState(
current.copyWith(
dismissedSignalIds: <String>[
normalizedId,
...current.dismissedSignalIds,
],
),
);
}
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));
}
}
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);
}
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(' ');
}
@@ -0,0 +1,161 @@
part of 'relationship_repository.dart';
extension LocalRepositoryAiDigestOperations on LocalRepository {
Future<void> saveAiSuggestionDrafts(List<AiSuggestionDraft> drafts) async {
if (drafts.isEmpty) {
return;
}
final LocalDataState current = _requireState();
final Set<String> existingKeys = current.aiSuggestionDrafts
.map(_aiDraftDedupeKey)
.toSet();
final List<AiSuggestionDraft> uniqueDrafts = drafts
.where((AiSuggestionDraft draft) {
if (!_personExists(current.people, draft.personId)) {
return false;
}
return existingKeys.add(_aiDraftDedupeKey(draft));
})
.toList(growable: false);
if (uniqueDrafts.isEmpty) {
return;
}
await _setState(
current.copyWith(
aiSuggestionDrafts: <AiSuggestionDraft>[
...uniqueDrafts,
...current.aiSuggestionDrafts,
],
),
);
}
Future<void> acceptAiSuggestionDraft(String draftId) async {
final LocalDataState current = _requireState();
final AiSuggestionDraft? draft = current.aiSuggestionDrafts
.where((AiSuggestionDraft item) => item.id == draftId)
.cast<AiSuggestionDraft?>()
.firstOrNull;
if (draft == null || draft.status != AiSuggestionStatus.pending) {
return;
}
if (!_personExists(current.people, draft.personId)) {
await _setAiSuggestionStatus(draftId, AiSuggestionStatus.dismissed);
return;
}
final DateTime now = DateTime.now();
final List<AiSuggestionDraft> drafts = current.aiSuggestionDrafts
.map(
(AiSuggestionDraft item) => item.id == draftId
? item.copyWith(status: AiSuggestionStatus.accepted)
: item,
)
.toList(growable: false);
switch (draft.kind) {
case AiSuggestionKind.giftIdea:
case AiSuggestionKind.eventIdea:
final RelationshipIdea idea = RelationshipIdea(
id: 'i-${_uuid.v4()}',
personId: draft.personId,
type: draft.kind == AiSuggestionKind.giftIdea
? IdeaType.gift
: IdeaType.event,
title: draft.title,
details: _aiDraftDetails(draft),
createdAt: now,
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
ideas: <RelationshipIdea>[idea, ...current.ideas],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: _ideaEntityType(idea.type),
entityId: idea.id,
op: ChangeOperation.upsert,
payload: _ideaPayload(idea),
),
);
case AiSuggestionKind.checkIn:
final DashboardTask task = DashboardTask(
id: 't-${_uuid.v4()}',
title: draft.title,
description: _aiDraftDetails(draft),
when: draft.suggestedFor ?? now.add(const Duration(days: 1)),
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
tasks: <DashboardTask>[task, ...current.tasks],
),
);
case AiSuggestionKind.reminder:
final ReminderRule reminder = ReminderRule(
id: 'r-${_uuid.v4()}',
personId: draft.personId,
title: draft.title,
cadence: ReminderCadence.weekly,
nextAt: draft.suggestedFor ?? now.add(const Duration(days: 7)),
);
await _setState(
current.copyWith(
aiSuggestionDrafts: drafts,
reminders: <ReminderRule>[reminder, ...current.reminders],
),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'reminderRule',
entityId: reminder.id,
op: ChangeOperation.upsert,
payload: _reminderPayload(reminder),
),
);
}
}
Future<void> dismissAiSuggestionDraft(String draftId) {
return _setAiSuggestionStatus(draftId, AiSuggestionStatus.dismissed);
}
Future<void> _setAiSuggestionStatus(
String draftId,
AiSuggestionStatus status,
) async {
final LocalDataState current = _requireState();
final List<AiSuggestionDraft> drafts = current.aiSuggestionDrafts
.map(
(AiSuggestionDraft item) =>
item.id == draftId ? item.copyWith(status: status) : item,
)
.toList(growable: false);
await _setState(current.copyWith(aiSuggestionDrafts: drafts));
}
}
String _aiDraftDedupeKey(AiSuggestionDraft draft) {
return <String>[
draft.personId,
draft.kind.name,
draft.title.trim().toLowerCase(),
draft.details.trim().toLowerCase(),
].join('|');
}
String _aiDraftDetails(AiSuggestionDraft draft) {
final String reason = draft.reason == null || draft.reason!.trim().isEmpty
? ''
: '\n\nWhy: ${draft.reason!.trim()}';
return '${draft.details.trim()}$reason'.trim();
}
extension<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}
@@ -0,0 +1,523 @@
part of 'relationship_repository.dart';
/// Person-profile persistence and merge operations.
extension LocalRepositoryPeopleOperations on LocalRepository {
Future<void> addPerson({
required String name,
required String relationship,
required String notes,
required List<String> tags,
DateTime? nextMoment,
String? location,
List<String> aliases = const <String>[],
}) async {
final String normalizedName = name.trim();
final String normalizedRelationship = relationship.trim();
if (normalizedName.isEmpty || normalizedRelationship.isEmpty) {
throw ArgumentError('Name and relationship are required');
}
final DateTime now = DateTime.now();
final LocalDataState current = _requireState();
final PersonProfile person = PersonProfile(
id: 'p-${_uuid.v4()}',
name: normalizedName,
relationship: normalizedRelationship,
affinityScore: 75,
nextMoment: nextMoment ?? now.add(const Duration(days: 3)),
tags: _mergeTags(tags, const <String>[]),
notes: notes.trim(),
aliases: _normalizeAliases(aliases),
location: _trimToNull(location),
lastUpdatedAt: now,
);
await _setState(
current.copyWith(people: <PersonProfile>[person, ...current.people]),
);
await _enqueueChange(
_buildEnvelope(
entityType: 'person',
entityId: person.id,
op: ChangeOperation.upsert,
payload: _personPayload(person),
),
);
}
Future<void> updatePerson(PersonProfile person) async {
final String normalizedName = person.name.trim();
final String normalizedRelationship = person.relationship.trim();
if (normalizedName.isEmpty || normalizedRelationship.isEmpty) {
throw ArgumentError('Name and relationship are required');
}
final DateTime now = DateTime.now();
final PersonProfile updatedPerson = person.copyWith(
name: normalizedName,
relationship: normalizedRelationship,
notes: person.notes.trim(),
tags: _mergeTags(person.tags, const <String>[]),
aliases: _normalizeAliases(person.aliases),
location: _trimToNull(person.location),
lastUpdatedAt: now,
);
final LocalDataState current = _requireState();
final List<PersonProfile> updated = current.people
.map(
(PersonProfile item) =>
item.id == updatedPerson.id ? updatedPerson : item,
)
.toList(growable: false);
await _setState(current.copyWith(people: updated));
await _enqueueChange(
_buildEnvelope(
entityType: 'person',
entityId: updatedPerson.id,
op: ChangeOperation.upsert,
payload: _personPayload(updatedPerson),
),
);
}
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<PersonFact> removedFacts = current.personFacts
.where((PersonFact fact) => fact.personId == personId)
.toList(growable: false);
final List<PersonImportantDate> removedDates = current.importantDates
.where((PersonImportantDate value) => value.personId == personId)
.toList(growable: false);
await _setState(
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),
personFacts: current.personFacts
.where((PersonFact fact) => fact.personId != personId)
.toList(growable: false),
aiSuggestionDrafts: current.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId != personId)
.toList(growable: false),
importantDates: current.importantDates
.where((PersonImportantDate value) => value.personId != personId)
.toList(growable: false),
sourceLinks: current.sourceLinks
.where((SourceProfileLink link) => link.profileId != personId)
.toList(growable: false),
sharedMessages: current.sharedMessages
.where((SharedMessageEntry entry) => entry.profileId != personId)
.toList(growable: false),
sharedInbox: current.sharedInbox
.map((SharedInboxEntry entry) {
final List<String> nextCandidates = entry.candidateProfileIds
.where((String candidateId) => candidateId != personId)
.toList(growable: false);
return entry.copyWith(
candidateProfileIds: nextCandidates,
targetPersonId: entry.targetPersonId == personId
? null
: entry.targetPersonId,
);
})
.toList(growable: false),
),
);
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,
),
),
...removedFacts.map(
(PersonFact fact) => _buildEnvelope(
entityType: 'personFact',
entityId: fact.id,
op: ChangeOperation.delete,
),
),
...removedDates.map(
(PersonImportantDate value) => _buildEnvelope(
entityType: 'personDate',
entityId: value.id,
op: ChangeOperation.delete,
),
),
]);
}
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 DateTime now = DateTime.now();
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,
),
aliases: _normalizeAliases(<String>[
...target.aliases,
...source.aliases,
]),
location: _effectiveLocation(target.location, source.location),
lastUpdatedAt: now,
lastInteractedAt: _latestDate(
target.lastInteractedAt,
source.lastInteractedAt,
),
);
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) &&
entry.targetPersonId != 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,
targetPersonId: entry.targetPersonId == sourcePersonId
? targetPersonId
: entry.targetPersonId,
);
})
.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);
final List<PersonFact> nextFacts = current.personFacts
.map((PersonFact fact) {
if (fact.personId != sourcePersonId) {
return fact;
}
return fact.copyWith(personId: targetPersonId, updatedAt: now);
})
.toList(growable: false);
final List<PersonImportantDate> nextImportantDates = current.importantDates
.map((PersonImportantDate value) {
if (value.personId != sourcePersonId) {
return value;
}
return value.copyWith(personId: targetPersonId, updatedAt: now);
})
.toList(growable: false);
final List<AiSuggestionDraft> nextAiSuggestionDrafts = current
.aiSuggestionDrafts
.map((AiSuggestionDraft draft) {
if (draft.personId != sourcePersonId) {
return draft;
}
return draft.copyWith(personId: targetPersonId);
})
.toList(growable: false);
await _setState(
current.copyWith(
people: nextPeople,
moments: nextMoments,
ideas: nextIdeas,
reminders: nextReminders,
sourceLinks: nextLinks,
sharedMessages: nextMessages,
sharedInbox: nextInbox,
personFacts: nextFacts,
importantDates: nextImportantDates,
preferenceSignals: nextPreferenceSignals,
aiSuggestionDrafts: nextAiSuggestionDrafts,
),
);
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),
),
),
]);
}
}
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);
}
List<String> _normalizeAliases(List<String> aliases) {
final List<String> values = <String>[];
final Set<String> seen = <String>{};
for (final String raw in aliases) {
final String value = raw.trim();
if (value.isEmpty) {
continue;
}
final String key = value.toLowerCase();
if (seen.add(key)) {
values.add(value);
}
}
return values;
}
DateTime? _latestDate(DateTime? left, DateTime? right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isAfter(right) ? left : right;
}
List<PersonProfile> _touchPeople(
List<PersonProfile> people, {
required String personId,
DateTime? updatedAt,
DateTime? interactedAt,
}) {
return people
.map((PersonProfile person) {
if (person.id != personId) {
return person;
}
return person.copyWith(
lastUpdatedAt: updatedAt ?? person.lastUpdatedAt ?? DateTime.now(),
lastInteractedAt:
interactedAt ?? person.lastInteractedAt ?? DateTime.now(),
);
})
.toList(growable: false);
}
PersonProfile? _findPersonById(List<PersonProfile> people, String id) {
for (final PersonProfile person in people) {
if (person.id == id) {
return person;
}
}
return null;
}
@@ -0,0 +1,883 @@
part of 'relationship_repository.dart';
/// Share capture intake, person resolution, and inbox triage.
extension LocalRepositoryShareOperations on LocalRepository {
Future<SharedMessageIngestResult> ingestSharedMessage(
SharedMessageIngestInput input,
) async {
final DateTime now = DateTime.now();
final SharedPayload payload = SharedPayload(
sourceApp: input.sourceApp.trim().toLowerCase(),
payloadType: SharedPayloadType.text,
rawText: input.messageText.trim(),
createdAt: input.sharedAt ?? now,
receivedAt: now,
platform: 'legacy',
sourceDisplayName: _trimToNull(input.sourceDisplayName),
sourceUserId: _trimToNull(input.sourceUserId),
sourceThreadId: _trimToNull(input.sourceThreadId),
dedupeKey: _dedupeKeyForShare(
sourceApp: input.sourceApp,
rawText: input.messageText,
url: null,
sourceUserId: input.sourceUserId,
sourceThreadId: input.sourceThreadId,
),
);
if (payload.rawText.isEmpty) {
throw ArgumentError('Shared message text cannot be empty');
}
final LocalDataState current = _requireState();
final String sourceApp = payload.sourceApp;
final String? sourceDisplayName = payload.sourceDisplayName;
final String normalizedDisplayName = _normalizeDisplayName(
sourceDisplayName,
);
final String? sourceUserId = payload.sourceUserId;
final String? sourceThreadId = payload.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,
payload: payload,
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,
payload: payload,
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,
payload: payload,
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: now.add(const Duration(days: 2)),
tags: const <String>['whatsapp'],
notes: 'Auto-created from shared message.',
aliases: sourceDisplayName == null || sourceDisplayName == inferredName
? const <String>[]
: <String>[sourceDisplayName],
lastUpdatedAt: now,
);
nextPeople.insert(0, resolvedPerson);
}
return _ingestIntoResolvedProfile(
current: current,
importedAt: now,
payload: payload,
sourceFingerprint: sourceFingerprint,
normalizedDisplayName: normalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: createdProfile,
people: nextPeople,
resolvedAutomatically: true,
draft: _defaultDraftForPayload(payload),
);
}
Future<SharedMessageIngestResult> resolveSharedInboxToExistingProfile({
required String inboxEntryId,
required String profileId,
CapturedFactDraft? draft,
}) 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');
}
return captureSharedPayloadToExistingProfile(
payload: entry.payload,
profileId: profileId,
draft: draft ?? entry.draft,
consumedInboxEntryId: entry.id,
normalizedDisplayName: entry.normalizedDisplayName,
sourceFingerprint: entry.sourceFingerprint,
resolvedAutomatically: false,
);
}
Future<SharedMessageIngestResult> resolveSharedInboxByCreatingProfile({
required String inboxEntryId,
String? name,
String relationship = 'WhatsApp Contact',
String? location,
List<String> aliases = const <String>[],
CapturedFactDraft? draft,
}) async {
final LocalDataState current = _requireState();
final SharedInboxEntry entry = _requireSharedInboxEntry(
current,
inboxEntryId,
);
return captureSharedPayloadByCreatingProfile(
payload: entry.payload,
name: name,
relationship: relationship,
location: location,
aliases: aliases,
draft: draft ?? entry.draft,
consumedInboxEntryId: entry.id,
normalizedDisplayName: entry.normalizedDisplayName,
sourceFingerprint: entry.sourceFingerprint,
resolvedAutomatically: false,
);
}
Future<SharedMessageIngestResult> saveSharedPayloadToInbox({
required SharedPayload payload,
SharedInboxReason reason = SharedInboxReason.missingIdentity,
List<String> candidateProfileIds = const <String>[],
String? normalizedDisplayName,
String? targetPersonId,
String? sourceFingerprint,
CapturedFactDraft? draft,
}) async {
final LocalDataState current = _requireState();
return _queueSharedInbox(
current: current,
payload: payload,
sourceFingerprint:
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
normalizedDisplayName:
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName),
reason: reason,
candidateProfileIds: candidateProfileIds,
targetPersonId: targetPersonId,
draft: draft,
);
}
Future<SharedMessageIngestResult> captureSharedPayloadToExistingProfile({
required SharedPayload payload,
required String profileId,
CapturedFactDraft? draft,
bool resolvedAutomatically = false,
String? consumedInboxEntryId,
String? normalizedDisplayName,
String? sourceFingerprint,
}) async {
final LocalDataState current = _requireState();
final PersonProfile? resolvedPerson = _findPersonById(
current.people,
profileId,
);
if (resolvedPerson == null) {
throw ArgumentError('Profile not found');
}
final String effectiveNormalizedDisplayName =
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName);
final String? effectiveSourceFingerprint =
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
);
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: payload.sourceApp,
sourceFingerprint: effectiveSourceFingerprint,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
normalizedDisplayName: effectiveNormalizedDisplayName,
);
return _ingestIntoResolvedProfile(
current: current,
importedAt: DateTime.now(),
payload: payload,
sourceFingerprint: effectiveSourceFingerprint,
normalizedDisplayName: effectiveNormalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: false,
people: current.people,
resolvedAutomatically: resolvedAutomatically,
consumedInboxEntryId: consumedInboxEntryId,
draft: draft ?? _defaultDraftForPayload(payload),
);
}
Future<SharedMessageIngestResult> captureSharedPayloadByCreatingProfile({
required SharedPayload payload,
String? name,
String relationship = 'Contact',
String? location,
List<String> aliases = const <String>[],
CapturedFactDraft? draft,
bool resolvedAutomatically = false,
String? consumedInboxEntryId,
String? normalizedDisplayName,
String? sourceFingerprint,
}) async {
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final String profileName =
_trimToNull(name) ??
_deriveAutoProfileName(
sourceDisplayName: payload.sourceDisplayName,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
) ??
'New Contact';
final String normalizedRelationship = relationship.trim().isEmpty
? 'Contact'
: relationship.trim();
final PersonProfile createdPerson = PersonProfile(
id: 'p-${_uuid.v4()}',
name: profileName,
relationship: normalizedRelationship,
affinityScore: 70,
nextMoment: now.add(const Duration(days: 2)),
tags: <String>[
if (payload.sourceApp.trim().isNotEmpty) payload.sourceApp.trim(),
],
notes: 'Created from shared capture.',
aliases: _normalizeAliases(<String>[
...aliases,
if (payload.sourceDisplayName != null) payload.sourceDisplayName!,
]),
location: _trimToNull(location),
lastUpdatedAt: now,
lastInteractedAt: now,
);
return _ingestIntoResolvedProfile(
current: current,
importedAt: now,
payload: payload,
sourceFingerprint:
sourceFingerprint ??
_buildStableSourceFingerprint(
sourceApp: payload.sourceApp,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
normalizedDisplayName:
normalizedDisplayName ??
_normalizeDisplayName(payload.sourceDisplayName),
resolvedPerson: createdPerson,
matchedLink: null,
createdProfile: true,
people: <PersonProfile>[createdPerson, ...current.people],
resolvedAutomatically: resolvedAutomatically,
consumedInboxEntryId: consumedInboxEntryId,
draft: draft ?? _defaultDraftForPayload(payload),
);
}
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<SharedMessageIngestResult> _queueSharedInbox({
required LocalDataState current,
required SharedPayload payload,
required SharedInboxReason reason,
required List<String> candidateProfileIds,
required String normalizedDisplayName,
String? sourceFingerprint,
String? targetPersonId,
CapturedFactDraft? draft,
}) async {
final SharedPayload storedPayload = payload.copyWith(
rawText: _truncate(payload.rawText, 1800),
dedupeKey:
payload.dedupeKey ??
_dedupeKeyForShare(
sourceApp: payload.sourceApp,
rawText: payload.rawText,
url: payload.url,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
);
final SharedInboxEntry entry = SharedInboxEntry(
id: 'si-${_uuid.v4()}',
payload: storedPayload,
reason: reason,
candidateProfileIds: candidateProfileIds,
normalizedDisplayName: normalizedDisplayName,
assignmentStatus: ShareAssignmentStatus.savedToInbox,
targetPersonId: targetPersonId,
sourceFingerprint: sourceFingerprint,
draft: draft ?? _defaultDraftForPayload(storedPayload),
);
await _setState(
current.copyWith(
sharedInbox: <SharedInboxEntry>[entry, ...current.sharedInbox],
),
);
return SharedMessageIngestResult.queuedForResolution(
inboxEntryId: entry.id,
);
}
Future<SharedMessageIngestResult> _ingestIntoResolvedProfile({
required LocalDataState current,
required SharedPayload payload,
required DateTime importedAt,
required String normalizedDisplayName,
required String? sourceFingerprint,
required PersonProfile resolvedPerson,
required bool createdProfile,
required List<PersonProfile> people,
required bool resolvedAutomatically,
required CapturedFactDraft draft,
SourceProfileLink? matchedLink,
String? consumedInboxEntryId,
}) async {
final String sourceApp = payload.sourceApp;
final DateTime sharedAt = payload.createdAt;
final String? sourceUserId = payload.sourceUserId;
final String? sourceThreadId = payload.sourceThreadId;
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 = _truncate(payload.rawText, 1800);
final RelationshipMoment moment = RelationshipMoment(
id: 'm-${_uuid.v4()}',
personId: resolvedPerson.id,
title: _titleFromSummary(summary),
summary: summary,
at: sharedAt,
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
);
final SharedMessageEntry sharedEntry = SharedMessageEntry(
id: 'sm-${_uuid.v4()}',
profileId: resolvedPerson.id,
payload: payload.copyWith(
rawText: summary,
dedupeKey:
payload.dedupeKey ??
_dedupeKeyForShare(
sourceApp: payload.sourceApp,
rawText: payload.rawText,
url: payload.url,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
),
),
importedAt: importedAt,
resolvedAutomatically: resolvedAutomatically,
assignmentStatus: ShareAssignmentStatus.assignedDirectly,
draft: draft,
);
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
? current.sharedInbox
: current.sharedInbox
.where(
(SharedInboxEntry entry) => entry.id != consumedInboxEntryId,
)
.toList(growable: false);
await _setState(
current.copyWith(
people: _touchPeople(
people,
personId: resolvedPerson.id,
updatedAt: importedAt,
interactedAt: sharedAt,
),
moments: <RelationshipMoment>[moment, ...current.moments],
sourceLinks: nextLinks,
sharedMessages: <SharedMessageEntry>[
sharedEntry,
...current.sharedMessages,
],
sharedInbox: nextInbox,
personFacts: _applyFactDraftToFacts(
current.personFacts,
personId: resolvedPerson.id,
sharedEntry: sharedEntry,
draft: draft,
consumedInboxEntryId: consumedInboxEntryId,
observedAt: sharedAt,
),
importantDates: _applyFactDraftToDates(
current.importantDates,
personId: resolvedPerson.id,
draft: draft,
sourceApp: payload.sourceApp,
consumedInboxEntryId: consumedInboxEntryId,
observedAt: sharedAt,
),
),
);
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);
final List<ExtractedPreferenceSignalCandidate> extractedSignals =
_chatPreferenceExtractor.extract(summary);
for (final ExtractedPreferenceSignalCandidate candidate
in extractedSignals) {
await upsertInferredPreferenceSignalObservation(
personId: resolvedPerson.id,
key: candidate.key,
label: candidate.label,
category: candidate.category,
polarity: candidate.polarity,
confidence: candidate.confidence,
sourceApp: sourceApp,
evidenceMessageId: sharedEntry.id,
evidenceSnippet: candidate.evidenceSnippet,
observedAt: sharedAt,
);
}
return SharedMessageIngestResult.imported(
profileId: resolvedPerson.id,
profileName: resolvedPerson.name,
createdProfile: createdProfile,
createdSourceLink: createdSourceLink,
momentId: moment.id,
);
}
}
CapturedFactDraft _defaultDraftForPayload(SharedPayload payload) {
return ShareCaptureDraftSuggester.suggestForPayload(
payload,
).copyWith(needsReview: true);
}
String _dedupeKeyForShare({
required String sourceApp,
required String rawText,
required String? url,
required String? sourceUserId,
required String? sourceThreadId,
}) {
final String text = rawText.trim().toLowerCase().replaceAll(
RegExp(r'\s+'),
' ',
);
return <String>[
sourceApp.trim().toLowerCase(),
sourceUserId?.trim().toLowerCase() ?? '',
sourceThreadId?.trim().toLowerCase() ?? '',
url?.trim().toLowerCase() ?? '',
text,
].join('|');
}
List<PersonFact> _applyFactDraftToFacts(
List<PersonFact> currentFacts, {
required String personId,
required SharedMessageEntry sharedEntry,
required CapturedFactDraft draft,
required String? consumedInboxEntryId,
required DateTime observedAt,
}) {
if (draft.type == CapturedFactType.importantDate ||
draft.text.trim().isEmpty) {
return currentFacts;
}
final PersonFact fact = PersonFact(
id: 'pf-${_uuid.v4()}',
personId: personId,
type: draft.type,
text: draft.text.trim(),
label: _trimToNull(draft.label),
sourceKind: consumedInboxEntryId == null
? CaptureSourceKind.shareSheet
: CaptureSourceKind.shareInbox,
sourceApp: sharedEntry.sourceApp,
sourceUrl: sharedEntry.url,
sharedMessageId: sharedEntry.id,
inboxEntryId: consumedInboxEntryId,
confidence: draft.confidence,
needsReview: draft.needsReview,
isSensitive: draft.isSensitive,
createdAt: observedAt,
updatedAt: observedAt,
);
return <PersonFact>[fact, ...currentFacts];
}
List<PersonImportantDate> _applyFactDraftToDates(
List<PersonImportantDate> currentDates, {
required String personId,
required CapturedFactDraft draft,
required String sourceApp,
required String? consumedInboxEntryId,
required DateTime observedAt,
}) {
if (draft.type != CapturedFactType.importantDate || draft.dateValue == null) {
return currentDates;
}
final PersonImportantDate value = PersonImportantDate(
id: 'pd-${_uuid.v4()}',
personId: personId,
label: _trimToNull(draft.label) ?? draft.text.trim(),
date: draft.dateValue!,
classification: 'important',
sourceKind: consumedInboxEntryId == null
? CaptureSourceKind.shareSheet
: CaptureSourceKind.shareInbox,
sourceApp: sourceApp,
inboxEntryId: consumedInboxEntryId,
isSensitive: draft.isSensitive,
createdAt: observedAt,
updatedAt: observedAt,
);
return <PersonImportantDate>[value, ...currentDates];
}
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;
}
List<PersonProfile> _findPeopleByNormalizedName(
List<PersonProfile> people,
String normalizedDisplayName,
) {
return people
.where((PersonProfile person) {
if (_normalizeDisplayName(person.name) == normalizedDisplayName) {
return true;
}
for (final String alias in person.aliases) {
if (_normalizeDisplayName(alias) == normalizedDisplayName) {
return true;
}
}
return false;
})
.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 List<String> probes = <String>[person.name, ...person.aliases];
for (final String probe in probes) {
final String personName = _normalizeDisplayName(probe);
if (personName.isEmpty || personName == normalizedDisplayName) {
continue;
}
final int distance = _levenshteinDistance(
normalizedDisplayName,
personName,
);
if (_isNearDisplayName(
normalizedDisplayName,
personName,
distance: distance,
)) {
distanceById[person.id] = distance;
if (!candidates.any((PersonProfile item) => item.id == person.id)) {
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+'), ' ');
}
@@ -0,0 +1,486 @@
part of 'relationship_repository.dart';
/// Sync queue integration and change-envelope serialization helpers.
extension LocalRepositorySyncOperations on LocalRepository {
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),
personFacts: current.personFacts
.where((PersonFact fact) => fact.personId != personId)
.toList(growable: false),
aiSuggestionDrafts: current.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId != personId)
.toList(growable: false),
importantDates: current.importantDates
.where((PersonImportantDate value) => value.personId != personId)
.toList(growable: false),
sourceLinks: current.sourceLinks
.where((SourceProfileLink link) => link.profileId != personId)
.toList(growable: false),
sharedMessages: current.sharedMessages
.where((SharedMessageEntry entry) => entry.profileId != 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 ?? ''),
aliases: _stringListField(
payload,
'aliases',
existing?.aliases ?? <String>[],
),
location: _stringOrNullField(payload, 'location', existing?.location),
lastUpdatedAt: _dateOrNullField(
payload,
'lastUpdatedAt',
existing?.lastUpdatedAt,
),
lastInteractedAt: _dateOrNullField(
payload,
'lastInteractedAt',
existing?.lastInteractedAt,
),
);
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);
}
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,
'aliases': person.aliases,
'location': person.location,
'lastUpdatedAt': person.lastUpdatedAt?.toUtc().toIso8601String(),
'lastInteractedAt': person.lastInteractedAt?.toUtc().toIso8601String(),
};
}
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;
}
DateTime? _dateOrNullField(
Map<String, dynamic> payload,
String key,
DateTime? fallback,
) {
final dynamic value = payload[key];
if (value is String) {
final DateTime? parsed = DateTime.tryParse(value);
return parsed?.toLocal();
}
return fallback;
}
+7
View File
@@ -0,0 +1,7 @@
# App Storage
This folder owns local persistence adapters for the aggregate app store.
Use these files when you need to change how the local snapshot is stored. The
contract is `local_data_store.dart`; Hive and shared-preferences adapters are
swap-in implementations behind the provider.
@@ -0,0 +1,19 @@
/// Raw local state payload and associated schema version.
class LocalDataRecord {
const LocalDataRecord({required this.rawState, required this.schemaVersion});
final String rawState;
final int schemaVersion;
}
/// Persistence boundary for local app state.
abstract interface class LocalDataStore {
/// Reads latest persisted state payload.
Future<LocalDataRecord?> read();
/// Writes state payload and schema version.
Future<void> write({required String rawState, required int schemaVersion});
/// Clears persisted state payload.
Future<void> clear();
}
@@ -0,0 +1,54 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
/// Hive-backed local store for future DB migration path.
class HiveLocalDataStore implements LocalDataStore {
static const String _boxName = 'relationship_saver_local';
static const String _stateKey = 'state_json';
static const String _schemaVersionKey = 'schema_version';
static bool _initialized = false;
@override
Future<void> clear() async {
final Box<dynamic> box = await _openBox();
await box.delete(_stateKey);
await box.delete(_schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final Box<dynamic> box = await _openBox();
final String? rawState = box.get(_stateKey) as String?;
if (rawState == null || rawState.isEmpty) {
return null;
}
final int schemaVersion =
(box.get(_schemaVersionKey) as num?)?.toInt() ?? 0;
return LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final Box<dynamic> box = await _openBox();
await box.put(_stateKey, rawState);
await box.put(_schemaVersionKey, schemaVersion);
}
Future<Box<dynamic>> _openBox() async {
if (!_initialized) {
await Hive.initFlutter();
_initialized = true;
}
if (!Hive.isBoxOpen(_boxName)) {
return Hive.openBox<dynamic>(_boxName);
}
return Hive.box<dynamic>(_boxName);
}
}
@@ -0,0 +1,22 @@
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
/// In-memory local store for deterministic tests.
class InMemoryLocalDataStore implements LocalDataStore {
LocalDataRecord? _record;
@override
Future<void> clear() async {
_record = null;
}
@override
Future<LocalDataRecord?> read() async => _record;
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
_record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
}
@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
import 'package:relationship_saver/core/config/app_config.dart';
/// Chooses local persistence backend for app state.
final Provider<LocalDataStore> localDataStoreProvider =
Provider<LocalDataStore>((Ref ref) {
if (AppConfig.useHiveLocalDb) {
return HiveLocalDataStore();
}
return SharedPrefsLocalDataStore();
});
@@ -0,0 +1,44 @@
import 'package:relationship_saver/app/data/storage/local_data_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// shared_preferences-backed local store used as stable default.
class SharedPrefsLocalDataStore implements LocalDataStore {
SharedPrefsLocalDataStore({
this.stateKey = 'local_data_state_v1',
this.schemaVersionKey = 'local_data_schema_version',
});
final String stateKey;
final String schemaVersionKey;
@override
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(stateKey);
await prefs.remove(schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? rawState = prefs.getString(stateKey);
if (rawState == null || rawState.isEmpty) {
return null;
}
return LocalDataRecord(
rawState: rawState,
schemaVersion: prefs.getInt(schemaVersionKey) ?? 0,
);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(stateKey, rawState);
await prefs.setInt(schemaVersionKey, schemaVersion);
}
}