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,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/llm/captured_fact_draft_parser.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
void main() {
test('parses a JSON suggestion into a captured draft', () {
final SharedPayload payload = SharedPayload(
sourceApp: 'signal',
payloadType: SharedPayloadType.text,
rawText: 'Mila birthday is on 2026-05-20',
createdAt: DateTime.utc(2026, 4, 17),
receivedAt: DateTime.utc(2026, 4, 17),
platform: 'android',
);
final CapturedFactDraft? result = parseCapturedFactDraftSuggestion(
'{"type":"importantDate","text":"Birthday to remember","label":"Birthday","dateValue":"2026-05-20","confidence":0.88,"isSensitive":false}',
fallbackPayload: payload,
);
expect(result, isNotNull);
expect(result!.type, CapturedFactType.importantDate);
expect(result.label, 'Birthday');
expect(result.dateValue, DateTime(2026, 5, 20));
expect(result.confidence, 0.88);
expect(result.needsReview, isTrue);
});
test('falls back to payload text when suggestion text is missing', () {
final SharedPayload payload = SharedPayload(
sourceApp: 'signal',
payloadType: SharedPayloadType.text,
rawText: 'Mila birthday is on 2026-05-20',
createdAt: DateTime.utc(2026, 4, 17),
receivedAt: DateTime.utc(2026, 4, 17),
platform: 'android',
);
final CapturedFactDraft? result = parseCapturedFactDraftSuggestion(
'{"type":"note","label":""}',
fallbackPayload: payload,
);
expect(result, isNotNull);
expect(result!.text, payload.rawText);
expect(result.label, isNull);
});
test('returns null for non-json responses', () {
final SharedPayload payload = SharedPayload(
sourceApp: 'signal',
payloadType: SharedPayloadType.text,
rawText: 'Mila birthday is on 2026-05-20',
createdAt: DateTime.utc(2026, 4, 17),
receivedAt: DateTime.utc(2026, 4, 17),
platform: 'android',
);
final CapturedFactDraft? result = parseCapturedFactDraftSuggestion(
'No structured output available.',
fallbackPayload: payload,
);
expect(result, isNull);
});
}
@@ -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;
}
+1 -1
View File
@@ -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(