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,12 @@
|
||||
# Share Intake Slice
|
||||
|
||||
This slice owns the highest-friction capture path: content arriving from other
|
||||
apps.
|
||||
|
||||
- `domain/share_models.dart`: normalized share payloads, drafts, inbox entries.
|
||||
- `domain/*parser*.dart`: payload parsing and source-specific parsing helpers.
|
||||
- `application/share_capture_*.dart`: orchestration contracts and routing flow.
|
||||
- `presentation/`: listener widgets, review sheet, inbox UI.
|
||||
|
||||
If you want to improve share UX, ambiguity handling, or payload normalization,
|
||||
start here.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Share Intake Application
|
||||
|
||||
This layer coordinates share-entry routing and the review flow. If content comes
|
||||
in from outside the app, this folder decides how it enters the local workflow.
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/people/presentation/people_view.dart';
|
||||
import 'package:relationship_saver/features/people/presentation/providers/people_ui_state.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_capture_review_sheet.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_payload_parser.dart';
|
||||
|
||||
SharedPayload? buildSharedPayloadFromRawText({
|
||||
required String rawText,
|
||||
required String platform,
|
||||
String? sourceAppHint,
|
||||
}) {
|
||||
final String trimmed = rawText.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final String sourceApp = SharePayloadParser.detectSourceApp(
|
||||
rawText: trimmed,
|
||||
sourceAppHint: sourceAppHint,
|
||||
);
|
||||
final SharedPayload payload = SharePayloadParser.parse(
|
||||
rawText: trimmed,
|
||||
sourceApp: sourceApp,
|
||||
platform: platform,
|
||||
createdAt: DateTime.now(),
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
if (payload.rawText.trim().isEmpty && (payload.url?.trim().isEmpty ?? true)) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult?> startShareCaptureReview(
|
||||
BuildContext context, {
|
||||
required WidgetRef ref,
|
||||
required SharedPayload payload,
|
||||
}) {
|
||||
return showShareCaptureReviewSheet(
|
||||
context,
|
||||
ref: ref,
|
||||
payload: payload,
|
||||
initialPersonId: ref.read(selectedPersonIdProvider),
|
||||
);
|
||||
}
|
||||
|
||||
void openShareCaptureDestination(
|
||||
BuildContext context, {
|
||||
required WidgetRef ref,
|
||||
required SharedMessageIngestResult result,
|
||||
}) {
|
||||
if (result.isQueuedForResolution) {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) => const ShareInboxView(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.profileId != null) {
|
||||
ref.read(selectedPersonIdProvider.notifier).select(result.profileId!);
|
||||
}
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) => const PeopleView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String shareCaptureResultMessage(SharedMessageIngestResult result) {
|
||||
if (result.isQueuedForResolution) {
|
||||
return 'Saved shared capture to inbox.';
|
||||
}
|
||||
if (result.createdProfile) {
|
||||
return 'Saved shared capture and created ${result.profileName}.';
|
||||
}
|
||||
return 'Saved shared capture to ${result.profileName}.';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
class SharedMessageIngestInput {
|
||||
const SharedMessageIngestInput({
|
||||
required this.sourceApp,
|
||||
required this.messageText,
|
||||
this.sourceDisplayName,
|
||||
this.sourceUserId,
|
||||
this.sourceThreadId,
|
||||
this.sharedAt,
|
||||
});
|
||||
|
||||
final String sourceApp;
|
||||
final String messageText;
|
||||
final String? sourceDisplayName;
|
||||
final String? sourceUserId;
|
||||
final String? sourceThreadId;
|
||||
final DateTime? sharedAt;
|
||||
}
|
||||
|
||||
enum SharedMessageIngestStatus { imported, queuedForResolution }
|
||||
|
||||
class SharedMessageIngestResult {
|
||||
const SharedMessageIngestResult({
|
||||
required this.status,
|
||||
required this.createdProfile,
|
||||
required this.createdSourceLink,
|
||||
this.profileId,
|
||||
this.profileName,
|
||||
this.momentId,
|
||||
this.inboxEntryId,
|
||||
});
|
||||
|
||||
const SharedMessageIngestResult.imported({
|
||||
required this.profileId,
|
||||
required this.profileName,
|
||||
required this.createdProfile,
|
||||
required this.createdSourceLink,
|
||||
required this.momentId,
|
||||
}) : status = SharedMessageIngestStatus.imported,
|
||||
inboxEntryId = null;
|
||||
|
||||
const SharedMessageIngestResult.queuedForResolution({
|
||||
required this.inboxEntryId,
|
||||
}) : status = SharedMessageIngestStatus.queuedForResolution,
|
||||
createdProfile = false,
|
||||
createdSourceLink = false,
|
||||
profileId = null,
|
||||
profileName = null,
|
||||
momentId = null;
|
||||
|
||||
final SharedMessageIngestStatus status;
|
||||
final String? profileId;
|
||||
final String? profileName;
|
||||
final bool createdProfile;
|
||||
final bool createdSourceLink;
|
||||
final String? momentId;
|
||||
final String? inboxEntryId;
|
||||
|
||||
bool get isQueuedForResolution =>
|
||||
status == SharedMessageIngestStatus.queuedForResolution;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Share Intake Presentation
|
||||
|
||||
The listener widgets, review sheet, and inbox UI live here. Use this folder when
|
||||
you want to change the share-capture UX without touching parsing or persistence.
|
||||
@@ -0,0 +1,176 @@
|
||||
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_models.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
|
||||
|
||||
/// Listens for native share intents and routes incoming content through review.
|
||||
class IncomingShareListener extends ConsumerStatefulWidget {
|
||||
const IncomingShareListener({required this.child, super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<IncomingShareListener> createState() =>
|
||||
_IncomingShareListenerState();
|
||||
}
|
||||
|
||||
class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
|
||||
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(
|
||||
(List<SharedMediaFile> files) =>
|
||||
_handleMediaList(files, autoOpenDestination: false),
|
||||
onError: (_) {},
|
||||
);
|
||||
|
||||
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
|
||||
.instance
|
||||
.getInitialMedia();
|
||||
if (initialMedia.isNotEmpty) {
|
||||
await _handleMediaList(initialMedia, autoOpenDestination: true);
|
||||
}
|
||||
} catch (_) {
|
||||
// Unsupported platform/plugin state should not break app usage.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleMediaList(
|
||||
List<SharedMediaFile> files, {
|
||||
required bool autoOpenDestination,
|
||||
}) async {
|
||||
for (final SharedMediaFile file in files) {
|
||||
final String? rawText = _extractText(file);
|
||||
if (rawText == null) {
|
||||
continue;
|
||||
}
|
||||
await _handleRawText(
|
||||
rawText,
|
||||
autoOpenDestination: autoOpenDestination,
|
||||
sourceAppHint: _inferSourceAppHint(file, 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;
|
||||
}
|
||||
|
||||
String? _inferSourceAppHint(SharedMediaFile file, String rawText) {
|
||||
final String? mimeType = file.mimeType?.toLowerCase();
|
||||
if (mimeType == 'text/plain' && rawText.contains('Notes')) {
|
||||
return 'notes';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _handleRawText(
|
||||
String rawText, {
|
||||
required bool autoOpenDestination,
|
||||
String? sourceAppHint,
|
||||
}) async {
|
||||
final String trimmed = rawText.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final SharedPayload? payload = buildSharedPayloadFromRawText(
|
||||
rawText: trimmed,
|
||||
platform: defaultTargetPlatform.name,
|
||||
sourceAppHint: sourceAppHint,
|
||||
);
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String token =
|
||||
'${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}';
|
||||
if (_lastToken == token) {
|
||||
return;
|
||||
}
|
||||
_lastToken = token;
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final SharedMessageIngestResult? result = await startShareCaptureReview(
|
||||
context,
|
||||
ref: ref,
|
||||
payload: payload,
|
||||
);
|
||||
|
||||
if (!mounted || result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoOpenDestination) {
|
||||
openShareCaptureDestination(context, ref: ref, result: result);
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(shareCaptureResultMessage(result)),
|
||||
action: SnackBarAction(
|
||||
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
|
||||
onPressed: () =>
|
||||
openShareCaptureDestination(context, ref: ref, result: result),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await ReceiveSharingIntent.instance.reset();
|
||||
} catch (_) {
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||
import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart';
|
||||
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
|
||||
|
||||
Future<SharedMessageIngestResult?> showShareCaptureReviewSheet(
|
||||
BuildContext context, {
|
||||
required WidgetRef ref,
|
||||
required SharedPayload payload,
|
||||
String? initialPersonId,
|
||||
}) {
|
||||
return showModalBottomSheet<SharedMessageIngestResult>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
showDragHandle: true,
|
||||
builder: (BuildContext context) {
|
||||
return _ShareCaptureReviewSheet(
|
||||
payload: payload,
|
||||
initialPersonId: initialPersonId,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _ShareCaptureReviewSheet extends ConsumerStatefulWidget {
|
||||
const _ShareCaptureReviewSheet({required this.payload, this.initialPersonId});
|
||||
|
||||
final SharedPayload payload;
|
||||
final String? initialPersonId;
|
||||
|
||||
@override
|
||||
ConsumerState<_ShareCaptureReviewSheet> createState() =>
|
||||
_ShareCaptureReviewSheetState();
|
||||
}
|
||||
|
||||
class _ShareCaptureReviewSheetState
|
||||
extends ConsumerState<_ShareCaptureReviewSheet> {
|
||||
late final TextEditingController _textController;
|
||||
late final TextEditingController _labelController;
|
||||
late CapturedFactType _type;
|
||||
String? _selectedPersonId;
|
||||
DateTime? _selectedDate;
|
||||
late bool _isSensitive;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final CapturedFactDraft suggestedDraft =
|
||||
ShareCaptureDraftSuggester.suggestForPayload(widget.payload);
|
||||
_selectedPersonId = widget.initialPersonId;
|
||||
_type = suggestedDraft.type;
|
||||
_selectedDate = suggestedDraft.dateValue;
|
||||
_isSensitive = suggestedDraft.isSensitive;
|
||||
_textController = TextEditingController(text: suggestedDraft.text);
|
||||
_labelController = TextEditingController(text: suggestedDraft.label ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_labelController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AsyncValue<LocalDataState> localData = ref.watch(
|
||||
localRepositoryProvider,
|
||||
);
|
||||
return localData.when(
|
||||
data: (LocalDataState state) {
|
||||
final List<PersonProfile> people = state.people.toList(growable: false)
|
||||
..sort(_recentPeopleSort);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Save Shared Capture',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Review the incoming text or link, then assign it safely.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_PayloadPreview(payload: widget.payload),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'What kind of info is this?',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: CapturedFactType.values
|
||||
.map((CapturedFactType type) {
|
||||
return ChoiceChip(
|
||||
label: Text(_factTypeLabel(type)),
|
||||
selected: _type == type,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_type = type;
|
||||
});
|
||||
},
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _textController,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
labelText: _type == CapturedFactType.importantDate
|
||||
? 'Date note'
|
||||
: 'Captured text',
|
||||
hintText:
|
||||
'Adjust the note, preference, or idea before saving.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _labelController,
|
||||
decoration: InputDecoration(
|
||||
labelText: _type == CapturedFactType.importantDate
|
||||
? 'Date label'
|
||||
: 'Optional label',
|
||||
),
|
||||
),
|
||||
if (_type == CapturedFactType.importantDate) ...<Widget>[
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDate,
|
||||
icon: const Icon(Icons.event_outlined),
|
||||
label: Text(
|
||||
_selectedDate == null
|
||||
? 'Choose date'
|
||||
: 'Date: ${_dateLabel(_selectedDate!)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Mark as sensitive'),
|
||||
subtitle: const Text(
|
||||
'Useful for future privacy controls and review.',
|
||||
),
|
||||
value: _isSensitive,
|
||||
onChanged: (bool value) {
|
||||
setState(() {
|
||||
_isSensitive = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Assign to person',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (people.isEmpty)
|
||||
Text(
|
||||
'No people yet. Create one or save this to the inbox.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: people
|
||||
.take(6)
|
||||
.map((PersonProfile person) {
|
||||
return ChoiceChip(
|
||||
label: Text(person.name),
|
||||
selected: _selectedPersonId == person.id,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_selectedPersonId = person.id;
|
||||
});
|
||||
},
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
if (people.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedPersonId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Or choose from all people',
|
||||
),
|
||||
items: people
|
||||
.map(
|
||||
(PersonProfile person) => DropdownMenuItem<String>(
|
||||
value: person.id,
|
||||
child: Text(
|
||||
'${person.name} • ${person.relationship}',
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (String? value) {
|
||||
setState(() {
|
||||
_selectedPersonId = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _submitting ? null : _saveToInbox,
|
||||
child: const Text('Save To Inbox'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _submitting ? null : _createPerson,
|
||||
child: const Text('Create Person'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _submitting || _selectedPersonId == null
|
||||
? null
|
||||
: _saveToSelectedPerson,
|
||||
child: const Text('Save To Person'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (_, _) => const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('Unable to load people for share review.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _recentPeopleSort(PersonProfile a, PersonProfile b) {
|
||||
final DateTime? left = a.lastInteractedAt ?? a.lastUpdatedAt;
|
||||
final DateTime? right = b.lastInteractedAt ?? b.lastUpdatedAt;
|
||||
if (left == null && right != null) {
|
||||
return 1;
|
||||
}
|
||||
if (left != null && right == null) {
|
||||
return -1;
|
||||
}
|
||||
if (left != null && right != null) {
|
||||
final int byDate = right.compareTo(left);
|
||||
if (byDate != 0) {
|
||||
return byDate;
|
||||
}
|
||||
}
|
||||
return b.affinityScore.compareTo(a.affinityScore);
|
||||
}
|
||||
|
||||
CapturedFactDraft _buildDraft() {
|
||||
return CapturedFactDraft(
|
||||
type: _type,
|
||||
text: _textController.text.trim(),
|
||||
label: _labelController.text.trim().isEmpty
|
||||
? null
|
||||
: _labelController.text.trim(),
|
||||
dateValue: _selectedDate,
|
||||
isSensitive: _isSensitive,
|
||||
needsReview: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveToSelectedPerson() async {
|
||||
final String? personId = _selectedPersonId;
|
||||
if (personId == null) {
|
||||
return;
|
||||
}
|
||||
await _runAction(() async {
|
||||
final SharedMessageIngestResult result = await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.captureSharedPayloadToExistingProfile(
|
||||
payload: widget.payload,
|
||||
profileId: personId,
|
||||
draft: _buildDraft(),
|
||||
resolvedAutomatically: false,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(result);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveToInbox() async {
|
||||
await _runAction(() async {
|
||||
final SharedMessageIngestResult result = await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.saveSharedPayloadToInbox(
|
||||
payload: widget.payload,
|
||||
draft: _buildDraft(),
|
||||
targetPersonId: _selectedPersonId,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(result);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createPerson() async {
|
||||
final _CreatePersonFromShareDraft? draft =
|
||||
await showDialog<_CreatePersonFromShareDraft>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => _CreatePersonFromShareDialog(
|
||||
initialName:
|
||||
widget.payload.sourceDisplayName ??
|
||||
widget.payload.rawText.split(RegExp(r'\s+')).take(2).join(' '),
|
||||
),
|
||||
);
|
||||
if (draft == null) {
|
||||
return;
|
||||
}
|
||||
await _runAction(() async {
|
||||
final SharedMessageIngestResult result = await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.captureSharedPayloadByCreatingProfile(
|
||||
payload: widget.payload,
|
||||
name: draft.name,
|
||||
relationship: draft.relationship,
|
||||
aliases: draft.aliases,
|
||||
draft: _buildDraft(),
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(result);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime? date = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: DateTime(now.year + 10),
|
||||
initialDate: _selectedDate ?? now,
|
||||
);
|
||||
if (date == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selectedDate = date;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runAction(Future<void> Function() action) async {
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
});
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _factTypeLabel(CapturedFactType type) {
|
||||
return switch (type) {
|
||||
CapturedFactType.note => 'Note',
|
||||
CapturedFactType.like => 'Like',
|
||||
CapturedFactType.dislike => 'Dislike',
|
||||
CapturedFactType.importantDate => 'Date',
|
||||
CapturedFactType.giftIdea => 'Gift',
|
||||
CapturedFactType.placeIdea => 'Place',
|
||||
CapturedFactType.activityIdea => 'Activity',
|
||||
CapturedFactType.misc => 'Misc',
|
||||
};
|
||||
}
|
||||
|
||||
String _dateLabel(DateTime value) {
|
||||
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
class _PayloadPreview extends StatelessWidget {
|
||||
const _PayloadPreview({required this.payload});
|
||||
|
||||
final SharedPayload payload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE5EDF2)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
payload.sourceApp.toUpperCase(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
if (payload.sourceDisplayName != null) ...<Widget>[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Sender: ${payload.sourceDisplayName}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
if (payload.rawText.trim().isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: 8),
|
||||
Text(payload.rawText.trim()),
|
||||
],
|
||||
if (payload.url != null) ...<Widget>[
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
payload.url!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreatePersonFromShareDraft {
|
||||
const _CreatePersonFromShareDraft({
|
||||
required this.name,
|
||||
required this.relationship,
|
||||
required this.aliases,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String relationship;
|
||||
final List<String> aliases;
|
||||
}
|
||||
|
||||
class _CreatePersonFromShareDialog extends StatefulWidget {
|
||||
const _CreatePersonFromShareDialog({required this.initialName});
|
||||
|
||||
final String initialName;
|
||||
|
||||
@override
|
||||
State<_CreatePersonFromShareDialog> createState() =>
|
||||
_CreatePersonFromShareDialogState();
|
||||
}
|
||||
|
||||
class _CreatePersonFromShareDialogState
|
||||
extends State<_CreatePersonFromShareDialog> {
|
||||
late final TextEditingController _nameController;
|
||||
final TextEditingController _relationshipController = TextEditingController(
|
||||
text: 'Contact',
|
||||
);
|
||||
final TextEditingController _aliasesController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.initialName);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_relationshipController.dispose();
|
||||
_aliasesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Create Person'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _relationshipController,
|
||||
decoration: const InputDecoration(labelText: 'Relationship'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _aliasesController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Aliases (comma separated)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final String name = _nameController.text.trim();
|
||||
final String relationship = _relationshipController.text.trim();
|
||||
if (name.isEmpty || relationship.isEmpty) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(
|
||||
_CreatePersonFromShareDraft(
|
||||
name: name,
|
||||
relationship: relationship,
|
||||
aliases: _aliasesController.text
|
||||
.split(',')
|
||||
.map((String value) => value.trim())
|
||||
.where((String value) => value.isNotEmpty)
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
// Legacy compatibility export for the generic incoming share listener.
|
||||
export 'package:relationship_saver/features/share_intake/presentation/incoming_share_listener.dart';
|
||||
@@ -0,0 +1,2 @@
|
||||
// Legacy compatibility export for the share capture application flow.
|
||||
export 'package:relationship_saver/features/share_intake/application/share_capture_flow.dart';
|
||||
@@ -0,0 +1,2 @@
|
||||
// Legacy compatibility export for share capture flow contracts.
|
||||
export 'package:relationship_saver/features/share_intake/application/share_capture_models.dart';
|
||||
@@ -0,0 +1,2 @@
|
||||
// Legacy compatibility export for the share capture review presentation.
|
||||
export 'package:relationship_saver/features/share_intake/presentation/share_capture_review_sheet.dart';
|
||||
@@ -1,971 +1,2 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||
|
||||
/// Review unresolved shared messages and map them to a person profile.
|
||||
class ShareInboxView extends ConsumerStatefulWidget {
|
||||
const ShareInboxView({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ShareInboxView> createState() => _ShareInboxViewState();
|
||||
}
|
||||
|
||||
class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AsyncValue<LocalDataState> state = ref.watch(localRepositoryProvider);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final bool compact = constraints.maxWidth < 760;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 16 : 28,
|
||||
compact ? 14 : 20,
|
||||
compact ? 16 : 28,
|
||||
compact ? 16 : 20,
|
||||
),
|
||||
child: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (Object error, StackTrace stackTrace) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Failed to load Share Inbox.',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
},
|
||||
data: (LocalDataState localState) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Share Inbox',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Resolve shared messages that could not be matched confidently.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: localState.sharedInbox.isEmpty
|
||||
? _EmptyInbox(compact: compact)
|
||||
: ListView.separated(
|
||||
itemCount: localState.sharedInbox.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final SharedInboxEntry entry =
|
||||
localState.sharedInbox[index];
|
||||
final List<PersonProfile> options =
|
||||
_orderedPeopleForEntry(entry, localState);
|
||||
return _InboxEntryCard(
|
||||
entry: entry,
|
||||
compact: compact,
|
||||
submitting: _submitting,
|
||||
suggestedMatches: options,
|
||||
onResolveToExisting: () =>
|
||||
_resolveToExisting(entry, options),
|
||||
onCreateProfile: () => _createProfile(entry),
|
||||
onDismiss: () => _dismiss(entry.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<PersonProfile> _orderedPeopleForEntry(
|
||||
SharedInboxEntry entry,
|
||||
LocalDataState state,
|
||||
) {
|
||||
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
|
||||
for (final PersonProfile person in state.people) person.id: person,
|
||||
};
|
||||
final List<PersonProfile> candidates = <PersonProfile>[];
|
||||
final Set<String> usedIds = <String>{};
|
||||
for (final String id in entry.candidateProfileIds) {
|
||||
final PersonProfile? person = peopleById[id];
|
||||
if (person != null) {
|
||||
candidates.add(person);
|
||||
usedIds.add(person.id);
|
||||
}
|
||||
}
|
||||
|
||||
final List<PersonProfile> others = state.people
|
||||
.where((PersonProfile person) => !usedIds.contains(person.id))
|
||||
.toList(growable: false);
|
||||
return <PersonProfile>[...candidates, ...others];
|
||||
}
|
||||
|
||||
Future<void> _resolveToExisting(
|
||||
SharedInboxEntry entry,
|
||||
List<PersonProfile> options,
|
||||
) async {
|
||||
if (_submitting || options.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final List<_CandidateReview> reviews = _buildCandidateReviews(
|
||||
entry: entry,
|
||||
options: options,
|
||||
);
|
||||
if (reviews.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String? selectedId = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return _ConflictReviewDialog(
|
||||
entry: entry,
|
||||
reviews: reviews,
|
||||
initialsOf: _initials,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (selectedId == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _runRepositoryAction(() async {
|
||||
final SharedMessageIngestResult result = await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.resolveSharedInboxToExistingProfile(
|
||||
inboxEntryId: entry.id,
|
||||
profileId: selectedId,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Imported to ${result.profileName ?? 'selected profile'}.',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createProfile(SharedInboxEntry entry) async {
|
||||
if (_submitting) {
|
||||
return;
|
||||
}
|
||||
final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => _CreateProfileDialog(
|
||||
initialName:
|
||||
entry.sourceDisplayName ??
|
||||
entry.sourceUserId ??
|
||||
entry.sourceThreadId ??
|
||||
'',
|
||||
),
|
||||
);
|
||||
if (draft == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _runRepositoryAction(() async {
|
||||
final SharedMessageIngestResult result = await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.resolveSharedInboxByCreatingProfile(
|
||||
inboxEntryId: entry.id,
|
||||
name: draft.name,
|
||||
relationship: draft.relationship,
|
||||
location: draft.location,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Created and imported to ${result.profileName ?? 'profile'}.',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _dismiss(String inboxEntryId) async {
|
||||
if (_submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _runRepositoryAction(() async {
|
||||
await ref
|
||||
.read(localRepositoryProvider.notifier)
|
||||
.dismissSharedInboxEntry(inboxEntryId);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Inbox item dismissed.')));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runRepositoryAction(Future<void> Function() action) async {
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
});
|
||||
try {
|
||||
await action();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Action failed. Please try again.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _initials(String name) {
|
||||
final List<String> parts = name
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((String value) => value.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (parts.isEmpty) {
|
||||
return '?';
|
||||
}
|
||||
if (parts.length == 1) {
|
||||
return parts.first.substring(0, 1).toUpperCase();
|
||||
}
|
||||
return '${parts.first.substring(0, 1).toUpperCase()}${parts[1].substring(0, 1).toUpperCase()}';
|
||||
}
|
||||
|
||||
List<_CandidateReview> _buildCandidateReviews({
|
||||
required SharedInboxEntry entry,
|
||||
required List<PersonProfile> options,
|
||||
}) {
|
||||
final List<_CandidateReview> reviews = <_CandidateReview>[];
|
||||
for (final PersonProfile person in options) {
|
||||
final int suggestionIndex = entry.candidateProfileIds.indexOf(person.id);
|
||||
final bool suggested = suggestionIndex >= 0;
|
||||
final double confidence = _confidenceScore(
|
||||
entry: entry,
|
||||
person: person,
|
||||
suggested: suggested,
|
||||
suggestionIndex: suggestionIndex,
|
||||
);
|
||||
reviews.add(
|
||||
_CandidateReview(
|
||||
person: person,
|
||||
confidence: confidence,
|
||||
suggested: suggested,
|
||||
reasons: _confidenceReasons(
|
||||
entry: entry,
|
||||
person: person,
|
||||
confidence: confidence,
|
||||
suggested: suggested,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
reviews.sort((_CandidateReview left, _CandidateReview right) {
|
||||
final int compare = right.confidence.compareTo(left.confidence);
|
||||
if (compare != 0) {
|
||||
return compare;
|
||||
}
|
||||
return left.person.name.length.compareTo(right.person.name.length);
|
||||
});
|
||||
|
||||
return reviews;
|
||||
}
|
||||
|
||||
List<String> _confidenceReasons({
|
||||
required SharedInboxEntry entry,
|
||||
required PersonProfile person,
|
||||
required double confidence,
|
||||
required bool suggested,
|
||||
}) {
|
||||
final List<String> reasons = <String>[];
|
||||
if (suggested) {
|
||||
reasons.add('Previously suggested by identity matching.');
|
||||
}
|
||||
final String normalizedSender = _normalizedNameForEntry(entry);
|
||||
final String normalizedPerson = _normalizeForScore(person.name);
|
||||
if (normalizedSender.isNotEmpty && normalizedPerson.isNotEmpty) {
|
||||
if (normalizedSender == normalizedPerson) {
|
||||
reasons.add('Exact normalized name match.');
|
||||
} else if (normalizedSender.contains(normalizedPerson) ||
|
||||
normalizedPerson.contains(normalizedSender)) {
|
||||
reasons.add('One name contains the other.');
|
||||
}
|
||||
}
|
||||
if ((entry.sourceFingerprint ?? '').isNotEmpty) {
|
||||
reasons.add('Share includes stable source fingerprint.');
|
||||
}
|
||||
if (confidence >= 0.9) {
|
||||
reasons.add('Very high confidence score.');
|
||||
} else if (confidence >= 0.75) {
|
||||
reasons.add('Strong confidence score.');
|
||||
} else {
|
||||
reasons.add('Review manually before confirming.');
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
double _confidenceScore({
|
||||
required SharedInboxEntry entry,
|
||||
required PersonProfile person,
|
||||
required bool suggested,
|
||||
required int suggestionIndex,
|
||||
}) {
|
||||
final String senderName = _normalizedNameForEntry(entry);
|
||||
final String personName = _normalizeForScore(person.name);
|
||||
|
||||
double similarity = 0.42;
|
||||
if (senderName.isNotEmpty && personName.isNotEmpty) {
|
||||
final int distance = _levenshteinDistance(senderName, personName);
|
||||
final int maxLen = senderName.length > personName.length
|
||||
? senderName.length
|
||||
: personName.length;
|
||||
if (maxLen > 0) {
|
||||
similarity = 1 - (distance / maxLen);
|
||||
}
|
||||
if (similarity < 0) {
|
||||
similarity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
double score = (similarity * 0.72);
|
||||
if (suggested) {
|
||||
score += 0.2;
|
||||
if (suggestionIndex == 0) {
|
||||
score += 0.05;
|
||||
}
|
||||
}
|
||||
if ((entry.sourceUserId ?? '').isNotEmpty ||
|
||||
(entry.sourceThreadId ?? '').isNotEmpty) {
|
||||
score += 0.04;
|
||||
}
|
||||
if ((entry.sourceFingerprint ?? '').isNotEmpty) {
|
||||
score += 0.02;
|
||||
}
|
||||
if (score > 0.99) {
|
||||
return 0.99;
|
||||
}
|
||||
if (score < 0.01) {
|
||||
return 0.01;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
String _normalizedNameForEntry(SharedInboxEntry entry) {
|
||||
if (entry.normalizedDisplayName.isNotEmpty) {
|
||||
return entry.normalizedDisplayName;
|
||||
}
|
||||
return _normalizeForScore(entry.sourceDisplayName ?? '');
|
||||
}
|
||||
|
||||
String _normalizeForScore(String value) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
}
|
||||
|
||||
int _levenshteinDistance(String left, String right) {
|
||||
if (left == right) {
|
||||
return 0;
|
||||
}
|
||||
if (left.isEmpty) {
|
||||
return right.length;
|
||||
}
|
||||
if (right.isEmpty) {
|
||||
return left.length;
|
||||
}
|
||||
|
||||
List<int> previous = List<int>.generate(
|
||||
right.length + 1,
|
||||
(int index) => index,
|
||||
growable: false,
|
||||
);
|
||||
for (int i = 1; i <= left.length; i += 1) {
|
||||
final List<int> current = List<int>.filled(right.length + 1, 0);
|
||||
current[0] = i;
|
||||
for (int j = 1; j <= right.length; j += 1) {
|
||||
final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1;
|
||||
final int deletion = previous[j] + 1;
|
||||
final int insertion = current[j - 1] + 1;
|
||||
final int substitution = previous[j - 1] + substitutionCost;
|
||||
final int value = deletion < insertion ? deletion : insertion;
|
||||
current[j] = value < substitution ? value : substitution;
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
return previous[right.length];
|
||||
}
|
||||
}
|
||||
|
||||
class _CandidateReview {
|
||||
const _CandidateReview({
|
||||
required this.person,
|
||||
required this.confidence,
|
||||
required this.suggested,
|
||||
required this.reasons,
|
||||
});
|
||||
|
||||
final PersonProfile person;
|
||||
final double confidence;
|
||||
final bool suggested;
|
||||
final List<String> reasons;
|
||||
}
|
||||
|
||||
class _ConflictReviewDialog extends StatefulWidget {
|
||||
const _ConflictReviewDialog({
|
||||
required this.entry,
|
||||
required this.reviews,
|
||||
required this.initialsOf,
|
||||
});
|
||||
|
||||
final SharedInboxEntry entry;
|
||||
final List<_CandidateReview> reviews;
|
||||
final String Function(String name) initialsOf;
|
||||
|
||||
@override
|
||||
State<_ConflictReviewDialog> createState() => _ConflictReviewDialogState();
|
||||
}
|
||||
|
||||
class _ConflictReviewDialogState extends State<_ConflictReviewDialog> {
|
||||
late String _selectedProfileId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedProfileId = widget.reviews.first.person.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _CandidateReview selected = widget.reviews.firstWhere(
|
||||
(_CandidateReview review) => review.person.id == _selectedProfileId,
|
||||
orElse: () => widget.reviews.first,
|
||||
);
|
||||
final String sender =
|
||||
widget.entry.sourceDisplayName ??
|
||||
widget.entry.sourceUserId ??
|
||||
'Unknown sender';
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Review Match'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 780),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Compare incoming share identity with a profile before linking.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (MediaQuery.sizeOf(context).width < 760)
|
||||
Column(
|
||||
children: <Widget>[
|
||||
_IncomingSharePanel(
|
||||
entry: widget.entry,
|
||||
sender: sender,
|
||||
messagePreview: widget.entry.messageText,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CandidatePanel(
|
||||
review: selected,
|
||||
initials: widget.initialsOf(selected.person.name),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _IncomingSharePanel(
|
||||
entry: widget.entry,
|
||||
sender: sender,
|
||||
messagePreview: widget.entry.messageText,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _CandidatePanel(
|
||||
review: selected,
|
||||
initials: widget.initialsOf(selected.person.name),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Candidate profiles',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Column(
|
||||
children: widget.reviews
|
||||
.map((_CandidateReview review) {
|
||||
final bool selectedTile =
|
||||
review.person.id == _selectedProfileId;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ListTile(
|
||||
selected: selectedTile,
|
||||
selectedTileColor: const Color(0xFFEAF7FB),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
child: Text(widget.initialsOf(review.person.name)),
|
||||
),
|
||||
title: Text(review.person.name),
|
||||
subtitle: Text(
|
||||
'${review.person.relationship} · ${_formatPercent(review.confidence)} confidence',
|
||||
),
|
||||
trailing: review.suggested
|
||||
? const Icon(
|
||||
Icons.star_rounded,
|
||||
color: Color(0xFF1AB6C8),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedProfileId = review.person.id;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_selectedProfileId),
|
||||
child: const Text('Confirm Match'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IncomingSharePanel extends StatelessWidget {
|
||||
const _IncomingSharePanel({
|
||||
required this.entry,
|
||||
required this.sender,
|
||||
required this.messagePreview,
|
||||
});
|
||||
|
||||
final SharedInboxEntry entry;
|
||||
final String sender;
|
||||
final String messagePreview;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7FBFF),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Incoming Share',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_kv('Sender', sender),
|
||||
_kv('Source User ID', entry.sourceUserId ?? '-'),
|
||||
_kv('Source Thread', entry.sourceThreadId ?? '-'),
|
||||
_kv('Fingerprint', entry.sourceFingerprint ?? '-'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Message',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
messagePreview,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CandidatePanel extends StatelessWidget {
|
||||
const _CandidatePanel({required this.review, required this.initials});
|
||||
|
||||
final _CandidateReview review;
|
||||
final String initials;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PersonProfile person = review.person;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF4FAF8),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
CircleAvatar(child: Text(initials)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
person.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
person.relationship,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Confidence ${_formatPercent(review.confidence)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(value: review.confidence),
|
||||
const SizedBox(height: 10),
|
||||
if ((person.location ?? '').trim().isNotEmpty)
|
||||
_kv('Location', person.location!.trim()),
|
||||
if (person.tags.isNotEmpty)
|
||||
_kv('Tags', person.tags.take(4).join(', ')),
|
||||
const SizedBox(height: 4),
|
||||
...review.reasons.map(
|
||||
(String reason) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'- $reason',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _kv(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
||||
children: <InlineSpan>[
|
||||
TextSpan(
|
||||
text: '$label: ',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
TextSpan(text: value),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPercent(double value) {
|
||||
final int percent = (value * 100).round().clamp(1, 99);
|
||||
return '$percent%';
|
||||
}
|
||||
|
||||
class _InboxEntryCard extends StatelessWidget {
|
||||
const _InboxEntryCard({
|
||||
required this.entry,
|
||||
required this.compact,
|
||||
required this.submitting,
|
||||
required this.suggestedMatches,
|
||||
required this.onResolveToExisting,
|
||||
required this.onCreateProfile,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final SharedInboxEntry entry;
|
||||
final bool compact;
|
||||
final bool submitting;
|
||||
final List<PersonProfile> suggestedMatches;
|
||||
final VoidCallback onResolveToExisting;
|
||||
final VoidCallback onCreateProfile;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String sender =
|
||||
entry.sourceDisplayName ?? entry.sourceUserId ?? 'Unknown sender';
|
||||
final bool hasCandidates = suggestedMatches.isNotEmpty;
|
||||
final String reasonLabel = switch (entry.reason) {
|
||||
SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match',
|
||||
SharedInboxReason.nearProfileConflict => 'Near match conflict',
|
||||
SharedInboxReason.missingIdentity => 'Missing identity',
|
||||
};
|
||||
|
||||
return FrostedCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Text(
|
||||
sender,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: const Color(0xFFEFF5FA),
|
||||
),
|
||||
child: Text(
|
||||
reasonLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(entry.messageText, style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Shared ${_formatDateTime(entry.sharedAt)}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: <Widget>[
|
||||
FilledButton.icon(
|
||||
onPressed: submitting || !hasCandidates
|
||||
? null
|
||||
: onResolveToExisting,
|
||||
icon: const Icon(Icons.person_search_rounded),
|
||||
label: Text(hasCandidates ? 'Review & Match' : 'No Matches'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: submitting ? null : onCreateProfile,
|
||||
icon: const Icon(Icons.person_add_alt_1_rounded),
|
||||
label: const Text('Create Profile'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: submitting ? null : onDismiss,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: Text(compact ? 'Dismiss' : 'Dismiss Item'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyInbox extends StatelessWidget {
|
||||
const _EmptyInbox({required this.compact});
|
||||
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FrostedCard(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: compact ? 18 : 30,
|
||||
horizontal: compact ? 6 : 14,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
Icons.inbox_rounded,
|
||||
size: 40,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'No unresolved shares.',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Incoming WhatsApp shares that need manual profile mapping will appear here.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateProfileDraft {
|
||||
const _CreateProfileDraft({
|
||||
required this.name,
|
||||
required this.relationship,
|
||||
this.location,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String relationship;
|
||||
final String? location;
|
||||
}
|
||||
|
||||
class _CreateProfileDialog extends StatefulWidget {
|
||||
const _CreateProfileDialog({required this.initialName});
|
||||
|
||||
final String initialName;
|
||||
|
||||
@override
|
||||
State<_CreateProfileDialog> createState() => _CreateProfileDialogState();
|
||||
}
|
||||
|
||||
class _CreateProfileDialogState extends State<_CreateProfileDialog> {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _relationshipController;
|
||||
late final TextEditingController _locationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.initialName);
|
||||
_relationshipController = TextEditingController(text: 'WhatsApp Contact');
|
||||
_locationController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_relationshipController.dispose();
|
||||
_locationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Create Profile'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _relationshipController,
|
||||
decoration: const InputDecoration(labelText: 'Relationship'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _locationController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Location (optional)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(
|
||||
_CreateProfileDraft(
|
||||
name: _nameController.text,
|
||||
relationship: _relationshipController.text,
|
||||
location: _locationController.text,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime value) {
|
||||
String twoDigits(int number) => number.toString().padLeft(2, '0');
|
||||
return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} ${twoDigits(value.hour)}:${twoDigits(value.minute)}';
|
||||
}
|
||||
// Legacy compatibility export for the share inbox presentation entry.
|
||||
export 'package:relationship_saver/features/share_intake/presentation/share_inbox_view.dart';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Legacy compatibility export for the share payload parser.
|
||||
export 'package:relationship_saver/features/share_intake/domain/share_payload_parser.dart';
|
||||
@@ -1,192 +1,2 @@
|
||||
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/share_inbox_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(
|
||||
(List<SharedMediaFile> files) =>
|
||||
_handleMediaList(files, autoOpenDestination: false),
|
||||
onError: (_) {},
|
||||
);
|
||||
|
||||
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
|
||||
.instance
|
||||
.getInitialMedia();
|
||||
if (initialMedia.isNotEmpty) {
|
||||
await _handleMediaList(initialMedia, autoOpenDestination: true);
|
||||
}
|
||||
} catch (_) {
|
||||
// Unsupported platform/plugin state should not break app usage.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleMediaList(
|
||||
List<SharedMediaFile> files, {
|
||||
required bool autoOpenDestination,
|
||||
}) async {
|
||||
for (final SharedMediaFile file in files) {
|
||||
final String? rawText = _extractText(file);
|
||||
if (rawText == null) {
|
||||
continue;
|
||||
}
|
||||
await _handleRawText(rawText, autoOpenDestination: autoOpenDestination);
|
||||
}
|
||||
}
|
||||
|
||||
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, {
|
||||
required bool autoOpenDestination,
|
||||
}) 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;
|
||||
}
|
||||
|
||||
if (autoOpenDestination) {
|
||||
_openShareDestination(result);
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.isQueuedForResolution
|
||||
? 'WhatsApp share needs profile resolution.'
|
||||
: result.createdProfile
|
||||
? 'Imported from WhatsApp and created profile ${result.profileName}.'
|
||||
: 'Imported from WhatsApp to ${result.profileName}.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
|
||||
onPressed: () => _openShareDestination(result),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await ReceiveSharingIntent.instance.reset();
|
||||
} catch (_) {
|
||||
// Best effort.
|
||||
}
|
||||
}
|
||||
|
||||
void _openShareDestination(SharedMessageIngestResult result) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (result.isQueuedForResolution) {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) => const ShareInboxView(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.profileId != null) {
|
||||
ref.read(selectedPersonIdProvider.notifier).select(result.profileId!);
|
||||
}
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (BuildContext context) => const PeopleView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Legacy compatibility export for the share listener presentation entry.
|
||||
export 'package:relationship_saver/features/share_intake/presentation/whatsapp_share_listener.dart';
|
||||
|
||||
@@ -1,78 +1,2 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
// Legacy compatibility export for the WhatsApp share parser.
|
||||
export 'package:relationship_saver/features/share_intake/domain/whatsapp_share_parser.dart';
|
||||
|
||||
Reference in New Issue
Block a user