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