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