Files
rely/lib/features/share_intake/domain/share_capture_draft_suggester.dart
Rijad Zuzo f655adfbea 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.
2026-05-17 00:17:20 +02:00

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;
}