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:
@@ -0,0 +1,12 @@
|
||||
# App Layer
|
||||
|
||||
`lib/app/` holds app-level code that is not one product slice:
|
||||
|
||||
- `relationship_saver_app.dart`: root widget and global listeners.
|
||||
- `navigation/`: typed shell destinations.
|
||||
- `presentation/`: responsive app shell.
|
||||
- `data/`: aggregate prototype repository and persistence plumbing.
|
||||
- `state/`: shared aggregate snapshot used by the current local-first store.
|
||||
|
||||
This is the place to change boot flow, shell behavior, or prototype-wide state
|
||||
composition.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# App Navigation
|
||||
|
||||
This folder contains typed shell destinations used by the main app shell.
|
||||
|
||||
Change `app_destination.dart` when you add, remove, or rename a top-level tab.
|
||||
The shell in `../presentation/` maps these destinations to screens.
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Typed shell destinations used by the app navigation shell.
|
||||
enum AppDestination {
|
||||
dashboard('Dashboard', Icons.dashboard_rounded),
|
||||
people('People', Icons.people_alt_rounded),
|
||||
moments('Moments', Icons.auto_awesome_rounded),
|
||||
signals('Signals', Icons.tips_and_updates_rounded),
|
||||
aiReview('AI Review', Icons.rate_review_rounded),
|
||||
ideas('Ideas', Icons.lightbulb_outline_rounded),
|
||||
reminders('Reminders', Icons.notifications_active_outlined),
|
||||
sync('Sync', Icons.sync_rounded),
|
||||
settings('Settings', Icons.settings_rounded);
|
||||
|
||||
const AppDestination(this.label, this.icon);
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
/// Secondary destinations exposed from the compact shell overflow menu.
|
||||
enum CompactSecondaryDestination { aiReview, ideas, reminders, sync, settings }
|
||||
@@ -0,0 +1,7 @@
|
||||
# App Presentation
|
||||
|
||||
This folder owns the shared app shell UI.
|
||||
|
||||
`app_shell.dart` is intentionally the only place that knows how wide-layout and
|
||||
compact-layout navigation work. Feature screens should stay unaware of shell
|
||||
details and just render their slice.
|
||||
@@ -0,0 +1,381 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/app/navigation/app_destination.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
|
||||
import 'package:relationship_saver/features/dashboard/presentation/dashboard_view.dart';
|
||||
import 'package:relationship_saver/features/ideas/presentation/ideas_view.dart';
|
||||
import 'package:relationship_saver/features/moments/presentation/moments_view.dart';
|
||||
import 'package:relationship_saver/features/people/presentation/people_view.dart';
|
||||
import 'package:relationship_saver/features/reminders/presentation/reminders_view.dart';
|
||||
import 'package:relationship_saver/features/settings/presentation/settings_view.dart';
|
||||
import 'package:relationship_saver/features/signals/presentation/signals_view.dart';
|
||||
import 'package:relationship_saver/features/sync/presentation/sync_view.dart';
|
||||
|
||||
/// Responsive shell that hosts the main feature slices.
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
const AppShell({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends ConsumerState<AppShell> {
|
||||
AppDestination _destination = AppDestination.dashboard;
|
||||
|
||||
static const List<AppDestination> _destinations = AppDestination.values;
|
||||
static const List<AppDestination> _compactPrimaryDestinations =
|
||||
<AppDestination>[
|
||||
AppDestination.dashboard,
|
||||
AppDestination.people,
|
||||
AppDestination.moments,
|
||||
AppDestination.signals,
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
Color(0xFFF3F8FB),
|
||||
Color(0xFFEAF1F8),
|
||||
Color(0xFFF8FAFC),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
if (constraints.maxWidth < 1000) {
|
||||
return _buildCompactLayout(context);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
_Sidebar(
|
||||
selected: _destination,
|
||||
destinations: _destinations,
|
||||
onChange: _selectDestination,
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<AppDestination>(_destination),
|
||||
child: _screenFor(_destination),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactLayout(BuildContext context) {
|
||||
final int compactIndex = _compactSelectedIndex(_destination);
|
||||
final AppDestination active = _compactPrimaryDestinations[compactIndex];
|
||||
return _CompactShell(
|
||||
title: active.label,
|
||||
selected: active,
|
||||
body: _screenFor(active),
|
||||
destinations: _compactPrimaryDestinations,
|
||||
onChange: _selectDestination,
|
||||
onOpenSecondary: (CompactSecondaryDestination destination) {
|
||||
final AppDestination screenDestination = switch (destination) {
|
||||
CompactSecondaryDestination.ideas => AppDestination.ideas,
|
||||
CompactSecondaryDestination.aiReview => AppDestination.aiReview,
|
||||
CompactSecondaryDestination.reminders => AppDestination.reminders,
|
||||
CompactSecondaryDestination.sync => AppDestination.sync,
|
||||
CompactSecondaryDestination.settings => AppDestination.settings,
|
||||
};
|
||||
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) {
|
||||
return _CompactSecondaryScreen(
|
||||
title: screenDestination.label,
|
||||
child: _screenFor(screenDestination),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _selectDestination(AppDestination destination) {
|
||||
setState(() {
|
||||
_destination = destination;
|
||||
});
|
||||
}
|
||||
|
||||
int _compactSelectedIndex(AppDestination destination) {
|
||||
for (int i = 0; i < _compactPrimaryDestinations.length; i += 1) {
|
||||
if (_compactPrimaryDestinations[i] == destination) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Widget _screenFor(AppDestination destination) {
|
||||
switch (destination) {
|
||||
case AppDestination.dashboard:
|
||||
return const DashboardView();
|
||||
case AppDestination.people:
|
||||
return const PeopleView();
|
||||
case AppDestination.moments:
|
||||
return const MomentsView();
|
||||
case AppDestination.signals:
|
||||
return const SignalsView();
|
||||
case AppDestination.aiReview:
|
||||
return const AiDigestReviewView();
|
||||
case AppDestination.ideas:
|
||||
return const IdeasView();
|
||||
case AppDestination.reminders:
|
||||
return const RemindersView();
|
||||
case AppDestination.sync:
|
||||
return const SyncView();
|
||||
case AppDestination.settings:
|
||||
return const SettingsView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Sidebar extends StatelessWidget {
|
||||
const _Sidebar({
|
||||
required this.selected,
|
||||
required this.destinations,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
final AppDestination selected;
|
||||
final List<AppDestination> destinations;
|
||||
final ValueChanged<AppDestination> onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 290,
|
||||
margin: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
||||
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.78),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.7)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
gradient: const LinearGradient(
|
||||
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Relationship Saver',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
...destinations.map((AppDestination destination) {
|
||||
final bool isSelected = destination == selected;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: InkWell(
|
||||
onTap: () => onChange(destination),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFFEAF7FB)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
destination.icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? AppTheme.primary
|
||||
: AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
destination.label,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(
|
||||
color: isSelected
|
||||
? AppTheme.primary
|
||||
: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F8FB),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
'Tip: Use Signals daily and convert at least one into a concrete plan.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactShell extends StatelessWidget {
|
||||
const _CompactShell({
|
||||
required this.title,
|
||||
required this.selected,
|
||||
required this.body,
|
||||
required this.destinations,
|
||||
required this.onChange,
|
||||
required this.onOpenSecondary,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final AppDestination selected;
|
||||
final Widget body;
|
||||
final List<AppDestination> destinations;
|
||||
final ValueChanged<AppDestination> onChange;
|
||||
final ValueChanged<CompactSecondaryDestination> onOpenSecondary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
PopupMenuButton<CompactSecondaryDestination>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_horiz_rounded),
|
||||
onSelected: onOpenSecondary,
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<CompactSecondaryDestination>>[
|
||||
const PopupMenuItem<CompactSecondaryDestination>(
|
||||
value: CompactSecondaryDestination.aiReview,
|
||||
child: Text('AI Review'),
|
||||
),
|
||||
const PopupMenuItem<CompactSecondaryDestination>(
|
||||
value: CompactSecondaryDestination.ideas,
|
||||
child: Text('Ideas'),
|
||||
),
|
||||
const PopupMenuItem<CompactSecondaryDestination>(
|
||||
value: CompactSecondaryDestination.reminders,
|
||||
child: Text('Reminders'),
|
||||
),
|
||||
const PopupMenuItem<CompactSecondaryDestination>(
|
||||
value: CompactSecondaryDestination.sync,
|
||||
child: Text('Sync'),
|
||||
),
|
||||
const PopupMenuItem<CompactSecondaryDestination>(
|
||||
value: CompactSecondaryDestination.settings,
|
||||
child: Text('Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<AppDestination>(selected),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationBar(
|
||||
selectedIndex: destinations.indexOf(selected),
|
||||
onDestinationSelected: (int index) => onChange(destinations[index]),
|
||||
destinations: destinations
|
||||
.map(
|
||||
(AppDestination destination) => NavigationDestination(
|
||||
icon: Icon(destination.icon),
|
||||
label: destination.label,
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactSecondaryScreen extends StatelessWidget {
|
||||
const _CompactSecondaryScreen({required this.title, required this.child});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[Color(0xFFF3F8FB), Color(0xFFEAF1F8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/app/presentation/app_shell.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/core/llm/llm_config.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart';
|
||||
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_notification_listener.dart';
|
||||
import 'package:relationship_saver/features/auth/application/session_controller.dart';
|
||||
import 'package:relationship_saver/features/auth/presentation/sign_in_view.dart';
|
||||
import 'package:relationship_saver/features/reminders/application/reminder_notification_listener.dart';
|
||||
import 'package:relationship_saver/features/share_intake/presentation/incoming_share_listener.dart';
|
||||
import 'package:relationship_saver/features/sync/application/sync_background_runner.dart';
|
||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||
|
||||
/// Root app widget that wires auth, shell entry, and global listeners.
|
||||
class RelationshipSaverApp extends ConsumerStatefulWidget {
|
||||
const RelationshipSaverApp({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RelationshipSaverApp> createState() =>
|
||||
_RelationshipSaverAppState();
|
||||
}
|
||||
|
||||
class _RelationshipSaverAppState extends ConsumerState<RelationshipSaverApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(ref.read(llmConfigProvider.notifier).initialize());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final WidgetRef ref = this.ref;
|
||||
final AsyncValue<AuthSession?> sessionState = ref.watch(
|
||||
sessionControllerProvider,
|
||||
);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Relationship Saver',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: sessionState.when(
|
||||
data: (AuthSession? session) => session == null
|
||||
? const SignInView()
|
||||
: const IncomingShareListener(
|
||||
child: ReminderNotificationListener(
|
||||
child: AiDigestNotificationListener(
|
||||
child: LlmDigestBackgroundRunner(
|
||||
child: SyncBackgroundRunner(child: AppShell()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (Object error, StackTrace stackTrace) => const SignInView(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# App State
|
||||
|
||||
`local_data_state.dart` is the aggregate snapshot that the prototype persists.
|
||||
|
||||
Treat it as composition, not as a domain home. New models should usually be
|
||||
added in a feature slice first, then referenced from the aggregate state if the
|
||||
prototype store needs them persisted.
|
||||
@@ -0,0 +1,380 @@
|
||||
// ignore_for_file: sort_constructors_first
|
||||
|
||||
import 'package:flutter/foundation.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/moments/domain/moment_models.dart';
|
||||
import 'package:relationship_saver/features/people/domain/person_models.dart';
|
||||
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
|
||||
|
||||
/// Aggregate local snapshot used by the prototype app store.
|
||||
///
|
||||
/// During this prototyping phase the app still uses one persisted snapshot for
|
||||
/// most product state. The types inside it are now feature-owned, so each slice
|
||||
/// can evolve without hiding its models in a horizontal "local" module.
|
||||
@immutable
|
||||
class LocalDataState {
|
||||
const LocalDataState({
|
||||
required this.people,
|
||||
required this.moments,
|
||||
required this.ideas,
|
||||
required this.reminders,
|
||||
required this.tasks,
|
||||
this.dismissedSignalIds = const <String>[],
|
||||
this.sourceLinks = const <SourceProfileLink>[],
|
||||
this.sharedMessages = const <SharedMessageEntry>[],
|
||||
this.sharedInbox = const <SharedInboxEntry>[],
|
||||
this.personFacts = const <PersonFact>[],
|
||||
this.importantDates = const <PersonImportantDate>[],
|
||||
this.preferenceSignals = const <PersonPreferenceSignal>[],
|
||||
this.aiSuggestionDrafts = const <AiSuggestionDraft>[],
|
||||
});
|
||||
|
||||
final List<PersonProfile> people;
|
||||
final List<RelationshipMoment> moments;
|
||||
final List<RelationshipIdea> ideas;
|
||||
final List<ReminderRule> reminders;
|
||||
final List<DashboardTask> tasks;
|
||||
final List<String> dismissedSignalIds;
|
||||
final List<SourceProfileLink> sourceLinks;
|
||||
final List<SharedMessageEntry> sharedMessages;
|
||||
final List<SharedInboxEntry> sharedInbox;
|
||||
final List<PersonFact> personFacts;
|
||||
final List<PersonImportantDate> importantDates;
|
||||
final List<PersonPreferenceSignal> preferenceSignals;
|
||||
final List<AiSuggestionDraft> aiSuggestionDrafts;
|
||||
|
||||
LocalDataState copyWith({
|
||||
List<PersonProfile>? people,
|
||||
List<RelationshipMoment>? moments,
|
||||
List<RelationshipIdea>? ideas,
|
||||
List<ReminderRule>? reminders,
|
||||
List<DashboardTask>? tasks,
|
||||
List<String>? dismissedSignalIds,
|
||||
List<SourceProfileLink>? sourceLinks,
|
||||
List<SharedMessageEntry>? sharedMessages,
|
||||
List<SharedInboxEntry>? sharedInbox,
|
||||
List<PersonFact>? personFacts,
|
||||
List<PersonImportantDate>? importantDates,
|
||||
List<PersonPreferenceSignal>? preferenceSignals,
|
||||
List<AiSuggestionDraft>? aiSuggestionDrafts,
|
||||
}) {
|
||||
return LocalDataState(
|
||||
people: people ?? this.people,
|
||||
moments: moments ?? this.moments,
|
||||
ideas: ideas ?? this.ideas,
|
||||
reminders: reminders ?? this.reminders,
|
||||
tasks: tasks ?? this.tasks,
|
||||
dismissedSignalIds: dismissedSignalIds ?? this.dismissedSignalIds,
|
||||
sourceLinks: sourceLinks ?? this.sourceLinks,
|
||||
sharedMessages: sharedMessages ?? this.sharedMessages,
|
||||
sharedInbox: sharedInbox ?? this.sharedInbox,
|
||||
personFacts: personFacts ?? this.personFacts,
|
||||
importantDates: importantDates ?? this.importantDates,
|
||||
preferenceSignals: preferenceSignals ?? this.preferenceSignals,
|
||||
aiSuggestionDrafts: aiSuggestionDrafts ?? this.aiSuggestionDrafts,
|
||||
);
|
||||
}
|
||||
|
||||
DashboardSummary summary({DateTime? now}) {
|
||||
final DateTime baseline = now ?? DateTime.now();
|
||||
return DashboardSummary(
|
||||
activePeople: people.length,
|
||||
upcomingPlans: people
|
||||
.where((PersonProfile p) => p.nextMoment.isAfter(baseline))
|
||||
.length,
|
||||
pendingIdeas: ideas
|
||||
.where((RelationshipIdea idea) => !idea.isArchived)
|
||||
.length,
|
||||
weeklyConsistency: moments.isEmpty
|
||||
? 0
|
||||
: (70 + moments.length * 4).clamp(0, 100),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'people': people
|
||||
.map((PersonProfile person) => person.toJson())
|
||||
.toList(growable: false),
|
||||
'moments': moments
|
||||
.map((RelationshipMoment moment) => moment.toJson())
|
||||
.toList(growable: false),
|
||||
'ideas': ideas
|
||||
.map((RelationshipIdea idea) => idea.toJson())
|
||||
.toList(growable: false),
|
||||
'reminders': reminders
|
||||
.map((ReminderRule reminder) => reminder.toJson())
|
||||
.toList(growable: false),
|
||||
'tasks': tasks
|
||||
.map((DashboardTask task) => task.toJson())
|
||||
.toList(growable: false),
|
||||
'dismissedSignalIds': dismissedSignalIds,
|
||||
'sourceLinks': sourceLinks
|
||||
.map((SourceProfileLink link) => link.toJson())
|
||||
.toList(growable: false),
|
||||
'sharedMessages': sharedMessages
|
||||
.map((SharedMessageEntry entry) => entry.toJson())
|
||||
.toList(growable: false),
|
||||
'sharedInbox': sharedInbox
|
||||
.map((SharedInboxEntry entry) => entry.toJson())
|
||||
.toList(growable: false),
|
||||
'personFacts': personFacts
|
||||
.map((PersonFact fact) => fact.toJson())
|
||||
.toList(growable: false),
|
||||
'importantDates': importantDates
|
||||
.map((PersonImportantDate value) => value.toJson())
|
||||
.toList(growable: false),
|
||||
'preferenceSignals': preferenceSignals
|
||||
.map((PersonPreferenceSignal signal) => signal.toJson())
|
||||
.toList(growable: false),
|
||||
'aiSuggestionDrafts': aiSuggestionDrafts
|
||||
.map((AiSuggestionDraft draft) => draft.toJson())
|
||||
.toList(growable: false),
|
||||
};
|
||||
}
|
||||
|
||||
factory LocalDataState.fromJson(Map<String, dynamic> json) {
|
||||
return LocalDataState(
|
||||
people: (json['people'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic person) =>
|
||||
PersonProfile.fromJson(person as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
moments: (json['moments'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic moment) =>
|
||||
RelationshipMoment.fromJson(moment as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
ideas: (json['ideas'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic idea) =>
|
||||
RelationshipIdea.fromJson(idea as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
reminders: (json['reminders'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic reminder) =>
|
||||
ReminderRule.fromJson(reminder as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
tasks: (json['tasks'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic task) =>
|
||||
DashboardTask.fromJson(task as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
dismissedSignalIds:
|
||||
(json['dismissedSignalIds'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map((dynamic value) => '$value')
|
||||
.toList(growable: false),
|
||||
sourceLinks: (json['sourceLinks'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic link) =>
|
||||
SourceProfileLink.fromJson(link as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
sharedMessages: (json['sharedMessages'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic entry) =>
|
||||
SharedMessageEntry.fromJson(entry as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
sharedInbox: (json['sharedInbox'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic entry) =>
|
||||
SharedInboxEntry.fromJson(entry as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
personFacts: (json['personFacts'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic fact) => PersonFact.fromJson(fact as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
importantDates: (json['importantDates'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic value) =>
|
||||
PersonImportantDate.fromJson(value as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
preferenceSignals:
|
||||
(json['preferenceSignals'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic signal) => PersonPreferenceSignal.fromJson(
|
||||
signal as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
aiSuggestionDrafts:
|
||||
(json['aiSuggestionDrafts'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic draft) =>
|
||||
AiSuggestionDraft.fromJson(draft as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
static LocalDataState seed() {
|
||||
return LocalDataState(
|
||||
people: <PersonProfile>[
|
||||
PersonProfile(
|
||||
id: 'p-ava',
|
||||
name: 'Ava Hart',
|
||||
relationship: 'Partner',
|
||||
affinityScore: 92,
|
||||
nextMoment: DateTime(2026, 2, 16, 19, 30),
|
||||
tags: <String>['coffee', 'books', 'slow mornings'],
|
||||
notes: 'Prefers thoughtful plans over expensive plans.',
|
||||
aliases: <String>['Aves'],
|
||||
location: 'Austin, TX',
|
||||
lastUpdatedAt: DateTime(2026, 2, 14, 9, 0),
|
||||
lastInteractedAt: DateTime(2026, 2, 12, 18, 30),
|
||||
),
|
||||
PersonProfile(
|
||||
id: 'p-jordan',
|
||||
name: 'Jordan Lee',
|
||||
relationship: 'Close Friend',
|
||||
affinityScore: 78,
|
||||
nextMoment: DateTime(2026, 2, 18, 18, 0),
|
||||
tags: <String>['running', 'street food'],
|
||||
notes: 'Has a race on Sunday; ask how training is going.',
|
||||
location: 'Chicago, IL',
|
||||
lastUpdatedAt: DateTime(2026, 2, 13, 8, 30),
|
||||
lastInteractedAt: DateTime(2026, 2, 11, 19, 0),
|
||||
),
|
||||
PersonProfile(
|
||||
id: 'p-mila',
|
||||
name: 'Mila Stone',
|
||||
relationship: 'Sister',
|
||||
affinityScore: 84,
|
||||
nextMoment: DateTime(2026, 2, 20, 12, 0),
|
||||
tags: <String>['plants', 'retro music'],
|
||||
notes: 'Birthday prep should start this week.',
|
||||
aliases: <String>['Millie'],
|
||||
location: 'Seattle, WA',
|
||||
lastUpdatedAt: DateTime(2026, 2, 13, 14, 20),
|
||||
lastInteractedAt: DateTime(2026, 2, 12, 14, 20),
|
||||
),
|
||||
],
|
||||
moments: <RelationshipMoment>[
|
||||
RelationshipMoment(
|
||||
id: 'm-1',
|
||||
personId: 'p-ava',
|
||||
title: 'Sunday Walk Tradition',
|
||||
summary: 'Short walk + no-phone hour worked really well.',
|
||||
at: DateTime(2026, 2, 9, 9, 30),
|
||||
type: 'ritual',
|
||||
),
|
||||
RelationshipMoment(
|
||||
id: 'm-2',
|
||||
personId: 'p-jordan',
|
||||
title: 'Post-workout Check-in',
|
||||
summary: 'Shared training playlist and grabbed smoothies.',
|
||||
at: DateTime(2026, 2, 11, 19, 0),
|
||||
type: 'support',
|
||||
),
|
||||
RelationshipMoment(
|
||||
id: 'm-3',
|
||||
personId: 'p-mila',
|
||||
title: 'Gift Idea Captured',
|
||||
summary: 'Found a vintage lamp from her saved style board.',
|
||||
at: DateTime(2026, 2, 12, 14, 20),
|
||||
type: 'gift',
|
||||
),
|
||||
],
|
||||
ideas: <RelationshipIdea>[
|
||||
RelationshipIdea(
|
||||
id: 'i-1',
|
||||
personId: 'p-ava',
|
||||
type: IdeaType.gift,
|
||||
title: 'Handwritten recipe journal',
|
||||
details: 'Collect 10 memories and recipes from this year.',
|
||||
createdAt: DateTime(2026, 2, 12, 9),
|
||||
),
|
||||
RelationshipIdea(
|
||||
id: 'i-2',
|
||||
personId: 'p-mila',
|
||||
type: IdeaType.event,
|
||||
title: 'Plant market + brunch date',
|
||||
details: 'Saturday morning slot; book nearby cafe in advance.',
|
||||
createdAt: DateTime(2026, 2, 13, 11),
|
||||
),
|
||||
],
|
||||
reminders: <ReminderRule>[
|
||||
ReminderRule(
|
||||
id: 'r-1',
|
||||
personId: 'p-jordan',
|
||||
title: 'Weekly check-in message',
|
||||
cadence: ReminderCadence.weekly,
|
||||
nextAt: DateTime(2026, 2, 18, 18),
|
||||
),
|
||||
ReminderRule(
|
||||
id: 'r-2',
|
||||
personId: 'p-ava',
|
||||
title: 'Sunday ritual planning',
|
||||
cadence: ReminderCadence.weekly,
|
||||
nextAt: DateTime(2026, 2, 16, 10),
|
||||
),
|
||||
],
|
||||
tasks: <DashboardTask>[
|
||||
DashboardTask(
|
||||
id: 't-1',
|
||||
title: 'Plan Friday dinner with Ava',
|
||||
description: 'Keep it low-key: ramen + bookstore stop.',
|
||||
when: DateTime(2026, 2, 16, 17, 0),
|
||||
),
|
||||
DashboardTask(
|
||||
id: 't-2',
|
||||
title: 'Send race-day encouragement to Jordan',
|
||||
description: 'Voice note before 8:00 AM.',
|
||||
when: DateTime(2026, 2, 17, 7, 30),
|
||||
),
|
||||
DashboardTask(
|
||||
id: 't-3',
|
||||
title: 'Finalize Mila birthday shortlist',
|
||||
description: 'Pick 1 meaningful gift and 1 backup.',
|
||||
when: DateTime(2026, 2, 18, 20, 0),
|
||||
),
|
||||
],
|
||||
personFacts: <PersonFact>[
|
||||
PersonFact(
|
||||
id: 'pf-1',
|
||||
personId: 'p-ava',
|
||||
type: CapturedFactType.like,
|
||||
text: 'Quiet brunch spots and recipe-book stores',
|
||||
label: 'Weekend preference',
|
||||
sourceKind: CaptureSourceKind.manual,
|
||||
createdAt: DateTime(2026, 2, 10, 8, 0),
|
||||
updatedAt: DateTime(2026, 2, 10, 8, 0),
|
||||
),
|
||||
PersonFact(
|
||||
id: 'pf-2',
|
||||
personId: 'p-mila',
|
||||
type: CapturedFactType.giftIdea,
|
||||
text: 'Vintage ceramic planter in muted green',
|
||||
label: 'Gift lead',
|
||||
sourceKind: CaptureSourceKind.manual,
|
||||
createdAt: DateTime(2026, 2, 12, 14, 25),
|
||||
updatedAt: DateTime(2026, 2, 12, 14, 25),
|
||||
),
|
||||
],
|
||||
importantDates: <PersonImportantDate>[
|
||||
PersonImportantDate(
|
||||
id: 'pd-1',
|
||||
personId: 'p-mila',
|
||||
label: 'Birthday',
|
||||
date: DateTime(2026, 3, 3),
|
||||
classification: 'birthday',
|
||||
sourceKind: CaptureSourceKind.manual,
|
||||
createdAt: DateTime(2026, 2, 12, 14, 30),
|
||||
updatedAt: DateTime(2026, 2, 12, 14, 30),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user