feat: add local-first private AI digest workflow

Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
@@ -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;
}