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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user