Implement WhatsApp share intake and person quick actions

This commit is contained in:
Rijad Zuzo
2026-02-19 00:09:28 +01:00
parent 818c1046f3
commit d2205bd3d9
15 changed files with 1463 additions and 4 deletions
@@ -0,0 +1,158 @@
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_repository.dart';
import 'package:relationship_saver/features/people/people_view.dart';
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
/// Listens for share intents and ingests WhatsApp text into local data.
class WhatsAppShareListener extends ConsumerStatefulWidget {
const WhatsAppShareListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<WhatsAppShareListener> createState() =>
_WhatsAppShareListenerState();
}
class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
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(_handleMediaList, onError: (_) {});
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
.instance
.getInitialMedia();
if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia);
}
} catch (_) {
// Unsupported platform/plugin state should not break app usage.
}
}
Future<void> _handleMediaList(List<SharedMediaFile> files) async {
for (final SharedMediaFile file in files) {
final String? rawText = _extractText(file);
if (rawText == null) {
continue;
}
await _handleRawText(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;
}
Future<void> _handleRawText(String rawText) async {
final String trimmed = rawText.trim();
if (trimmed.isEmpty) {
return;
}
final WhatsAppSharePayload parsed = WhatsAppShareParser.parse(trimmed);
if (parsed.messageText.trim().isEmpty) {
return;
}
final String token =
'${parsed.sourceUserId ?? parsed.sourceDisplayName ?? ''}:${parsed.messageText}';
if (_lastToken == token) {
return;
}
_lastToken = token;
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
SharedMessageIngestInput(
sourceApp: 'whatsapp',
messageText: parsed.messageText,
sourceDisplayName: parsed.sourceDisplayName,
sourceUserId: parsed.sourceUserId,
sourceThreadId: parsed.sourceThreadId,
sharedAt: DateTime.now(),
),
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.createdProfile
? 'Imported from WhatsApp and created profile ${result.profileName}.'
: 'Imported from WhatsApp to ${result.profileName}.',
),
action: SnackBarAction(
label: 'Open People',
onPressed: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const PeopleView(),
),
);
},
),
),
);
try {
await ReceiveSharingIntent.instance.reset();
} catch (_) {
// Best effort.
}
}
}
@@ -0,0 +1,78 @@
class WhatsAppSharePayload {
const WhatsAppSharePayload({
required this.messageText,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
});
final String messageText;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
}
/// Parses raw shared text from WhatsApp into normalized fields.
class WhatsAppShareParser {
const WhatsAppShareParser._();
static final RegExp _datePrefixPattern = RegExp(
r'^\[[^\]]+\]\s*([^:\n]{2,80}):\s*(.+)$',
dotAll: true,
);
static final RegExp _namePrefixPattern = RegExp(
r'^([^:\n]{2,80}):\s*(.+)$',
dotAll: true,
);
static WhatsAppSharePayload parse(String raw) {
final String input = raw.trim();
if (input.isEmpty) {
return const WhatsAppSharePayload(messageText: '');
}
String? senderName;
String messageText = input;
final RegExpMatch? dateMatch = _datePrefixPattern.firstMatch(input);
if (dateMatch != null) {
senderName = _cleanSender(dateMatch.group(1));
messageText = dateMatch.group(2)?.trim() ?? input;
} else {
final RegExpMatch? nameMatch = _namePrefixPattern.firstMatch(input);
if (nameMatch != null) {
senderName = _cleanSender(nameMatch.group(1));
messageText = nameMatch.group(2)?.trim() ?? input;
}
}
final String? sourceUserId = _normalizedKey(senderName);
return WhatsAppSharePayload(
messageText: messageText,
sourceDisplayName: senderName,
sourceUserId: sourceUserId,
sourceThreadId: null,
);
}
static String? _cleanSender(String? input) {
if (input == null) {
return null;
}
final String cleaned = input.trim();
return cleaned.isEmpty ? null : cleaned;
}
static String? _normalizedKey(String? input) {
if (input == null) {
return null;
}
final String key = input
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
return key.isEmpty ? null : 'wa:$key';
}
}