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,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'),
),
],
);
}
}