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,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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user