Refine backendless share intake

This commit is contained in:
Rijad Zuzo
2026-05-18 20:33:54 +02:00
parent f655adfbea
commit 42a59e959f
37 changed files with 1467 additions and 824 deletions
@@ -6,6 +6,7 @@ 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/application/share_intake_debug_log.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
@@ -23,6 +24,7 @@ class IncomingShareListener extends ConsumerStatefulWidget {
class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
String? _lastToken;
final ShareIntakeDebugLog _debugLog = const ShareIntakeDebugLog();
@override
void initState() {
@@ -41,25 +43,44 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
Future<void> _start() async {
if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) {
await _debugLog.record(
'listener_skipped',
'enabled=${AppConfig.enableWhatsAppShareIntake}, kIsWeb=$kIsWeb',
);
return;
}
try {
await _debugLog.record('listener_start', 'Subscribing to share intents.');
_mediaSubscription = ReceiveSharingIntent.instance
.getMediaStream()
.listen(
(List<SharedMediaFile> files) =>
_handleMediaList(files, autoOpenDestination: false),
onError: (_) {},
(List<SharedMediaFile> files) {
unawaited(
_debugLog.record(
'media_stream',
'Received ${files.length} shared item(s).',
),
);
unawaited(_handleMediaList(files, autoOpenDestination: false));
},
onError: (Object error) {
unawaited(_debugLog.record('media_stream_error', '$error'));
},
);
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
.instance
.getInitialMedia();
await _debugLog.record(
'initial_media',
'Received ${initialMedia.length} initial shared item(s).',
);
if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia, autoOpenDestination: true);
}
} catch (_) {
} catch (error) {
await _debugLog.record('listener_error', '$error');
// Unsupported platform/plugin state should not break app usage.
}
}
@@ -69,8 +90,16 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
required bool autoOpenDestination,
}) async {
for (final SharedMediaFile file in files) {
await _debugLog.record(
'media_item',
'type=${file.type.name}, mime=${file.mimeType ?? 'none'}, pathLength=${file.path.length}, messageLength=${file.message?.length ?? 0}',
);
final String? rawText = _extractText(file);
if (rawText == null) {
await _debugLog.record(
'extract_skip',
'No usable text for type=${file.type.name}.',
);
continue;
}
await _handleRawText(
@@ -129,12 +158,21 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
sourceAppHint: sourceAppHint,
);
if (payload == null) {
await _debugLog.record(
'payload_skip',
'Parser returned null for rawLength=${trimmed.length}.',
);
return;
}
await _debugLog.record(
'payload_parsed',
'source=${payload.sourceApp}, type=${payload.payloadType.name}, rawLength=${payload.rawText.length}, hasUrl=${payload.url != null}, detectedSender=${payload.sourceDisplayName != null}',
);
final String token =
'${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}';
if (_lastToken == token) {
await _debugLog.record('payload_duplicate', 'Ignored duplicate share.');
return;
}
_lastToken = token;
@@ -149,8 +187,17 @@ class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
);
if (!mounted || result == null) {
await _debugLog.record('review_cancelled', 'Share review closed.');
return;
}
unawaited(
_debugLog.record(
'review_result',
result.isQueuedForResolution
? 'Queued for inbox.'
: 'Saved to profile; createdProfile=${result.createdProfile}.',
),
);
if (autoOpenDestination) {
openShareCaptureDestination(context, ref: ref, result: result);
@@ -88,12 +88,12 @@ class _ShareCaptureReviewSheetState
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Save Shared Capture',
'Save Shared Text',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 6),
Text(
'Review the incoming text or link, then assign it safely.',
'Choose who this belongs to, or keep it in the inbox for later review.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
@@ -101,10 +101,7 @@ class _ShareCaptureReviewSheetState
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,
),
Text('Type', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
@@ -344,9 +341,7 @@ class _ShareCaptureReviewSheetState
await showDialog<_CreatePersonFromShareDraft>(
context: context,
builder: (BuildContext context) => _CreatePersonFromShareDialog(
initialName:
widget.payload.sourceDisplayName ??
widget.payload.rawText.split(RegExp(r'\s+')).take(2).join(' '),
detectedName: widget.payload.sourceDisplayName,
),
);
if (draft == null) {
@@ -445,7 +440,7 @@ class _PayloadPreview extends StatelessWidget {
if (payload.sourceDisplayName != null) ...<Widget>[
const SizedBox(height: 4),
Text(
'Sender: ${payload.sourceDisplayName}',
'Detected in text: ${payload.sourceDisplayName}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
@@ -484,9 +479,9 @@ class _CreatePersonFromShareDraft {
}
class _CreatePersonFromShareDialog extends StatefulWidget {
const _CreatePersonFromShareDialog({required this.initialName});
const _CreatePersonFromShareDialog({this.detectedName});
final String initialName;
final String? detectedName;
@override
State<_CreatePersonFromShareDialog> createState() =>
@@ -504,7 +499,7 @@ class _CreatePersonFromShareDialogState
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_nameController = TextEditingController();
}
@override
@@ -523,9 +518,21 @@ class _CreatePersonFromShareDialogState
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.detectedName?.trim().isNotEmpty == true) ...<Widget>[
Text(
'Detected message author: ${widget.detectedName!.trim()}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 10),
],
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: const InputDecoration(
labelText: 'Person name',
hintText: 'Enter the real contact',
),
),
const SizedBox(height: 10),
TextField(
@@ -4,6 +4,8 @@ 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/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_capture_review_sheet.dart';
import 'package:relationship_saver/features/share_intake/share_payload_parser.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
/// Review unresolved shared messages and map them to a person profile.
@@ -45,13 +47,25 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Share Inbox',
style: Theme.of(context).textTheme.headlineMedium,
Wrap(
spacing: 12,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Text(
'Capture',
style: Theme.of(context).textTheme.headlineMedium,
),
FilledButton.icon(
onPressed: _submitting ? null : _addTextCapture,
icon: const Icon(Icons.edit_note_rounded),
label: const Text('Add Text'),
),
],
),
const SizedBox(height: 6),
Text(
'Resolve shared messages that could not be matched confidently.',
'Share messages here, paste copied text, then choose or create the right person.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
@@ -59,7 +73,9 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
const SizedBox(height: 14),
Expanded(
child: localState.sharedInbox.isEmpty
? _EmptyInbox(compact: compact)
? SingleChildScrollView(
child: _EmptyInbox(compact: compact),
)
: ListView.separated(
itemCount: localState.sharedInbox.length,
separatorBuilder: (_, _) =>
@@ -178,13 +194,8 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
}
final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>(
context: context,
builder: (BuildContext context) => _CreateProfileDialog(
initialName:
entry.sourceDisplayName ??
entry.sourceUserId ??
entry.sourceThreadId ??
'',
),
builder: (BuildContext context) =>
_CreateProfileDialog(detectedName: entry.sourceDisplayName),
);
if (draft == null || !mounted) {
return;
@@ -232,6 +243,38 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
});
}
Future<void> _addTextCapture() async {
if (_submitting) {
return;
}
final String? rawText = await showDialog<String>(
context: context,
builder: (BuildContext context) => const _AddTextDialog(),
);
if (rawText == null || rawText.trim().isEmpty || !mounted) {
return;
}
final SharedPayload payload = SharePayloadParser.parse(
rawText: rawText,
sourceApp: 'manual',
platform: 'manual',
createdAt: DateTime.now(),
receivedAt: DateTime.now(),
);
final SharedMessageIngestResult? result = await showShareCaptureReviewSheet(
context,
ref: ref,
payload: payload,
);
if (result == null || !mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(_captureResultMessage(result))));
}
Future<void> _runRepositoryAction(Future<void> Function() action) async {
setState(() {
_submitting = true;
@@ -453,6 +496,60 @@ class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
}
}
class _AddTextDialog extends StatefulWidget {
const _AddTextDialog();
@override
State<_AddTextDialog> createState() => _AddTextDialogState();
}
class _AddTextDialogState extends State<_AddTextDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add Text'),
content: SizedBox(
width: 520,
child: TextField(
controller: _controller,
autofocus: true,
minLines: 5,
maxLines: 10,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
labelText: 'Shared or copied text',
hintText: 'Paste a message, note, link, preference, or date...',
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_controller.text),
child: const Text('Review'),
),
],
);
}
}
class _CandidateReview {
const _CandidateReview({
required this.person,
@@ -1073,6 +1170,16 @@ String _factTypeLabel(CapturedFactType type) {
};
}
String _captureResultMessage(SharedMessageIngestResult result) {
if (result.isQueuedForResolution) {
return 'Saved to Capture inbox.';
}
if (result.createdProfile) {
return 'Saved and created ${result.profileName}.';
}
return 'Saved to ${result.profileName}.';
}
class _EmptyInbox extends StatelessWidget {
const _EmptyInbox({required this.compact});
@@ -1103,7 +1210,7 @@ class _EmptyInbox extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
'Incoming shared captures that need manual person mapping or fact triage will appear here.',
'Share a message into the app or use Add Text to start building your private relationship notes.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
@@ -1132,9 +1239,9 @@ class _CreateProfileDraft {
}
class _CreateProfileDialog extends StatefulWidget {
const _CreateProfileDialog({required this.initialName});
const _CreateProfileDialog({this.detectedName});
final String initialName;
final String? detectedName;
@override
State<_CreateProfileDialog> createState() => _CreateProfileDialogState();
@@ -1149,7 +1256,7 @@ class _CreateProfileDialogState extends State<_CreateProfileDialog> {
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
_nameController = TextEditingController();
_relationshipController = TextEditingController(text: 'Contact');
_locationController = TextEditingController();
_aliasesController = TextEditingController();
@@ -1172,9 +1279,21 @@ class _CreateProfileDialogState extends State<_CreateProfileDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.detectedName?.trim().isNotEmpty == true) ...<Widget>[
Text(
'Detected message author: ${widget.detectedName!.trim()}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
],
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: const InputDecoration(
labelText: 'Person name',
hintText: 'Enter the real contact',
),
),
const SizedBox(height: 8),
TextField(