Implement WhatsApp share intake and person quick actions
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user