f655adfbea
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.
668 lines
20 KiB
Dart
668 lines
20 KiB
Dart
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(' ');
|
|
}
|