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,4 @@
# Share Intake Application
This layer coordinates share-entry routing and the review flow. If content comes
in from outside the app, this folder decides how it enters the local workflow.
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/people/presentation/people_view.dart';
import 'package:relationship_saver/features/people/presentation/providers/people_ui_state.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_capture_review_sheet.dart';
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
import 'package:relationship_saver/features/share_intake/share_payload_parser.dart';
SharedPayload? buildSharedPayloadFromRawText({
required String rawText,
required String platform,
String? sourceAppHint,
}) {
final String trimmed = rawText.trim();
if (trimmed.isEmpty) {
return null;
}
final String sourceApp = SharePayloadParser.detectSourceApp(
rawText: trimmed,
sourceAppHint: sourceAppHint,
);
final SharedPayload payload = SharePayloadParser.parse(
rawText: trimmed,
sourceApp: sourceApp,
platform: platform,
createdAt: DateTime.now(),
receivedAt: DateTime.now(),
);
if (payload.rawText.trim().isEmpty && (payload.url?.trim().isEmpty ?? true)) {
return null;
}
return payload;
}
Future<SharedMessageIngestResult?> startShareCaptureReview(
BuildContext context, {
required WidgetRef ref,
required SharedPayload payload,
}) {
return showShareCaptureReviewSheet(
context,
ref: ref,
payload: payload,
initialPersonId: ref.read(selectedPersonIdProvider),
);
}
void openShareCaptureDestination(
BuildContext context, {
required WidgetRef ref,
required SharedMessageIngestResult result,
}) {
if (result.isQueuedForResolution) {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const ShareInboxView(),
),
);
return;
}
if (result.profileId != null) {
ref.read(selectedPersonIdProvider.notifier).select(result.profileId!);
}
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const PeopleView(),
),
);
}
String shareCaptureResultMessage(SharedMessageIngestResult result) {
if (result.isQueuedForResolution) {
return 'Saved shared capture to inbox.';
}
if (result.createdProfile) {
return 'Saved shared capture and created ${result.profileName}.';
}
return 'Saved shared capture to ${result.profileName}.';
}
@@ -0,0 +1,60 @@
class SharedMessageIngestInput {
const SharedMessageIngestInput({
required this.sourceApp,
required this.messageText,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
this.sharedAt,
});
final String sourceApp;
final String messageText;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
final DateTime? sharedAt;
}
enum SharedMessageIngestStatus { imported, queuedForResolution }
class SharedMessageIngestResult {
const SharedMessageIngestResult({
required this.status,
required this.createdProfile,
required this.createdSourceLink,
this.profileId,
this.profileName,
this.momentId,
this.inboxEntryId,
});
const SharedMessageIngestResult.imported({
required this.profileId,
required this.profileName,
required this.createdProfile,
required this.createdSourceLink,
required this.momentId,
}) : status = SharedMessageIngestStatus.imported,
inboxEntryId = null;
const SharedMessageIngestResult.queuedForResolution({
required this.inboxEntryId,
}) : status = SharedMessageIngestStatus.queuedForResolution,
createdProfile = false,
createdSourceLink = false,
profileId = null,
profileName = null,
momentId = null;
final SharedMessageIngestStatus status;
final String? profileId;
final String? profileName;
final bool createdProfile;
final bool createdSourceLink;
final String? momentId;
final String? inboxEntryId;
bool get isQueuedForResolution =>
status == SharedMessageIngestStatus.queuedForResolution;
}