part of 'relationship_repository.dart'; /// Capture, idea, reminder, and lightweight dashboard state persistence. extension LocalRepositoryActivityOperations on LocalRepository { Future 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: [moment, ...current.moments], ), ); await _enqueueChange( _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.upsert, payload: _momentPayload(moment), ), ); } Future 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: [fact, ...current.personFacts], people: _touchPeople( current.people, personId: personId, updatedAt: now, interactedAt: now, ), ), ); } Future updatePersonFact(PersonFact fact) async { final LocalDataState current = _requireState(); final DateTime now = DateTime.now(); final List 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 deletePersonFact(String factId) async { final LocalDataState current = _requireState(); final List updated = current.personFacts .where((PersonFact item) => item.id != factId) .toList(growable: false); await _setState(current.copyWith(personFacts: updated)); } Future 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: [ importantDate, ...current.importantDates, ], people: _touchPeople( current.people, personId: personId, updatedAt: now, interactedAt: now, ), ), ); } Future updateImportantDate(PersonImportantDate value) async { final LocalDataState current = _requireState(); final DateTime now = DateTime.now(); final List 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 deleteImportantDate(String dateId) async { final LocalDataState current = _requireState(); final List updated = current.importantDates .where((PersonImportantDate item) => item.id != dateId) .toList(growable: false); await _setState(current.copyWith(importantDates: updated)); } Future updateMoment(RelationshipMoment moment) async { if (moment.summary.trim().isEmpty) { throw ArgumentError('Capture summary cannot be empty'); } final LocalDataState current = _requireState(); final List updated = current.moments .map((RelationshipMoment item) => item.id == moment.id ? moment : item) .toList(growable: false); await _setState(current.copyWith(moments: updated)); await _enqueueChange( _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.upsert, payload: _momentPayload(moment), ), ); } Future deleteMoment(String momentId) async { final LocalDataState current = _requireState(); final List moments = current.moments .where((RelationshipMoment moment) => moment.id != momentId) .toList(growable: false); await _setState(current.copyWith(moments: moments)); await _enqueueChange( _buildEnvelope( entityType: 'capture', entityId: momentId, op: ChangeOperation.delete, ), ); } Future addIdea({ required IdeaType type, required String title, required String details, String? personId, }) async { final String normalizedTitle = title.trim(); if (normalizedTitle.isEmpty) { throw ArgumentError('Idea title is required'); } final LocalDataState current = _requireState(); final RelationshipIdea idea = RelationshipIdea( id: 'i-${_uuid.v4()}', personId: personId, type: type, title: normalizedTitle, details: details.trim(), createdAt: DateTime.now(), ); await _setState( current.copyWith(ideas: [idea, ...current.ideas]), ); await _enqueueChange( _buildEnvelope( entityType: _ideaEntityType(idea.type), entityId: idea.id, op: ChangeOperation.upsert, payload: _ideaPayload(idea), ), ); } Future updateIdea(RelationshipIdea idea) async { if (idea.title.trim().isEmpty) { throw ArgumentError('Idea title is required'); } final LocalDataState current = _requireState(); final List ideas = current.ideas .map((RelationshipIdea item) => item.id == idea.id ? idea : item) .toList(growable: false); await _setState(current.copyWith(ideas: ideas)); await _enqueueChange( _buildEnvelope( entityType: _ideaEntityType(idea.type), entityId: idea.id, op: ChangeOperation.upsert, payload: _ideaPayload(idea), ), ); } Future toggleIdeaArchived(String ideaId) async { final LocalDataState current = _requireState(); RelationshipIdea? updatedIdea; final List ideas = current.ideas .map((RelationshipIdea item) { if (item.id != ideaId) { return item; } final RelationshipIdea toggled = item.copyWith( isArchived: !item.isArchived, ); updatedIdea = toggled; return toggled; }) .toList(growable: false); await _setState(current.copyWith(ideas: ideas)); if (updatedIdea != null) { await _enqueueChange( _buildEnvelope( entityType: _ideaEntityType(updatedIdea!.type), entityId: updatedIdea!.id, op: ChangeOperation.upsert, payload: _ideaPayload(updatedIdea!), ), ); } } Future deleteIdea(String ideaId) async { final LocalDataState current = _requireState(); final List ideas = current.ideas .where((RelationshipIdea idea) => idea.id != ideaId) .toList(growable: false); await _setState(current.copyWith(ideas: ideas)); await _enqueueChange( _buildEnvelope( entityType: _ideaEntityTypeFromId(current.ideas, ideaId), entityId: ideaId, op: ChangeOperation.delete, ), ); } Future addReminder({ required String title, required ReminderCadence cadence, required DateTime nextAt, String? personId, }) async { final String normalizedTitle = title.trim(); if (normalizedTitle.isEmpty) { throw ArgumentError('Reminder title is required'); } final LocalDataState current = _requireState(); final ReminderRule reminder = ReminderRule( id: 'r-${_uuid.v4()}', personId: personId, title: normalizedTitle, cadence: cadence, nextAt: nextAt, enabled: true, ); await _setState( current.copyWith( reminders: [reminder, ...current.reminders], ), ); await _enqueueChange( _buildEnvelope( entityType: 'reminderRule', entityId: reminder.id, op: ChangeOperation.upsert, payload: _reminderPayload(reminder), ), ); } Future updateReminder(ReminderRule reminder) async { if (reminder.title.trim().isEmpty) { throw ArgumentError('Reminder title is required'); } final LocalDataState current = _requireState(); final List reminders = current.reminders .map((ReminderRule item) => item.id == reminder.id ? reminder : item) .toList(growable: false); await _setState(current.copyWith(reminders: reminders)); await _enqueueChange( _buildEnvelope( entityType: 'reminderRule', entityId: reminder.id, op: ChangeOperation.upsert, payload: _reminderPayload(reminder), ), ); } Future toggleReminderEnabled(String reminderId) async { final LocalDataState current = _requireState(); ReminderRule? updatedReminder; final List reminders = current.reminders .map((ReminderRule item) { if (item.id != reminderId) { return item; } final ReminderRule toggled = item.copyWith(enabled: !item.enabled); updatedReminder = toggled; return toggled; }) .toList(growable: false); await _setState(current.copyWith(reminders: reminders)); if (updatedReminder != null) { await _enqueueChange( _buildEnvelope( entityType: 'reminderRule', entityId: updatedReminder!.id, op: ChangeOperation.upsert, payload: _reminderPayload(updatedReminder!), ), ); } } Future deleteReminder(String reminderId) async { final LocalDataState current = _requireState(); final List reminders = current.reminders .where((ReminderRule reminder) => reminder.id != reminderId) .toList(growable: false); await _setState(current.copyWith(reminders: reminders)); await _enqueueChange( _buildEnvelope( entityType: 'reminderRule', entityId: reminderId, op: ChangeOperation.delete, ), ); } Future upsertInferredPreferenceSignalObservation({ required String personId, required String key, required String label, required String category, required PreferenceSignalPolarity polarity, required double confidence, String? sourceApp, String? evidenceMessageId, String? evidenceSnippet, DateTime? observedAt, }) async { final LocalDataState current = _requireState(); final DateTime observed = observedAt ?? DateTime.now(); final String normalizedKey = _normalizePreferenceKey(key); if (normalizedKey.isEmpty) { throw ArgumentError('Preference signal key cannot be empty'); } if (!_personExists(current.people, personId)) { throw ArgumentError('Profile does not exist for preference signal'); } final int existingIndex = current.preferenceSignals.indexWhere( (PersonPreferenceSignal signal) => signal.personId == personId && signal.key == normalizedKey && signal.polarity == polarity, ); final double normalizedConfidence = _clampUnit(confidence); final String normalizedLabel = label.trim().isEmpty ? normalizedKey : label.trim(); final String normalizedCategory = category.trim().isEmpty ? 'general' : category.trim().toLowerCase(); final String? snippet = _trimToNull(evidenceSnippet); final String? messageId = _trimToNull(evidenceMessageId); final String? source = _trimToNull(sourceApp)?.toLowerCase(); late final PersonPreferenceSignal nextSignal; final List nextSignals = current.preferenceSignals .toList(growable: true); if (existingIndex < 0) { nextSignal = PersonPreferenceSignal( id: 'ps-${_uuid.v4()}', personId: personId, key: normalizedKey, label: normalizedLabel, category: normalizedCategory, polarity: polarity, confidence: normalizedConfidence, status: PreferenceSignalStatus.inferred, firstSeenAt: observed, lastSeenAt: observed, occurrenceCount: 1, sourceApps: source == null ? const [] : [source], evidenceMessageIds: messageId == null ? const [] : [messageId], evidenceSnippets: snippet == null ? const [] : [snippet], ); nextSignals.insert(0, nextSignal); } else { final PersonPreferenceSignal existing = nextSignals[existingIndex]; final int currentCount = existing.occurrenceCount < 1 ? 1 : existing.occurrenceCount; final int nextCount = currentCount + 1; final double mergedConfidence = ((existing.confidence * currentCount) + normalizedConfidence) / nextCount; nextSignal = existing.copyWith( label: normalizedLabel, category: normalizedCategory, confidence: _clampUnit(mergedConfidence), lastSeenAt: observed, occurrenceCount: nextCount, sourceApps: _mergeUniqueStrings(existing.sourceApps, source), evidenceMessageIds: _mergeUniqueStrings( existing.evidenceMessageIds, messageId, maxItems: 8, ), evidenceSnippets: _mergeUniqueStrings( existing.evidenceSnippets, snippet == null ? null : _truncate(snippet, 220), maxItems: 6, ), ); nextSignals[existingIndex] = nextSignal; } await _setState(current.copyWith(preferenceSignals: nextSignals)); return nextSignal; } Future upsertPreferenceSignal(PersonPreferenceSignal signal) async { final LocalDataState current = _requireState(); if (!_personExists(current.people, signal.personId)) { throw ArgumentError('Profile does not exist for preference signal'); } final List nextSignals = _upsertItem( items: current.preferenceSignals, item: signal, idOf: (PersonPreferenceSignal value) => value.id, ); await _setState(current.copyWith(preferenceSignals: nextSignals)); } Future setPreferenceSignalStatus({ required String signalId, required PreferenceSignalStatus status, }) async { final LocalDataState current = _requireState(); final List nextSignals = current.preferenceSignals .map((PersonPreferenceSignal signal) { if (signal.id != signalId) { return signal; } return signal.copyWith(status: status); }) .toList(growable: false); await _setState(current.copyWith(preferenceSignals: nextSignals)); } Future confirmPreferenceSignal(String signalId) { return setPreferenceSignalStatus( signalId: signalId, status: PreferenceSignalStatus.confirmed, ); } Future dismissPreferenceSignal(String signalId) { return setPreferenceSignalStatus( signalId: signalId, status: PreferenceSignalStatus.dismissed, ); } Future 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: [ normalizedId, ...current.dismissedSignalIds, ], ), ); } Future toggleTaskDone(String taskId) async { final LocalDataState current = _requireState(); final List tasks = current.tasks .map( (DashboardTask task) => task.id == taskId ? task.copyWith(done: !task.done) : task, ) .toList(growable: false); await _setState(current.copyWith(tasks: tasks)); } } bool _personExists(List people, String personId) { return people.any((PersonProfile person) => person.id == personId); } String _normalizePreferenceKey(String value) { return value .trim() .toLowerCase() .replaceAll(RegExp(r'[^a-z0-9:_ -]+'), '') .replaceAll(RegExp(r'\s+'), ' '); } double _clampUnit(double value) { if (value.isNaN) { return 0; } return value.clamp(0.0, 1.0).toDouble(); } List _mergeUniqueStrings( List existing, String? incoming, { int maxItems = 12, }) { final List values = []; final Set seen = {}; for (final String raw in existing) { final String value = raw.trim(); if (value.isEmpty) { continue; } final String key = value.toLowerCase(); if (seen.add(key)) { values.add(value); } } final String? newValue = _trimToNull(incoming); if (newValue != null) { final String key = newValue.toLowerCase(); if (!seen.contains(key)) { values.insert(0, newValue); } } if (values.length <= maxItems) { return values; } return values.take(maxItems).toList(growable: false); } String _truncate(String value, int maxChars) { if (value.length <= maxChars) { return value; } return value.substring(0, maxChars); } String _titleFromSummary(String summary) { final List words = summary.split(RegExp(r'\s+')); final int take = words.length < 5 ? words.length : 5; return words.take(take).join(' '); }