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 Presentation
The listener widgets, review sheet, and inbox UI live here. Use this folder when
you want to change the share-capture UX without touching parsing or persistence.
@@ -0,0 +1,176 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
/// Listens for native share intents and routes incoming content through review.
class IncomingShareListener extends ConsumerStatefulWidget {
const IncomingShareListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<IncomingShareListener> createState() =>
_IncomingShareListenerState();
}
class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
String? _lastToken;
@override
void initState() {
super.initState();
_start();
}
@override
void dispose() {
_mediaSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _start() async {
if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) {
return;
}
try {
_mediaSubscription = ReceiveSharingIntent.instance
.getMediaStream()
.listen(
(List<SharedMediaFile> files) =>
_handleMediaList(files, autoOpenDestination: false),
onError: (_) {},
);
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
.instance
.getInitialMedia();
if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia, autoOpenDestination: true);
}
} catch (_) {
// Unsupported platform/plugin state should not break app usage.
}
}
Future<void> _handleMediaList(
List<SharedMediaFile> files, {
required bool autoOpenDestination,
}) async {
for (final SharedMediaFile file in files) {
final String? rawText = _extractText(file);
if (rawText == null) {
continue;
}
await _handleRawText(
rawText,
autoOpenDestination: autoOpenDestination,
sourceAppHint: _inferSourceAppHint(file, rawText),
);
}
}
String? _extractText(SharedMediaFile file) {
if (file.type == SharedMediaType.text) {
final String text = file.path.trim();
if (text.isNotEmpty) {
return text;
}
}
final String? mimeType = file.mimeType?.toLowerCase();
if (mimeType != null && mimeType.startsWith('text/')) {
final String text = file.path.trim();
if (text.isNotEmpty) {
return text;
}
}
final String? message = file.message?.trim();
if (message != null && message.isNotEmpty) {
return message;
}
return null;
}
String? _inferSourceAppHint(SharedMediaFile file, String rawText) {
final String? mimeType = file.mimeType?.toLowerCase();
if (mimeType == 'text/plain' && rawText.contains('Notes')) {
return 'notes';
}
return null;
}
Future<void> _handleRawText(
String rawText, {
required bool autoOpenDestination,
String? sourceAppHint,
}) async {
final String trimmed = rawText.trim();
if (trimmed.isEmpty) {
return;
}
final SharedPayload? payload = buildSharedPayloadFromRawText(
rawText: trimmed,
platform: defaultTargetPlatform.name,
sourceAppHint: sourceAppHint,
);
if (payload == null) {
return;
}
final String token =
'${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}';
if (_lastToken == token) {
return;
}
_lastToken = token;
if (!mounted) {
return;
}
final SharedMessageIngestResult? result = await startShareCaptureReview(
context,
ref: ref,
payload: payload,
);
if (!mounted || result == null) {
return;
}
if (autoOpenDestination) {
openShareCaptureDestination(context, ref: ref, result: result);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(shareCaptureResultMessage(result)),
action: SnackBarAction(
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
onPressed: () =>
openShareCaptureDestination(context, ref: ref, result: result),
),
),
);
try {
await ReceiveSharingIntent.instance.reset();
} catch (_) {
// Best effort.
}
}
}
@@ -0,0 +1,574 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
Future<SharedMessageIngestResult?> showShareCaptureReviewSheet(
BuildContext context, {
required WidgetRef ref,
required SharedPayload payload,
String? initialPersonId,
}) {
return showModalBottomSheet<SharedMessageIngestResult>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (BuildContext context) {
return _ShareCaptureReviewSheet(
payload: payload,
initialPersonId: initialPersonId,
);
},
);
}
class _ShareCaptureReviewSheet extends ConsumerStatefulWidget {
const _ShareCaptureReviewSheet({required this.payload, this.initialPersonId});
final SharedPayload payload;
final String? initialPersonId;
@override
ConsumerState<_ShareCaptureReviewSheet> createState() =>
_ShareCaptureReviewSheetState();
}
class _ShareCaptureReviewSheetState
extends ConsumerState<_ShareCaptureReviewSheet> {
late final TextEditingController _textController;
late final TextEditingController _labelController;
late CapturedFactType _type;
String? _selectedPersonId;
DateTime? _selectedDate;
late bool _isSensitive;
bool _submitting = false;
@override
void initState() {
super.initState();
final CapturedFactDraft suggestedDraft =
ShareCaptureDraftSuggester.suggestForPayload(widget.payload);
_selectedPersonId = widget.initialPersonId;
_type = suggestedDraft.type;
_selectedDate = suggestedDraft.dateValue;
_isSensitive = suggestedDraft.isSensitive;
_textController = TextEditingController(text: suggestedDraft.text);
_labelController = TextEditingController(text: suggestedDraft.label ?? '');
}
@override
void dispose() {
_textController.dispose();
_labelController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState state) {
final List<PersonProfile> people = state.people.toList(growable: false)
..sort(_recentPeopleSort);
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Save Shared Capture',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 6),
Text(
'Review the incoming text or link, then assign it safely.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 14),
_PayloadPreview(payload: widget.payload),
const SizedBox(height: 14),
Text(
'What kind of info is this?',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: CapturedFactType.values
.map((CapturedFactType type) {
return ChoiceChip(
label: Text(_factTypeLabel(type)),
selected: _type == type,
onSelected: (_) {
setState(() {
_type = type;
});
},
);
})
.toList(growable: false),
),
const SizedBox(height: 14),
TextField(
controller: _textController,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
labelText: _type == CapturedFactType.importantDate
? 'Date note'
: 'Captured text',
hintText:
'Adjust the note, preference, or idea before saving.',
),
),
const SizedBox(height: 10),
TextField(
controller: _labelController,
decoration: InputDecoration(
labelText: _type == CapturedFactType.importantDate
? 'Date label'
: 'Optional label',
),
),
if (_type == CapturedFactType.importantDate) ...<Widget>[
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _pickDate,
icon: const Icon(Icons.event_outlined),
label: Text(
_selectedDate == null
? 'Choose date'
: 'Date: ${_dateLabel(_selectedDate!)}',
),
),
],
const SizedBox(height: 12),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Mark as sensitive'),
subtitle: const Text(
'Useful for future privacy controls and review.',
),
value: _isSensitive,
onChanged: (bool value) {
setState(() {
_isSensitive = value;
});
},
),
const SizedBox(height: 8),
Text(
'Assign to person',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (people.isEmpty)
Text(
'No people yet. Create one or save this to the inbox.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: people
.take(6)
.map((PersonProfile person) {
return ChoiceChip(
label: Text(person.name),
selected: _selectedPersonId == person.id,
onSelected: (_) {
setState(() {
_selectedPersonId = person.id;
});
},
);
})
.toList(growable: false),
),
if (people.isNotEmpty) ...<Widget>[
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: _selectedPersonId,
decoration: const InputDecoration(
labelText: 'Or choose from all people',
),
items: people
.map(
(PersonProfile person) => DropdownMenuItem<String>(
value: person.id,
child: Text(
'${person.name}${person.relationship}',
),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
),
],
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: OutlinedButton(
onPressed: _submitting ? null : _saveToInbox,
child: const Text('Save To Inbox'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.tonal(
onPressed: _submitting ? null : _createPerson,
child: const Text('Create Person'),
),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting || _selectedPersonId == null
? null
: _saveToSelectedPerson,
child: const Text('Save To Person'),
),
),
],
),
),
);
},
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
error: (_, _) => const Padding(
padding: EdgeInsets.all(24),
child: Text('Unable to load people for share review.'),
),
);
}
int _recentPeopleSort(PersonProfile a, PersonProfile b) {
final DateTime? left = a.lastInteractedAt ?? a.lastUpdatedAt;
final DateTime? right = b.lastInteractedAt ?? b.lastUpdatedAt;
if (left == null && right != null) {
return 1;
}
if (left != null && right == null) {
return -1;
}
if (left != null && right != null) {
final int byDate = right.compareTo(left);
if (byDate != 0) {
return byDate;
}
}
return b.affinityScore.compareTo(a.affinityScore);
}
CapturedFactDraft _buildDraft() {
return CapturedFactDraft(
type: _type,
text: _textController.text.trim(),
label: _labelController.text.trim().isEmpty
? null
: _labelController.text.trim(),
dateValue: _selectedDate,
isSensitive: _isSensitive,
needsReview: false,
);
}
Future<void> _saveToSelectedPerson() async {
final String? personId = _selectedPersonId;
if (personId == null) {
return;
}
await _runAction(() async {
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.captureSharedPayloadToExistingProfile(
payload: widget.payload,
profileId: personId,
draft: _buildDraft(),
resolvedAutomatically: false,
);
if (!mounted) {
return;
}
Navigator.of(context).pop(result);
});
}
Future<void> _saveToInbox() async {
await _runAction(() async {
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.saveSharedPayloadToInbox(
payload: widget.payload,
draft: _buildDraft(),
targetPersonId: _selectedPersonId,
);
if (!mounted) {
return;
}
Navigator.of(context).pop(result);
});
}
Future<void> _createPerson() async {
final _CreatePersonFromShareDraft? draft =
await showDialog<_CreatePersonFromShareDraft>(
context: context,
builder: (BuildContext context) => _CreatePersonFromShareDialog(
initialName:
widget.payload.sourceDisplayName ??
widget.payload.rawText.split(RegExp(r'\s+')).take(2).join(' '),
),
);
if (draft == null) {
return;
}
await _runAction(() async {
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.captureSharedPayloadByCreatingProfile(
payload: widget.payload,
name: draft.name,
relationship: draft.relationship,
aliases: draft.aliases,
draft: _buildDraft(),
);
if (!mounted) {
return;
}
Navigator.of(context).pop(result);
});
}
Future<void> _pickDate() async {
final DateTime now = DateTime.now();
final DateTime? date = await showDatePicker(
context: context,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 10),
initialDate: _selectedDate ?? now,
);
if (date == null) {
return;
}
setState(() {
_selectedDate = date;
});
}
Future<void> _runAction(Future<void> Function() action) async {
setState(() {
_submitting = true;
});
try {
await action();
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
}
String _factTypeLabel(CapturedFactType type) {
return switch (type) {
CapturedFactType.note => 'Note',
CapturedFactType.like => 'Like',
CapturedFactType.dislike => 'Dislike',
CapturedFactType.importantDate => 'Date',
CapturedFactType.giftIdea => 'Gift',
CapturedFactType.placeIdea => 'Place',
CapturedFactType.activityIdea => 'Activity',
CapturedFactType.misc => 'Misc',
};
}
String _dateLabel(DateTime value) {
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
}
class _PayloadPreview extends StatelessWidget {
const _PayloadPreview({required this.payload});
final SharedPayload payload;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE5EDF2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
payload.sourceApp.toUpperCase(),
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
),
if (payload.sourceDisplayName != null) ...<Widget>[
const SizedBox(height: 4),
Text(
'Sender: ${payload.sourceDisplayName}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
if (payload.rawText.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 8),
Text(payload.rawText.trim()),
],
if (payload.url != null) ...<Widget>[
const SizedBox(height: 8),
SelectableText(
payload.url!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.primary,
decoration: TextDecoration.underline,
),
),
],
],
),
);
}
}
class _CreatePersonFromShareDraft {
const _CreatePersonFromShareDraft({
required this.name,
required this.relationship,
required this.aliases,
});
final String name;
final String relationship;
final List<String> aliases;
}
class _CreatePersonFromShareDialog extends StatefulWidget {
const _CreatePersonFromShareDialog({required this.initialName});
final String initialName;
@override
State<_CreatePersonFromShareDialog> createState() =>
_CreatePersonFromShareDialogState();
}
class _CreatePersonFromShareDialogState
extends State<_CreatePersonFromShareDialog> {
late final TextEditingController _nameController;
final TextEditingController _relationshipController = TextEditingController(
text: 'Contact',
);
final TextEditingController _aliasesController = TextEditingController();
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
}
@override
void dispose() {
_nameController.dispose();
_relationshipController.dispose();
_aliasesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Create Person'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 10),
TextField(
controller: _relationshipController,
decoration: const InputDecoration(labelText: 'Relationship'),
),
const SizedBox(height: 10),
TextField(
controller: _aliasesController,
decoration: const InputDecoration(
labelText: 'Aliases (comma separated)',
),
),
],
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String name = _nameController.text.trim();
final String relationship = _relationshipController.text.trim();
if (name.isEmpty || relationship.isEmpty) {
return;
}
Navigator.of(context).pop(
_CreatePersonFromShareDraft(
name: name,
relationship: relationship,
aliases: _aliasesController.text
.split(',')
.map((String value) => value.trim())
.where((String value) => value.isNotEmpty)
.toList(growable: false),
),
);
},
child: const Text('Create'),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
// Legacy compatibility export for the generic incoming share listener.
export 'package:relationship_saver/features/share_intake/presentation/incoming_share_listener.dart';