Files
rely/lib/app/data/relationship_repository_ai_digest.dart
T
Rijad Zuzo f655adfbea 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.
2026-05-17 00:17:20 +02:00

162 lines
4.9 KiB
Dart

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;
}