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,114 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart';
import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_in_memory.dart';
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
test('accepting gift draft creates idea and marks draft accepted', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState initial = await container.read(
localRepositoryProvider.future,
);
final String personId = initial.people.first.id;
await container
.read(localRepositoryProvider.notifier)
.saveAiSuggestionDrafts(<AiSuggestionDraft>[
AiSuggestionDraft(
id: 'ai-1',
personId: personId,
kind: AiSuggestionKind.giftIdea,
title: 'Coffee sampler',
details: 'Small local roaster pack.',
suggestedAt: DateTime(2026, 5, 16),
confidence: 0.8,
status: AiSuggestionStatus.pending,
sourceRunId: 'run-1',
),
]);
await container
.read(localRepositoryProvider.notifier)
.acceptAiSuggestionDraft('ai-1');
final LocalDataState after = await container.read(
localRepositoryProvider.future,
);
expect(after.aiSuggestionDrafts.single.status, AiSuggestionStatus.accepted);
expect(
after.ideas.any(
(RelationshipIdea idea) =>
idea.personId == personId &&
idea.type == IdeaType.gift &&
idea.title == 'Coffee sampler',
),
isTrue,
);
});
test('dismissing draft does not create local artifacts', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState initial = await container.read(
localRepositoryProvider.future,
);
final String personId = initial.people.first.id;
final int ideaCount = initial.ideas.length;
await container
.read(localRepositoryProvider.notifier)
.saveAiSuggestionDrafts(<AiSuggestionDraft>[
AiSuggestionDraft(
id: 'ai-2',
personId: personId,
kind: AiSuggestionKind.eventIdea,
title: 'Museum afternoon',
details: 'Pick a quiet exhibit.',
suggestedAt: DateTime(2026, 5, 16),
confidence: 0.7,
status: AiSuggestionStatus.pending,
sourceRunId: 'run-1',
),
]);
await container
.read(localRepositoryProvider.notifier)
.dismissAiSuggestionDraft('ai-2');
final LocalDataState after = await container.read(
localRepositoryProvider.future,
);
expect(
after.aiSuggestionDrafts.single.status,
AiSuggestionStatus.dismissed,
);
expect(after.ideas.length, ideaCount);
});
}
ProviderContainer _createContainer() {
return ProviderContainer(
overrides: [
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
reminderSchedulerProvider.overrideWithValue(
const NoopReminderScheduler(),
),
],
);
}
@@ -0,0 +1,52 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
void main() {
test('maps valid tokenized suggestions back to local person ids', () {
final AiDigestParseResult result = const AiDigestResponseParser().parse(
response: '''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "giftIdea",
"title": "Coffee tasting set",
"details": "Pick a small local sampler.",
"suggestedTiming": "2026-05-20T18:00:00Z",
"confidence": 0.82,
"reason": "Matches known interests."
}
]
}
''',
tokenToPersonId: const <String, String>{'person_001': 'p-1'},
sourceRunId: 'run-1',
suggestedAt: DateTime(2026, 5, 16),
);
expect(result.success, isTrue);
expect(result.drafts, hasLength(1));
expect(result.drafts.single.personId, 'p-1');
expect(result.drafts.single.kind, AiSuggestionKind.giftIdea);
expect(result.drafts.single.status, AiSuggestionStatus.pending);
});
test('rejects unknown person tokens and malformed items', () {
final AiDigestParseResult result = const AiDigestResponseParser().parse(
response: '''
[
{"personToken": "person_404", "kind": "giftIdea", "title": "Bad"},
{"personToken": "person_001", "kind": "unknown", "title": "Bad"},
{"personToken": "person_001", "kind": "checkIn", "title": ""}
]
''',
tokenToPersonId: const <String, String>{'person_001': 'p-1'},
sourceRunId: 'run-1',
suggestedAt: DateTime(2026, 5, 16),
);
expect(result.success, isFalse);
expect(result.drafts, isEmpty);
});
}
@@ -0,0 +1,94 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
import 'package:relationship_saver/features/dashboard/domain/dashboard_models.dart';
import 'package:relationship_saver/features/ideas/domain/idea_models.dart';
import 'package:relationship_saver/features/moments/domain/moment_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/reminders/domain/reminder_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
void main() {
test('builds pseudonymous payload without identifiable profile data', () {
final LocalDataState state = LocalDataState(
people: <PersonProfile>[
PersonProfile(
id: 'p-1',
name: 'Alice Secret',
relationship: 'Sister',
affinityScore: 85,
nextMoment: DateTime(2026, 6, 1),
tags: const <String>['coffee', 'books'],
notes: 'Passport number and private address should stay local.',
aliases: const <String>['Ally'],
location: 'Zurich, Switzerland',
lastInteractedAt: DateTime(2026, 5, 1),
),
],
moments: const <RelationshipMoment>[],
ideas: const <RelationshipIdea>[],
reminders: const <ReminderRule>[],
tasks: const <DashboardTask>[],
sourceLinks: <SourceProfileLink>[
SourceProfileLink(
id: 'sl-1',
sourceApp: 'whatsapp',
normalizedDisplayName: 'Alice Secret',
profileId: 'p-1',
firstSeenAt: DateTime(2026, 5, 1),
lastSeenAt: DateTime(2026, 5, 1),
sourceUserId: 'wa:alice',
sourceThreadId: 'thread-secret',
sourceFingerprint: 'whatsapp|u:wa:alice',
),
],
sharedMessages: <SharedMessageEntry>[
SharedMessageEntry(
id: 'sm-1',
profileId: 'p-1',
payload: SharedPayload(
sourceApp: 'whatsapp',
payloadType: SharedPayloadType.text,
rawText: 'Raw private chat text',
createdAt: DateTime(2026, 5, 1),
receivedAt: DateTime(2026, 5, 1),
platform: 'ios',
sourceDisplayName: 'Alice Secret',
sourceUserId: 'wa:alice',
sourceThreadId: 'thread-secret',
),
importedAt: DateTime(2026, 5, 1),
resolvedAutomatically: true,
),
],
personFacts: <PersonFact>[
PersonFact(
id: 'pf-1',
personId: 'p-1',
type: CapturedFactType.note,
text: 'Secret medical detail',
sourceKind: CaptureSourceKind.manual,
createdAt: DateTime(2026, 5, 1),
updatedAt: DateTime(2026, 5, 1),
isSensitive: true,
),
],
);
final AnonymizedLlmDigestContext context = AnonymizedLlmContextBuilder(
now: () => DateTime(2026, 5, 16),
).build(state);
final String prompt = context.toPromptJson();
expect(context.tokenToPersonId, <String, String>{'person_001': 'p-1'});
expect(prompt, contains('person_001'));
expect(prompt, isNot(contains('Alice')));
expect(prompt, isNot(contains('Ally')));
expect(prompt, isNot(contains('Zurich')));
expect(prompt, isNot(contains('Passport')));
expect(prompt, isNot(contains('wa:alice')));
expect(prompt, isNot(contains('thread-secret')));
expect(prompt, isNot(contains('Raw private chat text')));
expect(prompt, isNot(contains('Secret medical detail')));
});
}
@@ -0,0 +1,168 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart';
import 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart';
import 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_in_memory.dart';
import 'package:relationship_saver/features/sync/data/storage/sync_state_store_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
test(
'scheduled digest creates review drafts with one LLM call and notification',
() async {
final FakeTextClient textClient = FakeTextClient(
response: '''
{
"suggestions": [
{
"personToken": "person_001",
"kind": "checkIn",
"title": "Send a quick note",
"details": "Ask how the week is going.",
"confidence": 0.7,
"reason": "Contact has been quiet."
}
]
}
''',
);
final FakeNotifier notifier = FakeNotifier();
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: notifier,
);
addTearDown(container.dispose);
await container.read(llmDigestConfigProvider.notifier).setEnabled(true);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runScheduledDigest();
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
expect(result.completed, isTrue);
expect(result.createdDraftCount, 1);
expect(textClient.calls, 1);
expect(textClient.lastPrompt, isNot(contains(state.people.first.name)));
expect(state.aiSuggestionDrafts, hasLength(1));
expect(
state.aiSuggestionDrafts.single.status,
AiSuggestionStatus.pending,
);
expect(notifier.lastCount, 1);
},
);
test('scheduled digest respects weekly cooldown', () async {
final LlmDigestRunStore runStore = const LlmDigestRunStore();
await runStore.write(LlmDigestRunState(lastCompletedAt: DateTime.now()));
final FakeTextClient textClient = FakeTextClient(response: '[]');
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: FakeNotifier(),
);
addTearDown(container.dispose);
await container.read(llmDigestConfigProvider.notifier).setEnabled(true);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runScheduledDigest();
expect(result.started, isFalse);
expect(textClient.calls, 0);
});
test('scheduled digest skips when network policy is not satisfied', () async {
final FakeTextClient textClient = FakeTextClient(response: '[]');
final ProviderContainer container = _createContainer(
textClient: textClient,
notifier: FakeNotifier(),
environment: const FakeEnvironment(canRunValue: false),
);
addTearDown(container.dispose);
await container.read(llmDigestConfigProvider.notifier).setEnabled(true);
final LlmDigestRunResult result = await container
.read(llmDigestOrchestratorProvider)
.runScheduledDigest();
expect(result.started, isFalse);
expect(textClient.calls, 0);
});
}
ProviderContainer _createContainer({
required FakeTextClient textClient,
required FakeNotifier notifier,
LlmDigestEnvironment environment = const FakeEnvironment(),
}) {
return ProviderContainer(
overrides: [
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
reminderSchedulerProvider.overrideWithValue(
const NoopReminderScheduler(),
),
llmDigestInitializerProvider.overrideWithValue(() async {}),
llmDigestApiKeyConfiguredProvider.overrideWithValue(true),
llmDigestTextClientProvider.overrideWithValue(textClient),
llmDigestEnvironmentProvider.overrideWithValue(environment),
aiDigestNotifierProvider.overrideWithValue(notifier),
],
);
}
class FakeTextClient implements LlmDigestTextClient {
FakeTextClient({required this.response});
final String response;
int calls = 0;
String? lastPrompt;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
}) async {
calls += 1;
lastPrompt = userPrompt;
return response;
}
}
class FakeNotifier implements AiDigestNotifier {
int? lastCount;
@override
Future<void> showDigestReady(int count) async {
lastCount = count;
}
}
class FakeEnvironment implements LlmDigestEnvironment {
const FakeEnvironment({this.canRunValue = true});
final bool canRunValue;
@override
Future<bool> canRun(LlmDigestConfigState config) async => canRunValue;
}