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,4 @@
# Share Intake Domain
Normalized payloads, inbox entries, fact drafts, and parsing helpers live here.
Start here when changing how raw shared content becomes structured app data.
@@ -0,0 +1,232 @@
import 'package:relationship_saver/features/local/local_models.dart';
class ShareCaptureDraftSuggester {
const ShareCaptureDraftSuggester._();
static final RegExp _birthdayPattern = RegExp(
r'\bbirthday\b',
caseSensitive: false,
);
static final RegExp _anniversaryPattern = RegExp(
r'\banniversary\b',
caseSensitive: false,
);
static final RegExp _giftPattern = RegExp(
r'\b(gift|present|flowers|scarf|book for her|book for him)\b',
caseSensitive: false,
);
static final RegExp _placePattern = RegExp(
r'\b(restaurant|cafe|coffee shop|bar|bistro|museum|park|hotel|beach|address|reservation)\b',
caseSensitive: false,
);
static final RegExp _activityPattern = RegExp(
r'\b(concert|movie|hike|walk|trip|class|event|show|plan|weekend|tickets?)\b',
caseSensitive: false,
);
static final RegExp _negativePreferencePattern = RegExp(
r"\b(hates?|dislikes?|allergic to|doesn't like|does not like|dont like)\b",
caseSensitive: false,
);
static final RegExp _positivePreferencePattern = RegExp(
r'\b(loves?|likes?|favorite|prefers?|enjoys?)\b',
caseSensitive: false,
);
static final RegExp _isoDatePattern = RegExp(
r'\b(\d{4})-(\d{1,2})-(\d{1,2})\b',
);
static final RegExp _dotDatePattern = RegExp(
r'\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b',
);
static final RegExp _monthNameDatePattern = RegExp(
r'\b(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})(?:,\s*(\d{4}))?\b',
caseSensitive: false,
);
static CapturedFactDraft suggestForPayload(SharedPayload payload) {
final String text = payload.rawText.trim().isNotEmpty
? payload.rawText.trim()
: (payload.url ?? '').trim();
final String normalized = text.toLowerCase();
final Uri? uri = payload.url == null ? null : Uri.tryParse(payload.url!);
final String host = uri?.host.toLowerCase() ?? '';
final _SuggestedImportantDate? importantDate = _suggestImportantDate(text);
if (importantDate != null) {
return CapturedFactDraft(
type: CapturedFactType.importantDate,
text: importantDate.label,
label: importantDate.label,
dateValue: importantDate.date,
isSensitive: false,
needsReview: true,
);
}
if (_giftPattern.hasMatch(normalized)) {
return CapturedFactDraft(
type: CapturedFactType.giftIdea,
text: text,
label: 'Gift idea',
isSensitive: false,
needsReview: true,
);
}
if (_looksLikePlace(normalized, host)) {
return CapturedFactDraft(
type: CapturedFactType.placeIdea,
text: text,
label: 'Place idea',
isSensitive: false,
needsReview: true,
);
}
if (_looksLikeActivity(normalized, host)) {
return CapturedFactDraft(
type: CapturedFactType.activityIdea,
text: text,
label: 'Activity idea',
isSensitive: false,
needsReview: true,
);
}
if (_negativePreferencePattern.hasMatch(normalized)) {
return CapturedFactDraft(
type: CapturedFactType.dislike,
text: text,
label: 'Preference',
isSensitive: normalized.contains('allergic'),
needsReview: true,
);
}
if (_positivePreferencePattern.hasMatch(normalized)) {
return CapturedFactDraft(
type: CapturedFactType.like,
text: text,
label: 'Preference',
isSensitive: false,
needsReview: true,
);
}
return CapturedFactDraft(
type: CapturedFactType.note,
text: text,
label: payload.url == null ? null : 'Shared link',
isSensitive: false,
needsReview: payload.url != null,
);
}
static bool _looksLikePlace(String normalized, String host) {
if (_placePattern.hasMatch(normalized)) {
return true;
}
return host.contains('maps.apple.com') ||
host.contains('google.com') && normalized.contains('maps') ||
host.contains('tripadvisor.') ||
host.contains('opentable.') ||
host.contains('thefork.');
}
static bool _looksLikeActivity(String normalized, String host) {
if (_activityPattern.hasMatch(normalized)) {
return true;
}
return host.contains('eventbrite.') ||
host.contains('meetup.') ||
host.contains('ticketmaster.');
}
static _SuggestedImportantDate? _suggestImportantDate(String text) {
final DateTime? date = _parseDate(text);
if (date == null) {
return null;
}
final String normalized = text.toLowerCase();
if (_birthdayPattern.hasMatch(normalized)) {
return _SuggestedImportantDate(date: date, label: 'Birthday');
}
if (_anniversaryPattern.hasMatch(normalized)) {
return _SuggestedImportantDate(date: date, label: 'Anniversary');
}
return null;
}
static DateTime? _parseDate(String text) {
final RegExpMatch? isoMatch = _isoDatePattern.firstMatch(text);
if (isoMatch != null) {
return _safeDate(
int.parse(isoMatch.group(1)!),
int.parse(isoMatch.group(2)!),
int.parse(isoMatch.group(3)!),
);
}
final RegExpMatch? dotMatch = _dotDatePattern.firstMatch(text);
if (dotMatch != null) {
final int year = int.parse(dotMatch.group(3)!);
return _safeDate(
year < 100 ? 2000 + year : year,
int.parse(dotMatch.group(2)!),
int.parse(dotMatch.group(1)!),
);
}
final RegExpMatch? monthNameMatch = _monthNameDatePattern.firstMatch(text);
if (monthNameMatch != null) {
final int? month = _monthNameToInt(monthNameMatch.group(1)!);
if (month == null) {
return null;
}
final int day = int.parse(monthNameMatch.group(2)!);
final int year = monthNameMatch.group(3) == null
? DateTime.now().year
: int.parse(monthNameMatch.group(3)!);
return _safeDate(year, month, day);
}
return null;
}
static DateTime? _safeDate(int year, int month, int day) {
try {
final DateTime value = DateTime(year, month, day);
if (value.year != year || value.month != month || value.day != day) {
return null;
}
return value;
} catch (_) {
return null;
}
}
static int? _monthNameToInt(String input) {
return switch (input.toLowerCase()) {
'january' => 1,
'february' => 2,
'march' => 3,
'april' => 4,
'may' => 5,
'june' => 6,
'july' => 7,
'august' => 8,
'september' => 9,
'october' => 10,
'november' => 11,
'december' => 12,
_ => null,
};
}
}
class _SuggestedImportantDate {
const _SuggestedImportantDate({required this.date, required this.label});
final DateTime date;
final String label;
}
@@ -0,0 +1,456 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Supported raw payload shapes entering the app from the share pipeline.
enum SharedPayloadType { text, url, textWithUrl }
/// User-facing assignment outcome for a shared payload.
enum ShareAssignmentStatus {
needsPersonSelection,
assignedDirectly,
savedToInbox,
discarded,
}
/// Structured capture types that shared or manual content can resolve into.
enum CapturedFactType {
note,
like,
dislike,
importantDate,
giftIdea,
placeIdea,
activityIdea,
misc,
}
/// Tracks how a fact or date entered local storage.
enum CaptureSourceKind { manual, shareSheet, shareInbox }
/// Normalized input payload shared from another app.
@immutable
class SharedPayload {
const SharedPayload({
required this.sourceApp,
required this.payloadType,
required this.rawText,
required this.createdAt,
required this.receivedAt,
required this.platform,
this.url,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
this.parsingHints = const <String>[],
this.metadata = const <String, String>{},
this.dedupeKey,
});
final String sourceApp;
final SharedPayloadType payloadType;
final String rawText;
final String? url;
final DateTime createdAt;
final DateTime receivedAt;
final String platform;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
final List<String> parsingHints;
final Map<String, String> metadata;
final String? dedupeKey;
SharedPayload copyWith({
String? sourceApp,
SharedPayloadType? payloadType,
String? rawText,
String? url,
DateTime? createdAt,
DateTime? receivedAt,
String? platform,
String? sourceDisplayName,
String? sourceUserId,
String? sourceThreadId,
List<String>? parsingHints,
Map<String, String>? metadata,
String? dedupeKey,
}) {
return SharedPayload(
sourceApp: sourceApp ?? this.sourceApp,
payloadType: payloadType ?? this.payloadType,
rawText: rawText ?? this.rawText,
url: url ?? this.url,
createdAt: createdAt ?? this.createdAt,
receivedAt: receivedAt ?? this.receivedAt,
platform: platform ?? this.platform,
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
sourceUserId: sourceUserId ?? this.sourceUserId,
sourceThreadId: sourceThreadId ?? this.sourceThreadId,
parsingHints: parsingHints ?? this.parsingHints,
metadata: metadata ?? this.metadata,
dedupeKey: dedupeKey ?? this.dedupeKey,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'sourceApp': sourceApp,
'payloadType': payloadType.name,
'rawText': rawText,
'url': url,
'createdAt': createdAt.toUtc().toIso8601String(),
'receivedAt': receivedAt.toUtc().toIso8601String(),
'platform': platform,
'sourceDisplayName': sourceDisplayName,
'sourceUserId': sourceUserId,
'sourceThreadId': sourceThreadId,
'parsingHints': parsingHints,
'metadata': metadata,
'dedupeKey': dedupeKey,
};
}
factory SharedPayload.fromJson(Map<String, dynamic> json) {
final String payloadTypeName =
json['payloadType'] as String? ?? SharedPayloadType.text.name;
return SharedPayload(
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
payloadType: SharedPayloadType.values.firstWhere(
(SharedPayloadType value) => value.name == payloadTypeName,
orElse: () => SharedPayloadType.text,
),
rawText: json['rawText'] as String? ?? '',
url: json['url'] as String?,
createdAt: DateTime.parse(
json['createdAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
receivedAt: DateTime.parse(
json['receivedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
platform: json['platform'] as String? ?? 'unknown',
sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?,
parsingHints: (json['parsingHints'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
metadata:
(json['metadata'] as Map<dynamic, dynamic>? ?? <dynamic, dynamic>{})
.map(
(dynamic key, dynamic value) =>
MapEntry<String, String>('$key', '$value'),
),
dedupeKey: json['dedupeKey'] as String?,
);
}
}
/// Draft extracted from a shared payload before it becomes a persistent fact.
@immutable
class CapturedFactDraft {
const CapturedFactDraft({
required this.type,
required this.text,
this.label,
this.dateValue,
this.confidence,
this.isSensitive = false,
this.needsReview = false,
});
final CapturedFactType type;
final String text;
final String? label;
final DateTime? dateValue;
final double? confidence;
final bool isSensitive;
final bool needsReview;
CapturedFactDraft copyWith({
CapturedFactType? type,
String? text,
String? label,
DateTime? dateValue,
double? confidence,
bool? isSensitive,
bool? needsReview,
}) {
return CapturedFactDraft(
type: type ?? this.type,
text: text ?? this.text,
label: label ?? this.label,
dateValue: dateValue ?? this.dateValue,
confidence: confidence ?? this.confidence,
isSensitive: isSensitive ?? this.isSensitive,
needsReview: needsReview ?? this.needsReview,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'type': type.name,
'text': text,
'label': label,
'dateValue': dateValue?.toUtc().toIso8601String(),
'confidence': confidence,
'isSensitive': isSensitive,
'needsReview': needsReview,
};
}
factory CapturedFactDraft.fromJson(Map<String, dynamic> json) {
final String typeName =
json['type'] as String? ?? CapturedFactType.note.name;
return CapturedFactDraft(
type: CapturedFactType.values.firstWhere(
(CapturedFactType value) => value.name == typeName,
orElse: () => CapturedFactType.note,
),
text: json['text'] as String? ?? '',
label: json['label'] as String?,
dateValue: json['dateValue'] == null
? null
: DateTime.parse(json['dateValue'] as String).toLocal(),
confidence: (json['confidence'] as num?)?.toDouble(),
isSensitive: json['isSensitive'] as bool? ?? false,
needsReview: json['needsReview'] as bool? ?? false,
);
}
}
/// Durable record of a share that was already attached to a person.
@immutable
class SharedMessageEntry {
const SharedMessageEntry({
required this.id,
required this.profileId,
required this.payload,
required this.importedAt,
required this.resolvedAutomatically,
this.assignmentStatus = ShareAssignmentStatus.assignedDirectly,
this.draft,
});
final String id;
final String profileId;
final SharedPayload payload;
final DateTime importedAt;
final bool resolvedAutomatically;
final ShareAssignmentStatus assignmentStatus;
final CapturedFactDraft? draft;
String get sourceApp => payload.sourceApp;
String get messageText => payload.rawText;
DateTime get sharedAt => payload.createdAt;
SharedPayloadType get payloadType => payload.payloadType;
String? get url => payload.url;
String? get sourceDisplayName => payload.sourceDisplayName;
String? get sourceUserId => payload.sourceUserId;
String? get sourceThreadId => payload.sourceThreadId;
SharedMessageEntry copyWith({
String? id,
String? profileId,
SharedPayload? payload,
DateTime? importedAt,
bool? resolvedAutomatically,
ShareAssignmentStatus? assignmentStatus,
CapturedFactDraft? draft,
}) {
return SharedMessageEntry(
id: id ?? this.id,
profileId: profileId ?? this.profileId,
payload: payload ?? this.payload,
importedAt: importedAt ?? this.importedAt,
resolvedAutomatically:
resolvedAutomatically ?? this.resolvedAutomatically,
assignmentStatus: assignmentStatus ?? this.assignmentStatus,
draft: draft ?? this.draft,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'profileId': profileId,
'payload': payload.toJson(),
'importedAt': importedAt.toUtc().toIso8601String(),
'resolvedAutomatically': resolvedAutomatically,
'assignmentStatus': assignmentStatus.name,
'draft': draft?.toJson(),
};
}
factory SharedMessageEntry.fromJson(Map<String, dynamic> json) {
final String assignmentStatusName =
json['assignmentStatus'] as String? ??
ShareAssignmentStatus.assignedDirectly.name;
final SharedPayload payload = json['payload'] is Map<String, dynamic>
? SharedPayload.fromJson(json['payload'] as Map<String, dynamic>)
: SharedPayload(
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
payloadType: SharedPayloadType.text,
rawText: json['messageText'] as String? ?? '',
createdAt: DateTime.parse(
json['sharedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
receivedAt: DateTime.parse(
json['importedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
platform: 'legacy',
sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?,
);
return SharedMessageEntry(
id: json['id'] as String,
profileId: json['profileId'] as String,
payload: payload,
importedAt: DateTime.parse(json['importedAt'] as String).toLocal(),
resolvedAutomatically: json['resolvedAutomatically'] as bool? ?? true,
assignmentStatus: ShareAssignmentStatus.values.firstWhere(
(ShareAssignmentStatus value) => value.name == assignmentStatusName,
orElse: () => ShareAssignmentStatus.assignedDirectly,
),
draft: json['draft'] is Map<String, dynamic>
? CapturedFactDraft.fromJson(json['draft'] as Map<String, dynamic>)
: null,
);
}
}
/// Why the share flow parked a payload in the inbox instead of saving directly.
enum SharedInboxReason {
ambiguousProfileMatch,
nearProfileConflict,
missingIdentity,
}
/// Unresolved share payload waiting for manual triage.
@immutable
class SharedInboxEntry {
const SharedInboxEntry({
required this.id,
required this.payload,
required this.reason,
required this.candidateProfileIds,
required this.normalizedDisplayName,
this.assignmentStatus = ShareAssignmentStatus.needsPersonSelection,
this.targetPersonId,
this.sourceFingerprint,
this.draft,
});
final String id;
final SharedPayload payload;
final SharedInboxReason reason;
final List<String> candidateProfileIds;
final String normalizedDisplayName;
final ShareAssignmentStatus assignmentStatus;
final String? targetPersonId;
final String? sourceFingerprint;
final CapturedFactDraft? draft;
String get sourceApp => payload.sourceApp;
String get messageText => payload.rawText;
DateTime get sharedAt => payload.createdAt;
DateTime get receivedAt => payload.receivedAt;
SharedPayloadType get payloadType => payload.payloadType;
String? get url => payload.url;
String? get sourceDisplayName => payload.sourceDisplayName;
String? get sourceUserId => payload.sourceUserId;
String? get sourceThreadId => payload.sourceThreadId;
SharedInboxEntry copyWith({
String? id,
SharedPayload? payload,
SharedInboxReason? reason,
List<String>? candidateProfileIds,
String? normalizedDisplayName,
ShareAssignmentStatus? assignmentStatus,
String? targetPersonId,
String? sourceFingerprint,
CapturedFactDraft? draft,
}) {
return SharedInboxEntry(
id: id ?? this.id,
payload: payload ?? this.payload,
reason: reason ?? this.reason,
candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds,
normalizedDisplayName:
normalizedDisplayName ?? this.normalizedDisplayName,
assignmentStatus: assignmentStatus ?? this.assignmentStatus,
targetPersonId: targetPersonId ?? this.targetPersonId,
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
draft: draft ?? this.draft,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'payload': payload.toJson(),
'reason': reason.name,
'candidateProfileIds': candidateProfileIds,
'normalizedDisplayName': normalizedDisplayName,
'assignmentStatus': assignmentStatus.name,
'targetPersonId': targetPersonId,
'sourceFingerprint': sourceFingerprint,
'draft': draft?.toJson(),
};
}
factory SharedInboxEntry.fromJson(Map<String, dynamic> json) {
final String reasonName =
json['reason'] as String? ?? SharedInboxReason.missingIdentity.name;
final String assignmentStatusName =
json['assignmentStatus'] as String? ??
ShareAssignmentStatus.needsPersonSelection.name;
final SharedPayload payload = json['payload'] is Map<String, dynamic>
? SharedPayload.fromJson(json['payload'] as Map<String, dynamic>)
: SharedPayload(
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
payloadType: SharedPayloadType.text,
rawText: json['messageText'] as String? ?? '',
createdAt: DateTime.parse(
json['sharedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
receivedAt: DateTime.parse(
json['receivedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
platform: 'legacy',
sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?,
);
return SharedInboxEntry(
id: json['id'] as String,
payload: payload,
reason: SharedInboxReason.values.firstWhere(
(SharedInboxReason item) => item.name == reasonName,
orElse: () => SharedInboxReason.missingIdentity,
),
candidateProfileIds:
(json['candidateProfileIds'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic id) => '$id')
.toList(growable: false),
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
assignmentStatus: ShareAssignmentStatus.values.firstWhere(
(ShareAssignmentStatus value) => value.name == assignmentStatusName,
orElse: () => ShareAssignmentStatus.needsPersonSelection,
),
targetPersonId: json['targetPersonId'] as String?,
sourceFingerprint: json['sourceFingerprint'] as String?,
draft: json['draft'] is Map<String, dynamic>
? CapturedFactDraft.fromJson(json['draft'] as Map<String, dynamic>)
: null,
);
}
}
@@ -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;
}
@@ -0,0 +1,72 @@
class SignalSharePayload {
const SignalSharePayload({
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 Signal into normalized sender/message fields.
class SignalShareParser {
const SignalShareParser._();
static final RegExp _namePrefixPattern = RegExp(
r'^([^:\n]{2,80}):\s*(.+)$',
dotAll: true,
);
static SignalSharePayload parse(String raw) {
final String input = raw.trim();
if (input.isEmpty) {
return const SignalSharePayload(messageText: '');
}
final RegExpMatch? match = _namePrefixPattern.firstMatch(input);
if (match == null) {
return SignalSharePayload(messageText: input);
}
final String? senderName = _cleanSender(match.group(1));
final String messageText = match.group(2)?.trim() ?? input;
return SignalSharePayload(
messageText: messageText,
sourceDisplayName: senderName,
sourceUserId: _normalizedKey(senderName),
sourceThreadId: null,
);
}
static bool looksLikeSignalMessage(String raw) {
final String input = raw.trim();
if (input.isEmpty) {
return false;
}
return _namePrefixPattern.hasMatch(input);
}
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 : 'signal:$key';
}
}
@@ -0,0 +1,87 @@
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 bool looksLikeWhatsAppMessage(String raw) {
final String input = raw.trim();
if (input.isEmpty) {
return false;
}
return _datePrefixPattern.hasMatch(input) ||
_namePrefixPattern.hasMatch(input);
}
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';
}
}