Add LLM digest setup and share timestamp context

This commit is contained in:
Rijad Zuzo
2026-05-19 19:17:35 +02:00
parent 9d2da0c600
commit 5d80af375e
42 changed files with 2878 additions and 39 deletions
@@ -69,6 +69,16 @@ class AiDigestResponseParser {
suggestedFor:
_dateOrNull(item['suggestedFor']) ??
_dateOrNull(item['suggestedTiming']),
eventEndsAt:
_dateOrNull(item['eventEndsAt']) ??
_dateOrNull(item['endsAt']) ??
_dateOrNull(item['endTime']),
eventLocation: _optionalTrimAndCap(
_string(item['eventLocation']) ?? _string(item['location']),
160,
),
eventUrl: _urlOrNull(item['eventUrl']) ?? _urlOrNull(item['url']),
sourceUrls: _sourceUrls(item['sourceUrls'] ?? item['sources']),
confidence: _confidence(item['confidence']),
status: AiSuggestionStatus.pending,
sourceRunId: sourceRunId,
@@ -158,6 +168,40 @@ class AiDigestResponseParser {
return DateTime.tryParse(value)?.toLocal();
}
String? _urlOrNull(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
final Uri? uri = Uri.tryParse(value);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
return uri.toString();
}
List<String> _sourceUrls(Object? raw) {
final List<Object?> values = raw is List<dynamic> ? raw : <Object?>[raw];
final Set<String> urls = <String>{};
for (final Object? value in values) {
if (value is Map<String, dynamic>) {
final String? url =
_urlOrNull(value['url']) ??
_urlOrNull(value['uri']) ??
_urlOrNull(value['link']);
if (url != null) {
urls.add(url);
}
continue;
}
final String? url = _urlOrNull(value);
if (url != null) {
urls.add(url);
}
}
return urls.take(5).toList(growable: false);
}
double _confidence(Object? raw) {
if (raw is num) {
return raw.toDouble().clamp(0.0, 1.0).toDouble();
@@ -172,4 +216,12 @@ class AiDigestResponseParser {
}
return trimmed.substring(0, maxChars).trim();
}
String? _optionalTrimAndCap(String? value, int maxChars) {
if (value == null) {
return null;
}
final String trimmed = _trimAndCap(value, maxChars);
return trimmed.isEmpty ? null : trimmed;
}
}
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
class AnonymizedLlmDigestContext {
const AnonymizedLlmDigestContext({
@@ -23,12 +24,14 @@ class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({
this.maxPeople = 20,
this.maxSignalsPerPerson = 8,
this.maxFactsPerPerson = 8,
this.maxPriorSuggestionsPerPerson = 6,
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final int maxPeople;
final int maxSignalsPerPerson;
final int maxFactsPerPerson;
final int maxPriorSuggestionsPerPerson;
final DateTime Function() _now;
@@ -69,7 +72,11 @@ class AnonymizedLlmContextBuilder {
'Do not infer or ask for names.',
'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.',
'Use web search for current event recommendations and include source URLs.',
'Only suggest real upcoming events with verified date and venue details.',
'Do not repeat priorSuggestions for the same personToken.',
'When shared_message_datetime is present, treat it as the point-in-time reference for that evidence.',
'Do not turn old health/status evidence into a current check-in; preserve durable preferences instead.',
],
'limits': <String, dynamic>{
'maxItems': 10,
@@ -188,10 +195,81 @@ class AnonymizedLlmContextBuilder {
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.toList(growable: false),
).take(maxSignalsPerPerson).toList(growable: false),
'preferenceSignals': _preferenceSignals(signals, now),
'capturedFacts': _capturedFacts(state, person, now),
'priorSuggestions': _priorSuggestions(state, person),
};
}
List<Map<String, String>> _capturedFacts(
LocalDataState state,
PersonProfile person,
DateTime now,
) {
final List<String> identityTerms = _identityTerms(state);
final Map<String, DateTime> sharedMessageTimes = _sharedMessageTimes(state);
return state.personFacts
.where(
(PersonFact fact) =>
fact.personId == person.id &&
!fact.isSensitive &&
!fact.needsReview &&
fact.text.trim().isNotEmpty,
)
.take(maxFactsPerPerson)
.map((PersonFact fact) {
final DateTime? sharedMessageDateTime = fact.sharedMessageId == null
? null
: sharedMessageTimes[fact.sharedMessageId];
return <String, String>{
'type': _factTypeLabel(fact.type),
if (_safeContextText(fact.label, identityTerms).isNotEmpty)
'label': _safeContextText(fact.label, identityTerms),
'summary': _safeContextText(fact.text, identityTerms),
if (sharedMessageDateTime != null) ...<String, String>{
'shared_message_datetime': sharedMessageDateTime
.toUtc()
.toIso8601String(),
'sourceAge': _ageLabel(sharedMessageDateTime, now),
},
};
})
.where(
(Map<String, String> fact) =>
fact['summary'] != null && fact['summary']!.isNotEmpty,
)
.toList(growable: false);
}
List<Map<String, String>> _preferenceSignals(
List<PersonPreferenceSignal> signals,
DateTime now,
) {
return signals
.map(
(PersonPreferenceSignal signal) => <String, String>{
'label': _safeCategory(signal.label),
'category': _safeCategory(signal.category),
'polarity': signal.polarity == PreferenceSignalPolarity.dislike
? 'dislike'
: 'like',
'lastObserved': signal.lastSeenAt.toUtc().toIso8601String(),
'sourceAge': _ageLabel(signal.lastSeenAt, now),
},
)
.where((Map<String, String> signal) => signal['label']!.isNotEmpty)
.take(maxSignalsPerPerson)
.toList(growable: false);
}
Map<String, DateTime> _sharedMessageTimes(LocalDataState state) {
return <String, DateTime>{
for (final SharedMessageEntry entry in state.sharedMessages)
if (entry.sharedMessageDateTime != null)
entry.id: entry.sharedMessageDateTime!,
};
}
List<Map<String, String>> _priorSuggestions(
LocalDataState state,
PersonProfile person,
@@ -272,6 +350,18 @@ class AnonymizedLlmContextBuilder {
return 'last contact about $days days ago';
}
String _ageLabel(DateTime value, DateTime now) {
final int days = now.difference(value).inDays;
if (days <= 1) {
return 'current or recent';
}
if (days < 45) {
return 'about $days days ago';
}
final int months = (days / 30).round().clamp(2, 24).toInt();
return 'about $months months ago';
}
List<String> _safeList(Iterable<String> values) {
final List<String> out = <String>[];
final Set<String> seen = <String>{};
@@ -297,6 +387,58 @@ class AnonymizedLlmContextBuilder {
.toLowerCase();
}
String _safeContextText(String? value, List<String> identityTerms) {
if (value == null) {
return '';
}
String safe = value.trim().replaceAll(RegExp(r'https?://\S+'), '');
for (final String term in identityTerms) {
safe = safe.replaceAll(
RegExp(RegExp.escape(term), caseSensitive: false),
'this person',
);
}
return safe
.replaceAll(RegExp(r'[\r\n\t]+'), ' ')
.replaceAll(RegExp(r'[^a-zA-Z0-9 .,;:!?+&%$#@()\-/]+'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.toLowerCase();
}
List<String> _identityTerms(LocalDataState state) {
final Set<String> terms = <String>{};
for (final PersonProfile person in state.people) {
for (final String raw in <String>[person.name, ...person.aliases]) {
final String term = raw.trim();
if (term.length >= 3) {
terms.add(term);
}
for (final String part in term.split(RegExp(r'\s+'))) {
if (part.length >= 3) {
terms.add(part);
}
}
}
}
final List<String> sorted = terms.toList(growable: false)
..sort((String a, String b) => b.length.compareTo(a.length));
return sorted;
}
String _factTypeLabel(CapturedFactType type) {
return switch (type) {
CapturedFactType.note => 'note',
CapturedFactType.like => 'like',
CapturedFactType.dislike => 'dislike',
CapturedFactType.importantDate => 'important date',
CapturedFactType.giftIdea => 'gift idea',
CapturedFactType.placeIdea => 'place idea',
CapturedFactType.activityIdea => 'activity idea',
CapturedFactType.misc => 'misc',
};
}
int _daysUntil(DateTime date, DateTime now) {
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
}
@@ -0,0 +1,53 @@
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:url_launcher/url_launcher.dart';
class CalendarEventLauncher {
const CalendarEventLauncher();
Future<bool> addSuggestionToCalendar(AiSuggestionDraft draft) async {
final Uri? uri = calendarUriForSuggestion(draft);
if (uri == null) {
return false;
}
if (await launchUrl(uri, mode: LaunchMode.externalApplication)) {
return true;
}
return launchUrl(uri);
}
}
Uri? calendarUriForSuggestion(AiSuggestionDraft draft) {
final DateTime? startsAt = draft.suggestedFor;
if (draft.kind != AiSuggestionKind.eventIdea || startsAt == null) {
return null;
}
final DateTime endsAt =
draft.eventEndsAt ?? startsAt.add(const Duration(hours: 2));
final String details = <String>[
draft.details,
if (draft.reason != null && draft.reason!.trim().isNotEmpty)
'Why: ${draft.reason!.trim()}',
if (draft.eventUrl != null && draft.eventUrl!.trim().isNotEmpty)
draft.eventUrl!.trim(),
if (draft.sourceUrls.isNotEmpty) 'Sources: ${draft.sourceUrls.join(', ')}',
].where((String value) => value.trim().isNotEmpty).join('\n\n');
return Uri.https('calendar.google.com', '/calendar/render', <String, String>{
'action': 'TEMPLATE',
'text': draft.title,
'dates': '${_calendarTimestamp(startsAt)}/${_calendarTimestamp(endsAt)}',
if (details.isNotEmpty) 'details': details,
if (draft.eventLocation != null && draft.eventLocation!.trim().isNotEmpty)
'location': draft.eventLocation!.trim(),
});
}
String _calendarTimestamp(DateTime value) {
final DateTime utc = value.toUtc();
return '${utc.year.toString().padLeft(4, '0')}'
'${utc.month.toString().padLeft(2, '0')}'
'${utc.day.toString().padLeft(2, '0')}T'
'${utc.hour.toString().padLeft(2, '0')}'
'${utc.minute.toString().padLeft(2, '0')}'
'${utc.second.toString().padLeft(2, '0')}Z';
}
@@ -3,6 +3,7 @@ import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/core/llm/llm_service.dart';
import 'package:relationship_saver/core/observability/sentry_init.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
@@ -11,6 +12,7 @@ import 'package:relationship_saver/features/ai_digest/application/llm_digest_tex
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:uuid/uuid.dart';
const Duration _scheduledCooldown = Duration(days: 7);
@@ -107,7 +109,8 @@ class LlmDigestOrchestrator {
);
}
if (!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
if (!bypassCooldown &&
!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
return const LlmDigestRunResult(
started: false,
completed: false,
@@ -143,12 +146,11 @@ class LlmDigestOrchestrator {
}
final String sourceRunId = 'digest-${_uuid.v4()}';
final String response = await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt,
userPrompt: context.toPromptJson(),
);
final String response = await _completeDigestPrompt(
userPrompt: context.toPromptJson(),
enableWebSearch: config.enableWebSearch,
sourceRunId: sourceRunId,
);
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
response: response,
tokenToPersonId: context.tokenToPersonId,
@@ -157,6 +159,18 @@ class LlmDigestOrchestrator {
);
if (!parsed.success) {
_ref
.read(sentryInitProvider)
.captureMessage(
parsed.error ?? 'Invalid digest response.',
level: SentryLevel.warning,
area: 'llm_digest_parse',
context: <String, dynamic>{
'sourceRunId': sourceRunId,
'responseChars': response.length,
'eligiblePeople': context.tokenToPersonId.length,
},
);
await _markFailure(parsed.error ?? 'Invalid digest response.');
return LlmDigestRunResult(
started: true,
@@ -185,8 +199,19 @@ class LlmDigestOrchestrator {
completed: true,
createdDraftCount: parsed.drafts.length,
);
} catch (error) {
} catch (error, stackTrace) {
final String reason = _digestFailureReason(error);
_ref
.read(sentryInitProvider)
.captureException(
error,
stackTrace: stackTrace,
area: 'llm_digest',
context: <String, dynamic>{
'bypassCooldown': bypassCooldown,
'notify': notify,
},
);
await _markFailure(reason);
return LlmDigestRunResult(
started: true,
@@ -196,6 +221,45 @@ class LlmDigestOrchestrator {
}
}
Future<String> _completeDigestPrompt({
required String userPrompt,
required bool enableWebSearch,
required String sourceRunId,
}) async {
try {
return await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt(enableWebSearch: enableWebSearch),
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
} on LlmProviderException catch (error) {
if (!enableWebSearch || error.statusCode != 429) {
rethrow;
}
_ref
.read(sentryInitProvider)
.captureMessage(
'Grounded digest request hit provider quota; retrying without web search.',
level: SentryLevel.warning,
area: 'llm_digest_grounding_fallback',
context: <String, dynamic>{
'sourceRunId': sourceRunId,
'provider': error.provider.name,
'statusCode': error.statusCode,
},
);
return _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt(enableWebSearch: false),
userPrompt: userPrompt,
enableWebSearch: false,
);
}
}
Future<void> _markFailure(String reason) async {
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState current = await store.read();
@@ -241,7 +305,21 @@ String _digestFailureReason(Object error) {
return '$error';
}
const String _systemPrompt = '''
String _systemPrompt({required bool enableWebSearch}) {
final String groundingRules = enableWebSearch
? '''
Use web search for current event, venue, availability, price, and timing claims.
Prefer official event or venue pages over aggregators. Only classify an item as
eventIdea when there is a real upcoming event with a concrete date in the next
60 days. Include sourceUrls for grounded suggestions and do not fabricate URLs.
'''
: '''
Web search grounding is disabled or unavailable for this run. Do not make
current availability, price, or venue claims. Avoid eventIdea suggestions unless
the input already contains enough concrete event details and leave sourceUrls
empty when no source URL was provided.
''';
return '''
You create private relationship-maintenance digest suggestions.
The input contains pseudonymous people only. Never ask for or invent names.
Return exactly one JSON object:
@@ -252,14 +330,24 @@ Return exactly one JSON object:
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
"title": "short action title",
"details": "practical details for the user to review",
"suggestedTiming": "optional ISO-8601 date or null",
"suggestedTiming": "optional ISO-8601 start date/time or null",
"eventEndsAt": "optional ISO-8601 end date/time for eventIdea only, otherwise null",
"eventLocation": "optional venue or city for eventIdea only, otherwise null",
"eventUrl": "optional official event URL for eventIdea only, otherwise null",
"sourceUrls": ["0-3 source URLs used to verify the suggestion"],
"confidence": 0.0,
"reason": "brief non-sensitive rationale"
}
]
}
Return at most 10 suggestions. Use only personToken values present in input.
If an input fact includes shared_message_datetime, use that timestamp as the
evidence date. A message from months ago about temporary health, travel, mood,
or availability should not produce an immediate checkIn unless other recent
evidence supports it; keep durable preferences, constraints, and gift ideas.
$groundingRules
''';
}
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
@@ -5,6 +5,7 @@ abstract interface class LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
});
}
@@ -17,10 +18,12 @@ class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) {
return _service.completeText(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
}
}
@@ -7,6 +7,7 @@ const String _preferredWeekdayKey = 'llm_digest_preferred_weekday';
const String _preferredHourKey = 'llm_digest_preferred_hour';
const String _requireWifiKey = 'llm_digest_require_wifi';
const String _requireChargingKey = 'llm_digest_require_charging';
const String _enableWebSearchKey = 'llm_digest_enable_web_search';
class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
Future<void>? _initializeFuture;
@@ -29,6 +30,7 @@ class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
preferredHour: prefs.getInt(_preferredHourKey) ?? 21,
requireWifi: prefs.getBool(_requireWifiKey) ?? true,
requireCharging: prefs.getBool(_requireChargingKey) ?? true,
enableWebSearch: prefs.getBool(_enableWebSearchKey) ?? true,
);
}
@@ -68,6 +70,12 @@ class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
requireCharging: requireCharging,
);
}
Future<void> setWebSearchEnabled(bool enabled) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_enableWebSearchKey, enabled);
state = state.copyWith(enableWebSearch: enabled);
}
}
final NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>
@@ -21,6 +21,10 @@ class AiSuggestionDraft {
required this.status,
required this.sourceRunId,
this.suggestedFor,
this.eventEndsAt,
this.eventLocation,
this.eventUrl,
this.sourceUrls = const <String>[],
this.reason,
});
@@ -31,6 +35,10 @@ class AiSuggestionDraft {
final String details;
final DateTime suggestedAt;
final DateTime? suggestedFor;
final DateTime? eventEndsAt;
final String? eventLocation;
final String? eventUrl;
final List<String> sourceUrls;
final double confidence;
final AiSuggestionStatus status;
final String sourceRunId;
@@ -44,6 +52,10 @@ class AiSuggestionDraft {
String? details,
DateTime? suggestedAt,
DateTime? suggestedFor,
DateTime? eventEndsAt,
String? eventLocation,
String? eventUrl,
List<String>? sourceUrls,
double? confidence,
AiSuggestionStatus? status,
String? sourceRunId,
@@ -57,6 +69,10 @@ class AiSuggestionDraft {
details: details ?? this.details,
suggestedAt: suggestedAt ?? this.suggestedAt,
suggestedFor: suggestedFor ?? this.suggestedFor,
eventEndsAt: eventEndsAt ?? this.eventEndsAt,
eventLocation: eventLocation ?? this.eventLocation,
eventUrl: eventUrl ?? this.eventUrl,
sourceUrls: sourceUrls ?? this.sourceUrls,
confidence: confidence ?? this.confidence,
status: status ?? this.status,
sourceRunId: sourceRunId ?? this.sourceRunId,
@@ -73,6 +89,10 @@ class AiSuggestionDraft {
'details': details,
'suggestedAt': suggestedAt.toUtc().toIso8601String(),
'suggestedFor': suggestedFor?.toUtc().toIso8601String(),
'eventEndsAt': eventEndsAt?.toUtc().toIso8601String(),
'eventLocation': eventLocation,
'eventUrl': eventUrl,
'sourceUrls': sourceUrls,
'confidence': confidence,
'status': status.name,
'sourceRunId': sourceRunId,
@@ -101,6 +121,14 @@ class AiSuggestionDraft {
suggestedFor: json['suggestedFor'] == null
? null
: DateTime.parse(json['suggestedFor'] as String).toLocal(),
eventEndsAt: json['eventEndsAt'] == null
? null
: DateTime.parse(json['eventEndsAt'] as String).toLocal(),
eventLocation: json['eventLocation'] as String?,
eventUrl: json['eventUrl'] as String?,
sourceUrls: (json['sourceUrls'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic value) => '$value')
.toList(growable: false),
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
status: AiSuggestionStatus.values.firstWhere(
(AiSuggestionStatus value) => value.name == statusName,
@@ -178,6 +206,7 @@ class LlmDigestConfigState {
this.preferredHour = 21,
this.requireWifi = true,
this.requireCharging = true,
this.enableWebSearch = true,
});
final bool enabled;
@@ -186,6 +215,7 @@ class LlmDigestConfigState {
final int preferredHour;
final bool requireWifi;
final bool requireCharging;
final bool enableWebSearch;
LlmDigestConfigState copyWith({
bool? enabled,
@@ -194,6 +224,7 @@ class LlmDigestConfigState {
int? preferredHour,
bool? requireWifi,
bool? requireCharging,
bool? enableWebSearch,
}) {
return LlmDigestConfigState(
enabled: enabled ?? this.enabled,
@@ -202,6 +233,7 @@ class LlmDigestConfigState {
preferredHour: preferredHour ?? this.preferredHour,
requireWifi: requireWifi ?? this.requireWifi,
requireCharging: requireCharging ?? this.requireCharging,
enableWebSearch: enableWebSearch ?? this.enableWebSearch,
);
}
}
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/ai_digest/application/calendar_event_launcher.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
@@ -165,6 +166,13 @@ class _AiSuggestionCard extends StatelessWidget {
icon: const Icon(Icons.check_rounded),
label: const Text('Accept'),
),
if (draft.kind == AiSuggestionKind.eventIdea &&
draft.suggestedFor != null)
OutlinedButton.icon(
onPressed: () => _addToCalendar(context),
icon: const Icon(Icons.calendar_month_rounded),
label: const Text('Calendar'),
),
OutlinedButton.icon(
onPressed: onDismiss,
icon: const Icon(Icons.close_rounded),
@@ -177,6 +185,17 @@ class _AiSuggestionCard extends StatelessWidget {
);
}
Future<void> _addToCalendar(BuildContext context) async {
final bool launched = await const CalendarEventLauncher()
.addSuggestionToCalendar(draft);
if (!context.mounted || launched) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Could not open calendar.')));
}
IconData _iconForKind(AiSuggestionKind kind) {
return switch (kind) {
AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded,
@@ -3,6 +3,7 @@ import 'dart:math' as math;
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/ai_digest/application/calendar_event_launcher.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';
@@ -22,6 +23,12 @@ class DashboardView extends ConsumerWidget {
data: (LocalDataState data) {
final DashboardSummary summary = data.summary();
final List<DashboardTask> tasks = data.tasks;
final DateTime now = DateTime.now();
final List<AiSuggestionDraft> upcomingEventSuggestions =
_upcomingEventSuggestions(data.aiSuggestionDrafts, now);
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
@@ -42,12 +49,19 @@ class DashboardView extends ConsumerWidget {
),
const SizedBox(height: 8),
Text(
'See the relationship graph first, then execute the highest-leverage next moves.',
'Review incoming context, then act on time-sensitive opportunities.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 18),
_HomeQueueCard(
unresolvedShares: data.sharedInbox,
upcomingEvents: upcomingEventSuggestions,
peopleById: peopleById,
compact: compact,
),
const SizedBox(height: 20),
_RelationshipGraphCard(people: data.people, compact: compact),
const SizedBox(height: 20),
Wrap(
@@ -130,6 +144,293 @@ class DashboardView extends ConsumerWidget {
}
}
class _HomeQueueCard extends StatelessWidget {
const _HomeQueueCard({
required this.unresolvedShares,
required this.upcomingEvents,
required this.peopleById,
required this.compact,
});
final List<SharedInboxEntry> unresolvedShares;
final List<AiSuggestionDraft> upcomingEvents;
final Map<String, PersonProfile> peopleById;
final bool compact;
@override
Widget build(BuildContext context) {
final List<Widget> sections = <Widget>[
_UnresolvedSharesPanel(entries: unresolvedShares),
_UpcomingEventsPanel(events: upcomingEvents, peopleById: peopleById),
];
return compact
? Column(
children: sections
.map(
(Widget child) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: child,
),
)
.toList(growable: false),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(child: sections[0]),
const SizedBox(width: 14),
Expanded(child: sections[1]),
],
);
}
}
class _UnresolvedSharesPanel extends StatelessWidget {
const _UnresolvedSharesPanel({required this.entries});
final List<SharedInboxEntry> entries;
@override
Widget build(BuildContext context) {
final List<SharedInboxEntry> visibleEntries = entries
.take(3)
.toList(growable: false);
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
const Icon(Icons.inbox_rounded, color: AppTheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Unresolved Shares',
style: Theme.of(context).textTheme.titleLarge,
),
),
_CountPill(count: entries.length),
],
),
const SizedBox(height: 12),
if (entries.isEmpty)
Text(
'Inbox is clear.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
)
else
...visibleEntries.map(
(SharedInboxEntry entry) => _ShareInboxPreview(entry: entry),
),
if (entries.length > visibleEntries.length)
Text(
'+${entries.length - visibleEntries.length} more in inbox',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
),
],
),
);
}
}
class _ShareInboxPreview extends StatelessWidget {
const _ShareInboxPreview({required this.entry});
final SharedInboxEntry entry;
@override
Widget build(BuildContext context) {
final String sender = entry.sourceDisplayName?.trim().isNotEmpty == true
? entry.sourceDisplayName!.trim()
: entry.sourceApp;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Icon(Icons.circle, size: 8, color: Color(0xFFFFA447)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
sender,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
_compactShareText(entry.payload.rawText),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
);
}
}
class _UpcomingEventsPanel extends StatelessWidget {
const _UpcomingEventsPanel({required this.events, required this.peopleById});
final List<AiSuggestionDraft> events;
final Map<String, PersonProfile> peopleById;
@override
Widget build(BuildContext context) {
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
const Icon(
Icons.event_available_rounded,
color: Color(0xFF2B7FFF),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Upcoming Events',
style: Theme.of(context).textTheme.titleLarge,
),
),
_CountPill(count: events.length),
],
),
const SizedBox(height: 12),
if (events.isEmpty)
Text(
'No grounded event suggestions in the next 30 days.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
)
else
...events.map(
(AiSuggestionDraft event) => _UpcomingEventPreview(
draft: event,
person: peopleById[event.personId],
),
),
],
),
);
}
}
class _UpcomingEventPreview extends StatelessWidget {
const _UpcomingEventPreview({required this.draft, required this.person});
final AiSuggestionDraft draft;
final PersonProfile? person;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF5FAFB),
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
draft.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'${_shortDate(draft.suggestedFor!)} · ${person?.name ?? 'Unknown person'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
if (draft.eventLocation != null &&
draft.eventLocation!.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 2),
Text(
draft.eventLocation!.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Add to calendar',
onPressed: () => _addToCalendar(context),
icon: const Icon(Icons.calendar_month_rounded),
),
],
),
),
);
}
Future<void> _addToCalendar(BuildContext context) async {
final bool launched = await const CalendarEventLauncher()
.addSuggestionToCalendar(draft);
if (!context.mounted || launched) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Could not open calendar.')));
}
}
class _CountPill extends StatelessWidget {
const _CountPill({required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFEAF7FB),
borderRadius: BorderRadius.circular(99),
),
child: Text(
'$count',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.primary),
),
);
}
}
class _RelationshipGraphCard extends StatelessWidget {
const _RelationshipGraphCard({required this.people, required this.compact});
@@ -2566,6 +2867,38 @@ String _shortName(String name) {
return parts.first;
}
List<AiSuggestionDraft> _upcomingEventSuggestions(
List<AiSuggestionDraft> drafts,
DateTime now,
) {
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime windowEnd = today.add(const Duration(days: 30));
final List<AiSuggestionDraft> events =
drafts
.where((AiSuggestionDraft draft) {
final DateTime? startsAt = draft.suggestedFor;
return draft.kind == AiSuggestionKind.eventIdea &&
draft.status == AiSuggestionStatus.pending &&
startsAt != null &&
!startsAt.isBefore(today) &&
startsAt.isBefore(windowEnd);
})
.toList(growable: false)
..sort(
(AiSuggestionDraft a, AiSuggestionDraft b) =>
a.suggestedFor!.compareTo(b.suggestedFor!),
);
return events.take(5).toList(growable: false);
}
String _compactShareText(String value) {
final String compact = value.trim().replaceAll(RegExp(r'\s+'), ' ');
if (compact.length <= 120) {
return compact;
}
return '${compact.substring(0, 117).trim()}...';
}
String _shortDate(DateTime value) {
const List<String> weekdays = <String>[
'Mon',
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/core/llm/llm_diagnostics_log.dart';
import 'package:relationship_saver/core/llm/llm_service.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart';
@@ -114,7 +116,15 @@ class SettingsView extends ConsumerWidget {
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'AI network',
label: 'AI grounding',
value: digestConfig.enableWebSearch
? 'Google Search enabled'
: 'Model only',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Scheduled AI network',
value:
'${digestConfig.requireWifi ? 'Wi-Fi' : 'Any network'} + ${digestConfig.requireCharging ? 'charging' : 'battery allowed'}',
),
@@ -150,6 +160,19 @@ class SettingsView extends ConsumerWidget {
: 'Enable Weekly AI Digest',
),
),
OutlinedButton.icon(
onPressed: () => _toggleWebSearch(context, ref),
icon: Icon(
digestConfig.enableWebSearch
? Icons.public_off_rounded
: Icons.public_rounded,
),
label: Text(
digestConfig.enableWebSearch
? 'Disable Web Grounding'
: 'Enable Web Grounding',
),
),
OutlinedButton.icon(
onPressed: ref.watch(llmConfigProvider).isConfigured
? () => _runPrivateDigest(context, ref)
@@ -319,6 +342,26 @@ class SettingsView extends ConsumerWidget {
);
}
Future<void> _toggleWebSearch(BuildContext context, WidgetRef ref) async {
final LlmDigestConfigState current = ref.read(llmDigestConfigProvider);
final bool nextEnabled = !current.enableWebSearch;
await ref
.read(llmDigestConfigProvider.notifier)
.setWebSearchEnabled(nextEnabled);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
nextEnabled
? 'Web grounding enabled for private digest.'
: 'Web grounding disabled for private digest.',
),
),
);
}
Future<void> _showLlmDebug(BuildContext context) async {
await showDialog<void>(
context: context,
@@ -891,8 +934,10 @@ class _LlmDebugDialog extends ConsumerStatefulWidget {
class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
late final TextEditingController _promptController;
bool _running = false;
bool _loadingEvents = true;
String? _response;
String? _error;
List<LlmDiagnosticsEvent> _events = const <LlmDiagnosticsEvent>[];
@override
void initState() {
@@ -900,6 +945,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
_promptController = TextEditingController(
text: 'Reply with one short sentence confirming the connection works.',
);
_loadDiagnostics();
}
@override
@@ -958,6 +1004,44 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
),
),
],
const SizedBox(height: 18),
Row(
children: <Widget>[
Expanded(
child: Text(
'Recent diagnostics',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton.icon(
onPressed: _events.isEmpty ? null : _copyDiagnostics,
icon: const Icon(Icons.copy_rounded),
label: const Text('Copy'),
),
TextButton.icon(
onPressed: _events.isEmpty ? null : _clearDiagnostics,
icon: const Icon(Icons.delete_outline_rounded),
label: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
if (_loadingEvents)
const LinearProgressIndicator()
else if (_events.isEmpty)
Text(
'No LLM diagnostics recorded yet.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
)
else
..._events
.take(8)
.map(
(LlmDiagnosticsEvent event) =>
_LlmDiagnosticsEventTile(event: event),
),
],
),
),
@@ -976,6 +1060,40 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
);
}
Future<void> _loadDiagnostics() async {
final List<LlmDiagnosticsEvent> events = await ref
.read(llmDiagnosticsLogProvider)
.read();
if (!mounted) {
return;
}
setState(() {
_events = events;
_loadingEvents = false;
});
}
Future<void> _copyDiagnostics() async {
final LlmDiagnosticsLog log = ref.read(llmDiagnosticsLogProvider);
await Clipboard.setData(ClipboardData(text: log.exportText(_events)));
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('LLM diagnostics copied.')));
}
Future<void> _clearDiagnostics() async {
await ref.read(llmDiagnosticsLogProvider).clear();
if (!mounted) {
return;
}
setState(() {
_events = const <LlmDiagnosticsEvent>[];
});
}
Future<void> _runDebugPrompt() async {
setState(() {
_running = true;
@@ -996,6 +1114,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
setState(() {
_response = response;
});
await _loadDiagnostics();
} catch (error) {
if (!mounted) {
return;
@@ -1003,6 +1122,7 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
setState(() {
_error = error.toString();
});
await _loadDiagnostics();
} finally {
if (mounted) {
setState(() {
@@ -1013,6 +1133,46 @@ class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
}
}
class _LlmDiagnosticsEventTile extends StatelessWidget {
const _LlmDiagnosticsEventTile({required this.event});
final LlmDiagnosticsEvent event;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'${event.stage} · ${event.at.toLocal().toIso8601String()}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
SelectableText(event.message),
if (event.details != null &&
event.details!.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 6),
SelectableText(
event.details!,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
],
),
);
}
}
class _LlmDebugConnectionSummary extends StatelessWidget {
const _LlmDebugConnectionSummary({required this.config});
@@ -38,6 +38,7 @@ class SharedPayload {
required this.createdAt,
required this.receivedAt,
required this.platform,
this.sharedMessageDateTime,
this.url,
this.sourceDisplayName,
this.sourceUserId,
@@ -53,6 +54,7 @@ class SharedPayload {
final String? url;
final DateTime createdAt;
final DateTime receivedAt;
final DateTime? sharedMessageDateTime;
final String platform;
final String? sourceDisplayName;
final String? sourceUserId;
@@ -68,6 +70,7 @@ class SharedPayload {
String? url,
DateTime? createdAt,
DateTime? receivedAt,
DateTime? sharedMessageDateTime,
String? platform,
String? sourceDisplayName,
String? sourceUserId,
@@ -83,6 +86,8 @@ class SharedPayload {
url: url ?? this.url,
createdAt: createdAt ?? this.createdAt,
receivedAt: receivedAt ?? this.receivedAt,
sharedMessageDateTime:
sharedMessageDateTime ?? this.sharedMessageDateTime,
platform: platform ?? this.platform,
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
sourceUserId: sourceUserId ?? this.sourceUserId,
@@ -101,6 +106,7 @@ class SharedPayload {
'url': url,
'createdAt': createdAt.toUtc().toIso8601String(),
'receivedAt': receivedAt.toUtc().toIso8601String(),
'sharedMessageDateTime': sharedMessageDateTime?.toUtc().toIso8601String(),
'platform': platform,
'sourceDisplayName': sourceDisplayName,
'sourceUserId': sourceUserId,
@@ -130,6 +136,9 @@ class SharedPayload {
json['receivedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
sharedMessageDateTime: json['sharedMessageDateTime'] == null
? null
: DateTime.parse(json['sharedMessageDateTime'] as String).toLocal(),
platform: json['platform'] as String? ?? 'unknown',
sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?,
@@ -244,7 +253,8 @@ class SharedMessageEntry {
String get sourceApp => payload.sourceApp;
String get messageText => payload.rawText;
DateTime get sharedAt => payload.createdAt;
DateTime get sharedAt => payload.sharedMessageDateTime ?? payload.createdAt;
DateTime? get sharedMessageDateTime => payload.sharedMessageDateTime;
SharedPayloadType get payloadType => payload.payloadType;
String? get url => payload.url;
String? get sourceDisplayName => payload.sourceDisplayName;
@@ -1,4 +1,5 @@
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/share_intake/domain/shared_message_datetime_extractor.dart';
import 'package:relationship_saver/features/share_intake/domain/signal_share_parser.dart';
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
@@ -20,6 +21,8 @@ class SharePayloadParser {
final DateTime now = DateTime.now();
final String normalizedSourceApp = _normalizeSourceApp(sourceApp);
final String trimmed = rawText.trim();
final DateTime? sharedMessageDateTime =
SharedMessageDateTimeExtractor.extract(trimmed, now: receivedAt ?? now);
final _ParsedSourceMetadata? sourceMetadata = _parseSourceMetadata(
sourceApp: normalizedSourceApp,
rawText: trimmed,
@@ -48,6 +51,7 @@ class SharePayloadParser {
url: url,
createdAt: createdAt ?? now,
receivedAt: receivedAt ?? now,
sharedMessageDateTime: sharedMessageDateTime,
platform: platform,
sourceDisplayName: sourceMetadata?.sourceDisplayName,
sourceUserId: sourceMetadata?.sourceUserId,
@@ -56,11 +60,14 @@ class SharePayloadParser {
if (normalizedSourceApp != 'share_sheet')
'source_app_$normalizedSourceApp',
if (url != null) 'contains_url',
if (sharedMessageDateTime != null) 'shared_message_datetime_detected',
if (sourceMetadata?.sourceDisplayName case final String _)
'sender_detected',
],
metadata: <String, String>{
if (url case final String value) 'primaryUrl': value,
if (sharedMessageDateTime case final DateTime value)
'shared_message_datetime': value.toUtc().toIso8601String(),
},
);
}
@@ -0,0 +1,377 @@
/// Best-effort extraction of the original message timestamp from shared text.
///
/// This intentionally prefers source-message metadata shapes (chat export
/// prefixes, bracketed timestamps, and explicit sent/received labels). It avoids
/// treating arbitrary content dates like "birthday is May 20" as the time the
/// message was received.
class SharedMessageDateTimeExtractor {
const SharedMessageDateTimeExtractor._();
static final RegExp _isoDateTimePattern = RegExp(
r'(\d{4}-\d{1,2}-\d{1,2})(?:[tT\s]+(\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?)(?:\s*(Z|[+-]\d{2}:?\d{2}))?)?',
);
static final RegExp _numericDatePattern = RegExp(
r'(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})(?:[,\s]+(?:at\s+)?(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(AM|PM|am|pm)?)?',
);
static final RegExp _monthNameDatePattern = RegExp(
r'(?:(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-zÀ-ž.]+)\s*,?\s*(\d{2,4})|([A-Za-zÀ-ž.]+)\s+(\d{1,2})(?:st|nd|rd|th)?[,]?\s*(\d{2,4}))(?:[,\s]+(?:at\s+)?(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(AM|PM|am|pm)?)?',
);
static final RegExp _sourcePrefixPattern = RegExp(
r'^\s*(?:\[([^\]]{3,80})\]|([^-–—\n]{3,80}))\s*(?:[-–—]\s*)?([^:\n]{2,80}):\s+.+$',
dotAll: true,
);
static final RegExp _labelledLinePattern = RegExp(
r'^\s*(?:sent|sent at|received|received at|date|datetime|timestamp|message date|message time)\s*[:=-]\s*(.+)$',
caseSensitive: false,
);
static final RegExp _relativeLabelPattern = RegExp(
r'\b(today|yesterday)\b(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))(?::(\d{2}))?\s*(AM|PM|am|pm)?',
caseSensitive: false,
);
static final Map<String, int> _months = <String, int>{
'jan': 1,
'january': 1,
'januar': 1,
'januari': 1,
'janvier': 1,
'feb': 2,
'february': 2,
'februar': 2,
'februari': 2,
'fevrier': 2,
'février': 2,
'mar': 3,
'march': 3,
'märz': 3,
'marz': 3,
'maerz': 3,
'mars': 3,
'apr': 4,
'april': 4,
'avr': 4,
'avril': 4,
'may': 5,
'mai': 5,
'maj': 5,
'jun': 6,
'june': 6,
'juni': 6,
'juin': 6,
'jul': 7,
'july': 7,
'juli': 7,
'juillet': 7,
'aug': 8,
'august': 8,
'aout': 8,
'août': 8,
'sep': 9,
'sept': 9,
'september': 9,
'septembre': 9,
'okt': 10,
'oct': 10,
'october': 10,
'oktober': 10,
'octobre': 10,
'nov': 11,
'november': 11,
'novembre': 11,
'dec': 12,
'dez': 12,
'december': 12,
'dezember': 12,
'decembre': 12,
'décembre': 12,
};
static DateTime? extract(String rawText, {DateTime? now}) {
try {
return _extract(rawText, now: now ?? DateTime.now());
} catch (_) {
return null;
}
}
static DateTime? _extract(String rawText, {required DateTime now}) {
final String input = rawText.trim();
if (input.isEmpty) {
return null;
}
for (final String candidate in _candidateFragments(input)) {
final DateTime? parsed = _parseDateTime(candidate, now: now);
if (parsed != null) {
return parsed;
}
}
final RegExpMatch? sourcePrefix = _sourcePrefixPattern.firstMatch(input);
if (sourcePrefix != null) {
for (final String? group in <String?>[
sourcePrefix.group(1),
sourcePrefix.group(2),
]) {
if (group == null) {
continue;
}
final DateTime? parsed = _parseDateTime(group, now: now);
if (parsed != null) {
return parsed;
}
}
}
return null;
}
static List<String> _candidateFragments(String input) {
final List<String> candidates = <String>[];
final List<String> lines = input
.split(RegExp(r'\r?\n'))
.map((String line) => line.trim())
.where((String line) => line.isNotEmpty)
.take(6)
.toList(growable: false);
for (final String line in lines) {
final RegExpMatch? labelled = _labelledLinePattern.firstMatch(line);
if (labelled != null) {
candidates.add(labelled.group(1)!.trim());
}
if (line.startsWith('[') && line.contains(']')) {
final int end = line.indexOf(']');
candidates.add(line.substring(1, end).trim());
}
final int dashIndex = _firstSeparatorIndex(line);
final int colonIndex = line.indexOf(':');
if (dashIndex > 0 && dashIndex <= 80) {
candidates.add(line.substring(0, dashIndex).trim());
} else if (colonIndex > 0 && colonIndex <= 80) {
candidates.add(line.substring(0, colonIndex).trim());
}
candidates.add(line);
}
return candidates;
}
static int _firstSeparatorIndex(String value) {
final List<int> indexes = <int>[
value.indexOf(' - '),
value.indexOf(' '),
value.indexOf(''),
].where((int index) => index >= 0).toList(growable: false);
if (indexes.isEmpty) {
return -1;
}
indexes.sort();
return indexes.first;
}
static DateTime? _parseDateTime(String raw, {required DateTime now}) {
String value = raw
.trim()
.replaceFirst(RegExp(r'^[A-Za-z]{3,9},\s+'), '')
.replaceAll(RegExp(r'\s+'), ' ');
value = value.replaceAll(' um ', ' at ');
value = value.replaceAll(' à ', ' at ');
final RegExpMatch? relative = _relativeLabelPattern.firstMatch(value);
if (relative != null) {
final DateTime date = relative.group(1)!.toLowerCase() == 'yesterday'
? now.subtract(const Duration(days: 1))
: now;
return _dateTimeOrNull(
date.year,
date.month,
date.day,
_hour(relative.group(2), relative.group(5)),
_intOrNull(relative.group(3)) ?? 0,
_intOrNull(relative.group(4)) ?? 0,
);
}
final RegExpMatch? iso = _isoDateTimePattern.firstMatch(value);
if (iso != null && _looksLikeMetadataFragment(value, iso.group(0)!)) {
final String date = iso.group(1)!;
final String? time = iso.group(2);
final String? zone = iso.group(3);
final String isoValue = time == null
? date
: '${date}T$time${zone == null ? '' : _normalizeZone(zone)}';
final DateTime? parsed = DateTime.tryParse(isoValue);
if (parsed != null) {
return parsed.isUtc ? parsed.toLocal() : parsed;
}
}
final RegExpMatch? numeric = _numericDatePattern.firstMatch(value);
if (numeric != null &&
_looksLikeMetadataFragment(value, numeric.group(0)!)) {
final int first = int.parse(numeric.group(1)!);
final int second = int.parse(numeric.group(2)!);
final int year = _expandYear(int.parse(numeric.group(3)!));
final bool preferMonthFirst = _preferMonthFirst(value);
final int day;
final int month;
if (first > 12) {
day = first;
month = second;
} else if (second > 12) {
day = second;
month = first;
} else if (preferMonthFirst) {
day = second;
month = first;
} else {
day = first;
month = second;
}
return _dateTimeOrNull(
year,
month,
day,
_hour(numeric.group(4), numeric.group(7)),
_intOrNull(numeric.group(5)) ?? 0,
_intOrNull(numeric.group(6)) ?? 0,
);
}
final RegExpMatch? named = _monthNameDatePattern.firstMatch(value);
if (named != null && _looksLikeMetadataFragment(value, named.group(0)!)) {
final bool dayFirst = named.group(1) != null;
final int day = int.parse(dayFirst ? named.group(1)! : named.group(5)!);
final int? month = _monthNumber(
dayFirst ? named.group(2)! : named.group(4)!,
);
if (month == null) {
return null;
}
final int year = _expandYear(
int.parse(dayFirst ? named.group(3)! : named.group(6)!),
);
return _dateTimeOrNull(
year,
month,
day,
_hour(named.group(7), named.group(10)),
_intOrNull(named.group(8)) ?? 0,
_intOrNull(named.group(9)) ?? 0,
);
}
return null;
}
static bool _looksLikeMetadataFragment(String fragment, String match) {
final String value = fragment.trim();
if (value == match.trim()) {
return true;
}
if (value.startsWith(match)) {
final String tail = value.substring(match.length).trimLeft();
return tail.isEmpty ||
tail.startsWith('-') ||
tail.startsWith('') ||
tail.startsWith('') ||
tail.startsWith(']') ||
tail.startsWith(',') ||
tail.startsWith(':') ||
tail.toLowerCase().startsWith('at ');
}
return false;
}
static String _normalizeZone(String zone) {
if (zone == 'Z') {
return zone;
}
if (RegExp(r'^[+-]\d{4}$').hasMatch(zone)) {
return '${zone.substring(0, 3)}:${zone.substring(3)}';
}
return zone;
}
static bool _preferMonthFirst(String value) {
final String lower = value.toLowerCase();
return lower.contains('am') || lower.contains('pm');
}
static int _expandYear(int year) {
if (year >= 100) {
return year;
}
return year >= 70 ? 1900 + year : 2000 + year;
}
static int? _monthNumber(String raw) {
final String key = raw
.toLowerCase()
.replaceAll('.', '')
.replaceAll('ä', 'a')
.replaceAll('é', 'e')
.replaceAll('è', 'e')
.replaceAll('û', 'u')
.trim();
return _months[key] ?? _months[raw.toLowerCase().replaceAll('.', '')];
}
static int _hour(String? rawHour, String? meridiem) {
if (rawHour == null) {
return 0;
}
int hour = int.parse(rawHour);
final String? marker = meridiem?.toLowerCase();
if (marker == 'pm' && hour < 12) {
hour += 12;
}
if (marker == 'am' && hour == 12) {
hour = 0;
}
return hour;
}
static int? _intOrNull(String? value) {
if (value == null || value.isEmpty) {
return null;
}
return int.tryParse(value);
}
static DateTime? _dateTimeOrNull(
int year,
int month,
int day,
int hour,
int minute,
int second,
) {
if (month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59 ||
second < 0 ||
second > 59) {
return null;
}
final DateTime value = DateTime(year, month, day, hour, minute, second);
if (value.year != year || value.month != month || value.day != day) {
return null;
}
return value;
}
}