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:
@@ -0,0 +1,178 @@
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/signal_share_parser.dart';
|
||||
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
|
||||
|
||||
class SharePayloadParser {
|
||||
const SharePayloadParser._();
|
||||
|
||||
static final RegExp _urlPattern = RegExp(
|
||||
r'(https?:\/\/[^\s]+|www\.[^\s]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static SharedPayload parse({
|
||||
required String rawText,
|
||||
required String sourceApp,
|
||||
required String platform,
|
||||
DateTime? createdAt,
|
||||
DateTime? receivedAt,
|
||||
}) {
|
||||
final DateTime now = DateTime.now();
|
||||
final String normalizedSourceApp = _normalizeSourceApp(sourceApp);
|
||||
final String trimmed = rawText.trim();
|
||||
final _ParsedSourceMetadata? sourceMetadata = _parseSourceMetadata(
|
||||
sourceApp: normalizedSourceApp,
|
||||
rawText: trimmed,
|
||||
);
|
||||
final String candidateText = sourceMetadata?.messageText.trim() ?? trimmed;
|
||||
final RegExpMatch? urlMatch = _urlPattern.firstMatch(candidateText);
|
||||
final String? url = urlMatch == null
|
||||
? null
|
||||
: _normalizeUrl(urlMatch.group(0));
|
||||
final String cleanedText = url == null
|
||||
? candidateText
|
||||
: candidateText.replaceFirst(urlMatch!.group(0)!, '').trim();
|
||||
final SharedPayloadType payloadType;
|
||||
if (url != null && cleanedText.isNotEmpty) {
|
||||
payloadType = SharedPayloadType.textWithUrl;
|
||||
} else if (url != null) {
|
||||
payloadType = SharedPayloadType.url;
|
||||
} else {
|
||||
payloadType = SharedPayloadType.text;
|
||||
}
|
||||
|
||||
return SharedPayload(
|
||||
sourceApp: normalizedSourceApp,
|
||||
payloadType: payloadType,
|
||||
rawText: cleanedText,
|
||||
url: url,
|
||||
createdAt: createdAt ?? now,
|
||||
receivedAt: receivedAt ?? now,
|
||||
platform: platform,
|
||||
sourceDisplayName: sourceMetadata?.sourceDisplayName,
|
||||
sourceUserId: sourceMetadata?.sourceUserId,
|
||||
sourceThreadId: sourceMetadata?.sourceThreadId,
|
||||
parsingHints: <String>[
|
||||
if (normalizedSourceApp != 'share_sheet')
|
||||
'source_app_$normalizedSourceApp',
|
||||
if (url != null) 'contains_url',
|
||||
if (sourceMetadata?.sourceDisplayName case final String _)
|
||||
'sender_detected',
|
||||
],
|
||||
metadata: <String, String>{
|
||||
if (url case final String value) 'primaryUrl': value,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String detectSourceApp({
|
||||
required String rawText,
|
||||
String? sourceAppHint,
|
||||
}) {
|
||||
final String normalizedHint = _normalizeSourceApp(sourceAppHint);
|
||||
if (normalizedHint != 'share_sheet') {
|
||||
return normalizedHint;
|
||||
}
|
||||
|
||||
final String trimmed = rawText.trim();
|
||||
if (_shouldUseWhatsAppParser(normalizedHint, trimmed)) {
|
||||
return 'whatsapp';
|
||||
}
|
||||
|
||||
return 'share_sheet';
|
||||
}
|
||||
|
||||
static bool _shouldUseWhatsAppParser(String sourceApp, String rawText) {
|
||||
if (sourceApp.contains('whatsapp')) {
|
||||
return true;
|
||||
}
|
||||
if (sourceApp != 'share_sheet') {
|
||||
return false;
|
||||
}
|
||||
if (rawText.contains(': ') && rawText.length < 4000) {
|
||||
return WhatsAppShareParser.looksLikeWhatsAppMessage(rawText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool _shouldUseSignalParser(String sourceApp) {
|
||||
return sourceApp.contains('signal');
|
||||
}
|
||||
|
||||
static _ParsedSourceMetadata? _parseSourceMetadata({
|
||||
required String sourceApp,
|
||||
required String rawText,
|
||||
}) {
|
||||
if (_shouldUseWhatsAppParser(sourceApp, rawText)) {
|
||||
final WhatsAppSharePayload payload = WhatsAppShareParser.parse(rawText);
|
||||
return _ParsedSourceMetadata(
|
||||
messageText: payload.messageText,
|
||||
sourceDisplayName: payload.sourceDisplayName,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldUseSignalParser(sourceApp) &&
|
||||
SignalShareParser.looksLikeSignalMessage(rawText)) {
|
||||
final SignalSharePayload payload = SignalShareParser.parse(rawText);
|
||||
return _ParsedSourceMetadata(
|
||||
messageText: payload.messageText,
|
||||
sourceDisplayName: payload.sourceDisplayName,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _normalizeSourceApp(String? raw) {
|
||||
final String value = raw?.trim().toLowerCase() ?? '';
|
||||
if (value.isEmpty ||
|
||||
value == 'generic' ||
|
||||
value == 'text' ||
|
||||
value == 'share sheet') {
|
||||
return 'share_sheet';
|
||||
}
|
||||
if (value.contains('whatsapp')) {
|
||||
return 'whatsapp';
|
||||
}
|
||||
if (value.contains('signal')) {
|
||||
return 'signal';
|
||||
}
|
||||
if (value.contains('note')) {
|
||||
return 'notes';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String? _normalizeUrl(String? raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
final String value = raw.trim();
|
||||
if (value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (value.toLowerCase().startsWith('http://') ||
|
||||
value.toLowerCase().startsWith('https://')) {
|
||||
return value;
|
||||
}
|
||||
return 'https://$value';
|
||||
}
|
||||
}
|
||||
|
||||
class _ParsedSourceMetadata {
|
||||
const _ParsedSourceMetadata({
|
||||
required this.messageText,
|
||||
this.sourceDisplayName,
|
||||
this.sourceUserId,
|
||||
this.sourceThreadId,
|
||||
});
|
||||
|
||||
final String messageText;
|
||||
final String? sourceDisplayName;
|
||||
final String? sourceUserId;
|
||||
final String? sourceThreadId;
|
||||
}
|
||||
Reference in New Issue
Block a user