f655adfbea
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.
233 lines
6.7 KiB
Dart
233 lines
6.7 KiB
Dart
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;
|
|
}
|