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
@@ -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);