Add LLM digest setup and share timestamp context
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user