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,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;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Sign Out'));
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Sign Out'));
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 450));
|
||||
|
||||
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||
|
||||
@@ -59,7 +59,10 @@ void main() {
|
||||
|
||||
expect(find.text('Add Person'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Taylor Quinn');
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Name'),
|
||||
'Taylor Quinn',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Relationship'),
|
||||
'Partner',
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:relationship_saver/features/local/storage/local_data_store_in_me
|
||||
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
|
||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
|
||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_payload_parser.dart';
|
||||
import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart';
|
||||
import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -277,6 +279,105 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'saveSharedPayloadToInbox then resolve persists structured fact draft',
|
||||
() async {
|
||||
final ProviderContainer container = _createContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final LocalDataState state = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final String personId = state.people.first.id;
|
||||
final SharedPayload payload = SharePayloadParser.parse(
|
||||
rawText: 'She loves vegan sushi and rooftop dinners.',
|
||||
sourceApp: 'share_sheet',
|
||||
platform: 'android',
|
||||
);
|
||||
|
||||
final SharedMessageIngestResult queued = await container
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.saveSharedPayloadToInbox(
|
||||
payload: payload,
|
||||
draft: const CapturedFactDraft(
|
||||
type: CapturedFactType.like,
|
||||
text: 'Vegan sushi and rooftop dinners',
|
||||
),
|
||||
);
|
||||
|
||||
await container
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.resolveSharedInboxToExistingProfile(
|
||||
inboxEntryId: queued.inboxEntryId!,
|
||||
profileId: personId,
|
||||
draft: const CapturedFactDraft(
|
||||
type: CapturedFactType.like,
|
||||
text: 'Vegan sushi and rooftop dinners',
|
||||
),
|
||||
);
|
||||
|
||||
final LocalDataState after = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
expect(
|
||||
after.personFacts.any(
|
||||
(PersonFact fact) =>
|
||||
fact.personId == personId &&
|
||||
fact.type == CapturedFactType.like &&
|
||||
fact.text.contains('Vegan sushi'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'captureSharedPayloadByCreatingProfile can persist important date',
|
||||
() async {
|
||||
final ProviderContainer container = _createContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(localRepositoryProvider.future);
|
||||
final SharedPayload payload = SharePayloadParser.parse(
|
||||
rawText: 'Anniversary dinner https://example.com',
|
||||
sourceApp: 'share_sheet',
|
||||
platform: 'ios',
|
||||
);
|
||||
|
||||
final SharedMessageIngestResult result = await container
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.captureSharedPayloadByCreatingProfile(
|
||||
payload: payload,
|
||||
name: 'Robin Vale',
|
||||
relationship: 'Partner',
|
||||
aliases: const <String>['RV'],
|
||||
draft: CapturedFactDraft(
|
||||
type: CapturedFactType.importantDate,
|
||||
text: 'Anniversary',
|
||||
label: 'Anniversary dinner',
|
||||
dateValue: DateTime(2026, 9, 14),
|
||||
),
|
||||
);
|
||||
|
||||
final LocalDataState after = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
final PersonProfile created = after.people.firstWhere(
|
||||
(PersonProfile person) => person.id == result.profileId,
|
||||
);
|
||||
|
||||
expect(created.aliases, contains('RV'));
|
||||
expect(
|
||||
after.importantDates.any(
|
||||
(PersonImportantDate value) =>
|
||||
value.personId == created.id &&
|
||||
value.label == 'Anniversary dinner',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
|
||||
() async {
|
||||
@@ -684,6 +785,22 @@ void main() {
|
||||
expect(afterDelete.reminders.length, beforeCount);
|
||||
});
|
||||
|
||||
test('dismissLocalSignal persists dismissed ids in local state', () async {
|
||||
final ProviderContainer container = _createContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(localRepositoryProvider.future);
|
||||
|
||||
await container
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.dismissLocalSignal('local-checkin-p-ava-123');
|
||||
|
||||
final LocalDataState after = await container.read(
|
||||
localRepositoryProvider.future,
|
||||
);
|
||||
expect(after.dismissedSignalIds, contains('local-checkin-p-ava-123'));
|
||||
});
|
||||
|
||||
test('reconciles reminder schedule after state changes', () async {
|
||||
final RecordingReminderScheduler scheduler = RecordingReminderScheduler();
|
||||
final ProviderContainer container = _createContainer(scheduler: scheduler);
|
||||
|
||||
@@ -52,7 +52,7 @@ void main() {
|
||||
|
||||
final LocalDataRecord? migrated = await hiveStore.read();
|
||||
expect(migrated, isNotNull);
|
||||
expect(migrated!.schemaVersion, 3);
|
||||
expect(migrated!.schemaVersion, 5);
|
||||
|
||||
final LocalDataRecord? legacyAfter = await legacyStore.read();
|
||||
expect(legacyAfter, isNull);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart';
|
||||
|
||||
void main() {
|
||||
test('suggests gift idea draft from gift-oriented text', () {
|
||||
final CapturedFactDraft draft =
|
||||
ShareCaptureDraftSuggester.suggestForPayload(
|
||||
SharedPayload(
|
||||
sourceApp: 'whatsapp',
|
||||
payloadType: SharedPayloadType.text,
|
||||
rawText: 'Gift idea: blue scarf she mentioned last week',
|
||||
createdAt: DateTime(2026, 4, 17),
|
||||
receivedAt: DateTime(2026, 4, 17),
|
||||
platform: 'android',
|
||||
),
|
||||
);
|
||||
|
||||
expect(draft.type, CapturedFactType.giftIdea);
|
||||
expect(draft.label, 'Gift idea');
|
||||
expect(draft.needsReview, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'suggests important date draft when birthday and explicit date are present',
|
||||
() {
|
||||
final CapturedFactDraft draft =
|
||||
ShareCaptureDraftSuggester.suggestForPayload(
|
||||
SharedPayload(
|
||||
sourceApp: 'notes',
|
||||
payloadType: SharedPayloadType.text,
|
||||
rawText: 'Her birthday is 2026-09-14',
|
||||
createdAt: DateTime(2026, 4, 17),
|
||||
receivedAt: DateTime(2026, 4, 17),
|
||||
platform: 'ios',
|
||||
),
|
||||
);
|
||||
|
||||
expect(draft.type, CapturedFactType.importantDate);
|
||||
expect(draft.label, 'Birthday');
|
||||
expect(draft.dateValue, DateTime(2026, 9, 14));
|
||||
},
|
||||
);
|
||||
|
||||
test('suggests place idea for shared maps links', () {
|
||||
final CapturedFactDraft draft =
|
||||
ShareCaptureDraftSuggester.suggestForPayload(
|
||||
SharedPayload(
|
||||
sourceApp: 'share_sheet',
|
||||
payloadType: SharedPayloadType.url,
|
||||
rawText: '',
|
||||
url: 'https://maps.apple.com/?address=1%20Infinite%20Loop',
|
||||
createdAt: DateTime(2026, 4, 17),
|
||||
receivedAt: DateTime(2026, 4, 17),
|
||||
platform: 'ios',
|
||||
),
|
||||
);
|
||||
|
||||
expect(draft.type, CapturedFactType.placeIdea);
|
||||
expect(draft.label, 'Place idea');
|
||||
});
|
||||
|
||||
test(
|
||||
'suggests dislike when text contains strong negative preference signal',
|
||||
() {
|
||||
final CapturedFactDraft draft =
|
||||
ShareCaptureDraftSuggester.suggestForPayload(
|
||||
SharedPayload(
|
||||
sourceApp: 'signal',
|
||||
payloadType: SharedPayloadType.text,
|
||||
rawText: "She doesn't like crowded bars",
|
||||
createdAt: DateTime(2026, 4, 17),
|
||||
receivedAt: DateTime(2026, 4, 17),
|
||||
platform: 'android',
|
||||
),
|
||||
);
|
||||
|
||||
expect(draft.type, CapturedFactType.dislike);
|
||||
expect(draft.label, 'Preference');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:relationship_saver/features/local/storage/local_data_store_in_me
|
||||
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
|
||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
|
||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
|
||||
import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart';
|
||||
import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart';
|
||||
@@ -66,7 +67,7 @@ void main() {
|
||||
|
||||
expect(find.text('Share Inbox'), findsOneWidget);
|
||||
expect(find.text('Ambiguous match'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Review & Match'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Review & Save'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Dismiss Item').first);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -116,7 +117,12 @@ void main() {
|
||||
|
||||
expect(find.text('Near match conflict'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Review & Match').first);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Review & Save').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Review Capture Mapping'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Use Mapping'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Review Match'), findsOneWidget);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_payload_parser.dart';
|
||||
|
||||
void main() {
|
||||
test('parses url-only payload', () {
|
||||
final SharedPayload payload = SharePayloadParser.parse(
|
||||
rawText: 'https://example.com/date-night',
|
||||
sourceApp: 'share_sheet',
|
||||
platform: 'android',
|
||||
);
|
||||
|
||||
expect(payload.payloadType, SharedPayloadType.url);
|
||||
expect(payload.url, 'https://example.com/date-night');
|
||||
expect(payload.rawText, isEmpty);
|
||||
});
|
||||
|
||||
test('parses text plus url payload', () {
|
||||
final SharedPayload payload = SharePayloadParser.parse(
|
||||
rawText: 'Could be a good anniversary place https://example.com',
|
||||
sourceApp: 'share_sheet',
|
||||
platform: 'android',
|
||||
);
|
||||
|
||||
expect(payload.payloadType, SharedPayloadType.textWithUrl);
|
||||
expect(payload.url, 'https://example.com');
|
||||
expect(payload.rawText, 'Could be a good anniversary place');
|
||||
});
|
||||
|
||||
test('preserves WhatsApp sender extraction when pattern matches', () {
|
||||
final SharedPayload payload = SharePayloadParser.parse(
|
||||
rawText: 'Ava Hart: loves cozy Italian restaurants',
|
||||
sourceApp: 'whatsapp',
|
||||
platform: 'ios',
|
||||
);
|
||||
|
||||
expect(payload.sourceDisplayName, 'Ava Hart');
|
||||
expect(payload.sourceUserId, 'wa:ava hart');
|
||||
expect(payload.rawText, 'loves cozy Italian restaurants');
|
||||
});
|
||||
|
||||
test('uses explicit signal source app hint and extracts sender', () {
|
||||
final SharedPayload payload = SharePayloadParser.parse(
|
||||
rawText: 'Marek: this concert looks promising',
|
||||
sourceApp: 'signal',
|
||||
platform: 'android',
|
||||
);
|
||||
|
||||
expect(payload.sourceApp, 'signal');
|
||||
expect(payload.sourceDisplayName, 'Marek');
|
||||
expect(payload.sourceUserId, 'signal:marek');
|
||||
expect(payload.rawText, 'this concert looks promising');
|
||||
});
|
||||
|
||||
test('normalizes notes hint without trying to invent sender metadata', () {
|
||||
final SharedPayload payload = SharePayloadParser.parse(
|
||||
rawText: 'Dinner idea from Notes https://example.com',
|
||||
sourceApp: 'notes',
|
||||
platform: 'ios',
|
||||
);
|
||||
|
||||
expect(payload.sourceApp, 'notes');
|
||||
expect(payload.sourceDisplayName, isNull);
|
||||
expect(payload.payloadType, SharedPayloadType.textWithUrl);
|
||||
expect(payload.rawText, 'Dinner idea from Notes');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/app/state/local_data_state.dart';
|
||||
import 'package:relationship_saver/features/ideas/domain/idea_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';
|
||||
import 'package:relationship_saver/features/signals/domain/signals_feed_synthesizer.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'creates a check-in signal when interaction is stale and no reminder exists',
|
||||
() {
|
||||
final LocalDataState state = LocalDataState(
|
||||
people: <PersonProfile>[
|
||||
PersonProfile(
|
||||
id: 'p-1',
|
||||
name: 'Alex',
|
||||
relationship: 'Friend',
|
||||
affinityScore: 80,
|
||||
nextMoment: DateTime(2026, 4, 30),
|
||||
tags: const <String>[],
|
||||
notes: '',
|
||||
lastInteractedAt: DateTime(2026, 4, 1),
|
||||
),
|
||||
],
|
||||
moments: const [],
|
||||
ideas: const [],
|
||||
reminders: const [],
|
||||
tasks: const [],
|
||||
);
|
||||
|
||||
final signals = SignalsFeedSynthesizer.synthesize(
|
||||
state: state,
|
||||
now: DateTime(2026, 4, 17),
|
||||
);
|
||||
|
||||
expect(signals, isNotEmpty);
|
||||
expect(signals.first.title, 'Check in with Alex');
|
||||
expect(
|
||||
signals.first.metadata?[signalMetadataActionKey],
|
||||
createReminderSignalAction,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'suppresses stale check-in signal when a nearby reminder already exists',
|
||||
() {
|
||||
final LocalDataState state = LocalDataState(
|
||||
people: <PersonProfile>[
|
||||
PersonProfile(
|
||||
id: 'p-1',
|
||||
name: 'Alex',
|
||||
relationship: 'Friend',
|
||||
affinityScore: 80,
|
||||
nextMoment: DateTime(2026, 4, 30),
|
||||
tags: const <String>[],
|
||||
notes: '',
|
||||
lastInteractedAt: DateTime(2026, 4, 1),
|
||||
),
|
||||
],
|
||||
moments: const [],
|
||||
ideas: const [],
|
||||
reminders: <ReminderRule>[
|
||||
ReminderRule(
|
||||
id: 'r-1',
|
||||
personId: 'p-1',
|
||||
title: 'Weekly check in',
|
||||
cadence: ReminderCadence.weekly,
|
||||
nextAt: DateTime(2026, 4, 18),
|
||||
),
|
||||
],
|
||||
tasks: const [],
|
||||
);
|
||||
|
||||
final signals = SignalsFeedSynthesizer.synthesize(
|
||||
state: state,
|
||||
now: DateTime(2026, 4, 17),
|
||||
);
|
||||
|
||||
expect(
|
||||
signals.where((signal) => signal.title.contains('Check in')),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('creates an upcoming date signal with idea count context', () {
|
||||
final DateTime now = DateTime(2026, 4, 17);
|
||||
final LocalDataState state = LocalDataState(
|
||||
people: <PersonProfile>[
|
||||
PersonProfile(
|
||||
id: 'p-1',
|
||||
name: 'Mila',
|
||||
relationship: 'Sister',
|
||||
affinityScore: 88,
|
||||
nextMoment: DateTime(2026, 4, 25),
|
||||
tags: const <String>[],
|
||||
notes: '',
|
||||
lastInteractedAt: DateTime(2026, 4, 16),
|
||||
),
|
||||
],
|
||||
moments: const [],
|
||||
ideas: <RelationshipIdea>[
|
||||
RelationshipIdea(
|
||||
id: 'i-1',
|
||||
personId: 'p-1',
|
||||
type: IdeaType.gift,
|
||||
title: 'Plant stand',
|
||||
details: 'Oak finish',
|
||||
createdAt: DateTime(2026, 4, 10),
|
||||
),
|
||||
],
|
||||
reminders: const [],
|
||||
tasks: const [],
|
||||
importantDates: <PersonImportantDate>[
|
||||
PersonImportantDate(
|
||||
id: 'd-1',
|
||||
personId: 'p-1',
|
||||
label: 'Birthday',
|
||||
date: now.add(const Duration(days: 3)),
|
||||
classification: 'birthday',
|
||||
sourceKind: CaptureSourceKind.manual,
|
||||
createdAt: now.subtract(const Duration(days: 1)),
|
||||
updatedAt: now,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final signals = SignalsFeedSynthesizer.synthesize(state: state, now: now);
|
||||
|
||||
expect(
|
||||
signals.any(
|
||||
(signal) => signal.title.contains('Birthday for Mila is in 3 days'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -9,45 +9,48 @@ import 'package:relationship_saver/features/sync/sync_background_runner.dart';
|
||||
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('triggers sync when reachability transitions from offline to online', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _TestReachability reachability = _TestReachability(initial: false);
|
||||
addTearDown(reachability.dispose);
|
||||
testWidgets(
|
||||
'triggers sync when reachability transitions from offline to online',
|
||||
(WidgetTester tester) async {
|
||||
final _TestReachability reachability = _TestReachability(initial: false);
|
||||
addTearDown(reachability.dispose);
|
||||
|
||||
int syncCalls = 0;
|
||||
int syncCalls = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
networkReachabilityProvider.overrideWithValue(reachability),
|
||||
syncCoordinatorProvider.overrideWith(
|
||||
(Ref ref) =>
|
||||
_TestSyncCoordinator(ref, onSyncNow: () async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
networkReachabilityProvider.overrideWithValue(reachability),
|
||||
syncCoordinatorProvider.overrideWith(
|
||||
(Ref ref) => _TestSyncCoordinator(
|
||||
ref,
|
||||
onSyncNow: () async {
|
||||
syncCalls += 1;
|
||||
return _okResult();
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SyncBackgroundRunner(child: SizedBox()),
|
||||
),
|
||||
],
|
||||
child: const Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: SyncBackgroundRunner(child: SizedBox()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 60));
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 60));
|
||||
|
||||
// Startup trigger always runs once when background sync is enabled.
|
||||
expect(syncCalls, 1);
|
||||
// Startup trigger always runs once when background sync is enabled.
|
||||
expect(syncCalls, 1);
|
||||
|
||||
reachability.emit(false);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 40));
|
||||
expect(syncCalls, 1);
|
||||
reachability.emit(false);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 40));
|
||||
expect(syncCalls, 1);
|
||||
|
||||
reachability.emit(true);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 60));
|
||||
expect(syncCalls, 2);
|
||||
});
|
||||
reachability.emit(true);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 60));
|
||||
expect(syncCalls, 2);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('does not trigger extra sync while connectivity stays online', (
|
||||
WidgetTester tester,
|
||||
@@ -62,11 +65,13 @@ void main() {
|
||||
overrides: [
|
||||
networkReachabilityProvider.overrideWithValue(reachability),
|
||||
syncCoordinatorProvider.overrideWith(
|
||||
(Ref ref) =>
|
||||
_TestSyncCoordinator(ref, onSyncNow: () async {
|
||||
syncCalls += 1;
|
||||
return _okResult();
|
||||
}),
|
||||
(Ref ref) => _TestSyncCoordinator(
|
||||
ref,
|
||||
onSyncNow: () async {
|
||||
syncCalls += 1;
|
||||
return _okResult();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const Directionality(
|
||||
@@ -102,7 +107,9 @@ class _TestReachability implements NetworkReachability {
|
||||
bool _current;
|
||||
|
||||
@override
|
||||
Future<bool> isReachable({Duration timeout = const Duration(seconds: 2)}) async {
|
||||
Future<bool> isReachable({
|
||||
Duration timeout = const Duration(seconds: 2),
|
||||
}) async {
|
||||
return _current;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ void main() {
|
||||
],
|
||||
),
|
||||
);
|
||||
final syncState = await container.read(syncQueueRepositoryProvider.future);
|
||||
final syncState = await container.read(
|
||||
syncQueueRepositoryProvider.future,
|
||||
);
|
||||
expect(syncState.lastRejected.length, 1);
|
||||
|
||||
await tester.pumpWidget(
|
||||
|
||||
Reference in New Issue
Block a user