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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user