From 5d80af375ecf7b16cb59f13e6871f49479c59a44 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Tue, 19 May 2026 19:17:35 +0200 Subject: [PATCH] Add LLM digest setup and share timestamp context --- README.md | 11 + android/app/src/main/AndroidManifest.xml | 4 +- docs/SETUP.md | 27 ++ ios/Podfile.lock | 6 + ios/Runner/Info.plist | 7 + .../relationship_repository_ai_digest.dart | 14 +- .../data/relationship_repository_share.dart | 4 +- lib/core/config/app_config.dart | 31 +- lib/core/llm/llm_diagnostics_log.dart | 118 ++++++ lib/core/llm/llm_service.dart | 235 ++++++++++- .../llm/ollama/ollama_tool_chat_client.dart | 261 ++++++++++++ .../llm/ollama/ollama_web_search_client.dart | 173 ++++++++ lib/core/observability/sentry_init.dart | 81 +++- .../ai_digest_response_parser.dart | 52 +++ .../anonymized_llm_context_builder.dart | 142 +++++++ .../application/calendar_event_launcher.dart | 53 +++ .../application/llm_digest_orchestrator.dart | 108 ++++- .../application/llm_digest_text_client.dart | 3 + .../ai_digest/data/llm_digest_config.dart | 8 + .../ai_digest/domain/ai_digest_models.dart | 32 ++ .../presentation/ai_digest_review_view.dart | 19 + .../presentation/dashboard_view.dart | 335 +++++++++++++++- .../settings/presentation/settings_view.dart | 162 +++++++- .../share_intake/domain/share_models.dart | 12 +- .../domain/share_payload_parser.dart | 7 + .../shared_message_datetime_extractor.dart | 377 ++++++++++++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 51 +++ pubspec.lock | 64 +++ pubspec.yaml | 1 + test/core/llm/llm_diagnostics_log_test.dart | 41 ++ .../llm/ollama_tool_chat_client_test.dart | 125 ++++++ .../ai_digest_response_parser_test.dart | 13 +- .../anonymized_llm_context_builder_test.dart | 30 ++ .../calendar_event_launcher_test.dart | 53 +++ .../llm_digest_orchestrator_test.dart | 161 ++++++++ .../share_payload_parser_test.dart | 27 ++ ...hared_message_datetime_extractor_test.dart | 58 +++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 42 files changed, 2878 insertions(+), 39 deletions(-) create mode 100644 lib/core/llm/llm_diagnostics_log.dart create mode 100644 lib/core/llm/ollama/ollama_tool_chat_client.dart create mode 100644 lib/core/llm/ollama/ollama_web_search_client.dart create mode 100644 lib/features/ai_digest/application/calendar_event_launcher.dart create mode 100644 lib/features/share_intake/domain/shared_message_datetime_extractor.dart create mode 100644 test/core/llm/llm_diagnostics_log_test.dart create mode 100644 test/core/llm/ollama_tool_chat_client_test.dart create mode 100644 test/features/ai_digest/calendar_event_launcher_test.dart create mode 100644 test/features/share_intake/shared_message_datetime_extractor_test.dart diff --git a/README.md b/README.md index 133c2b3..6124c2b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ Notable build-time flags live in `lib/core/config/app_config.dart`: - `BACKGROUND_SYNC_INTERVAL_SECONDS` - `ENABLE_LOCAL_NOTIFICATIONS` - `ENABLE_WHATSAPP_SHARE_INTAKE` +- `SENTRY_DSN` defaults to the Bugsink project DSN +- `SENTRY_ENVIRONMENT` +- `SENTRY_RELEASE` ## Run @@ -75,6 +78,14 @@ flutter run \ --dart-define=BACKEND_BASE_URL=https://api.example.com ``` +Build a release with explicit Bugsink/Sentry metadata: + +```bash +flutter build apk --release \ + --dart-define=SENTRY_ENVIRONMENT=production \ + --dart-define=SENTRY_RELEASE=relationship_saver@1.0.0+1 +``` + Run tests: ```bash diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f1cfdc3..8f469af 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,11 @@ + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Relationship Saver can connect to your local Ollama server for private AI suggestions. NSPhotoLibraryUsageDescription Relationship Saver can import shared media and links you send into the app. UIApplicationSceneManifest diff --git a/lib/app/data/relationship_repository_ai_digest.dart b/lib/app/data/relationship_repository_ai_digest.dart index 0cb4cfd..bf1eb32 100644 --- a/lib/app/data/relationship_repository_ai_digest.dart +++ b/lib/app/data/relationship_repository_ai_digest.dart @@ -66,7 +66,7 @@ extension LocalRepositoryAiDigestOperations on LocalRepository { ? IdeaType.gift : IdeaType.event, title: draft.title, - details: _aiDraftDetails(draft), + details: _aiIdeaDetails(draft), createdAt: now, ); await _setState( @@ -156,6 +156,18 @@ String _aiDraftDetails(AiSuggestionDraft draft) { return '${draft.details.trim()}$reason'.trim(); } +String _aiIdeaDetails(AiSuggestionDraft draft) { + final List parts = [ + _aiDraftDetails(draft), + if (draft.eventLocation != null && draft.eventLocation!.trim().isNotEmpty) + 'Location: ${draft.eventLocation!.trim()}', + if (draft.eventUrl != null && draft.eventUrl!.trim().isNotEmpty) + 'Event: ${draft.eventUrl!.trim()}', + if (draft.sourceUrls.isNotEmpty) 'Sources: ${draft.sourceUrls.join(', ')}', + ].where((String value) => value.trim().isNotEmpty).toList(growable: false); + return parts.join('\n\n').trim(); +} + extension on Iterable { T? get firstOrNull => isEmpty ? null : first; } diff --git a/lib/app/data/relationship_repository_share.dart b/lib/app/data/relationship_repository_share.dart index 4a7628e..4c10934 100644 --- a/lib/app/data/relationship_repository_share.dart +++ b/lib/app/data/relationship_repository_share.dart @@ -12,6 +12,7 @@ extension LocalRepositoryShareOperations on LocalRepository { rawText: input.messageText.trim(), createdAt: input.sharedAt ?? now, receivedAt: now, + sharedMessageDateTime: input.sharedAt, platform: 'legacy', sourceDisplayName: _trimToNull(input.sourceDisplayName), sourceUserId: _trimToNull(input.sourceUserId), @@ -390,7 +391,8 @@ extension LocalRepositoryShareOperations on LocalRepository { String? consumedInboxEntryId, }) async { final String sourceApp = payload.sourceApp; - final DateTime sharedAt = payload.createdAt; + final DateTime sharedAt = + payload.sharedMessageDateTime ?? payload.createdAt; final String? sourceUserId = payload.sourceUserId; final String? sourceThreadId = payload.sourceThreadId; final bool createdSourceLink = matchedLink == null; diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 54f9194..486562c 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -11,6 +11,17 @@ class AppConfig { static const String _defaultSentryDsn = String.fromEnvironment( 'SENTRY_DSN', + defaultValue: + 'https://97fb0a56bc35440fba0ba139dc3b1ccc@bugs.zuzo.ch/2', + ); + + static const String _defaultSentryEnvironment = String.fromEnvironment( + 'SENTRY_ENVIRONMENT', + defaultValue: '', + ); + + static const String _defaultSentryRelease = String.fromEnvironment( + 'SENTRY_RELEASE', defaultValue: '', ); @@ -53,9 +64,27 @@ class AppConfig { defaultValue: 180, ); - /// Optional Sentry DSN for release crash/error reporting. + /// Sentry-compatible DSN for release crash/error reporting. static String get sentryDsn => _defaultSentryDsn.trim(); + /// Sentry environment label. + static String get sentryEnvironment { + final String configured = _defaultSentryEnvironment.trim(); + if (configured.isNotEmpty) { + return configured; + } + return useFakeBackend ? 'development' : 'production'; + } + + /// Optional Sentry release identifier. + static String? get sentryRelease { + final String configured = _defaultSentryRelease.trim(); + if (configured.isEmpty) { + return null; + } + return configured; + } + /// Runtime override for backend URL (e.g. local settings screen). static void overrideBackendBaseUrl(String? baseUrl) { _baseUrlOverride = baseUrl; diff --git a/lib/core/llm/llm_diagnostics_log.dart b/lib/core/llm/llm_diagnostics_log.dart new file mode 100644 index 0000000..163bf53 --- /dev/null +++ b/lib/core/llm/llm_diagnostics_log.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const String _llmDiagnosticsLogKey = 'llm_diagnostics_log_v1'; +const int _maxLlmDiagnosticsEvents = 40; +const int _maxLlmDiagnosticsDetailChars = 8000; + +class LlmDiagnosticsEvent { + const LlmDiagnosticsEvent({ + required this.at, + required this.stage, + required this.message, + this.details, + }); + + factory LlmDiagnosticsEvent.fromJson(Map json) { + return LlmDiagnosticsEvent( + at: DateTime.parse( + json['at'] as String? ?? DateTime.now().toUtc().toIso8601String(), + ).toLocal(), + stage: json['stage'] as String? ?? 'unknown', + message: json['message'] as String? ?? '', + details: json['details'] as String?, + ); + } + + final DateTime at; + final String stage; + final String message; + final String? details; + + Map toJson() { + return { + 'at': at.toUtc().toIso8601String(), + 'stage': stage, + 'message': message, + if (details != null) 'details': details, + }; + } +} + +class LlmDiagnosticsLog { + const LlmDiagnosticsLog(); + + Future> read() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? raw = prefs.getString(_llmDiagnosticsLogKey); + if (raw == null || raw.isEmpty) { + return const []; + } + try { + final List items = jsonDecode(raw) as List; + return items + .whereType>() + .map(LlmDiagnosticsEvent.fromJson) + .toList(growable: false); + } on FormatException { + return const []; + } + } + + Future record(String stage, String message, {String? details}) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final List current = await read(); + final List next = [ + LlmDiagnosticsEvent( + at: DateTime.now(), + stage: stage, + message: message, + details: _compactDetails(details), + ), + ...current, + ].take(_maxLlmDiagnosticsEvents).toList(growable: false); + await prefs.setString( + _llmDiagnosticsLogKey, + jsonEncode( + next + .map((LlmDiagnosticsEvent event) => event.toJson()) + .toList(growable: false), + ), + ); + } + + Future clear() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove(_llmDiagnosticsLogKey); + } + + String exportText(List events) { + if (events.isEmpty) { + return 'No LLM diagnostics recorded.'; + } + return events + .map((LlmDiagnosticsEvent event) { + final String details = event.details == null + ? '' + : '\n${event.details}'; + return '[${event.at.toIso8601String()}] ${event.stage}: ${event.message}$details'; + }) + .join('\n\n'); + } +} + +final Provider llmDiagnosticsLogProvider = + Provider((Ref ref) => const LlmDiagnosticsLog()); + +String? _compactDetails(String? value) { + final String? trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + if (trimmed.length <= _maxLlmDiagnosticsDetailChars) { + return trimmed; + } + return '${trimmed.substring(0, _maxLlmDiagnosticsDetailChars)}\n[truncated]'; +} diff --git a/lib/core/llm/llm_service.dart b/lib/core/llm/llm_service.dart index 7e23171..b041140 100644 --- a/lib/core/llm/llm_service.dart +++ b/lib/core/llm/llm_service.dart @@ -4,6 +4,9 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/core/llm/captured_fact_draft_parser.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/ollama/ollama_tool_chat_client.dart'; +import 'package:relationship_saver/core/observability/sentry_init.dart'; import 'package:relationship_saver/features/people/domain/person_models.dart'; import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; @@ -100,6 +103,7 @@ Generate 3-5 helpful relationship signals for these people.'''; Future completeText({ required String systemPrompt, required String userPrompt, + bool enableWebSearch = false, }) async { final config = await _config; final String? apiKey = await _ref @@ -117,6 +121,7 @@ Generate 3-5 helpful relationship signals for these people.'''; model: config.model, systemPrompt: systemPrompt, userPrompt: userPrompt, + enableWebSearch: enableWebSearch, ); } @@ -147,6 +152,9 @@ Be conservative. If the content is ambiguous, prefer "note". Return JSON only.'' Sender: ${payload.sourceDisplayName ?? "unknown"} Platform: ${payload.platform} URL: ${payload.url ?? "none"} +shared_message_datetime: ${payload.sharedMessageDateTime?.toIso8601String() ?? "null"} +Import received at: ${payload.receivedAt.toIso8601String()} +If shared_message_datetime is present, use it as the point-in-time reference for interpreting the message. Old status or health updates should usually become historical context or durable preferences, not current check-in prompts. Raw text: ${payload.rawText}'''; @@ -230,14 +238,30 @@ ${payload.rawText}'''; required String model, required String systemPrompt, required String userPrompt, + bool enableWebSearch = false, }) async { - final Dio dio = _createDio(); + final Dio dio = provider == LlmProvider.ollama + ? _createDio(receiveTimeout: const Duration(minutes: 5)) + : _createDio(); final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl); + await _recordDiagnostics( + 'request_start', + 'Starting ${provider.label} LLM request.', + details: _diagnosticsDetails( + provider: provider, + baseUrl: resolvedBaseUrl, + model: model, + enableWebSearch: enableWebSearch, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + ), + ); try { + late final String content; switch (provider) { case LlmProvider.openai: - return _callOpenAI( + content = await _callOpenAI( dio, resolvedBaseUrl, apiKey, @@ -246,7 +270,7 @@ ${payload.rawText}'''; userPrompt, ); case LlmProvider.anthropic: - return _callAnthropic( + content = await _callAnthropic( dio, resolvedBaseUrl, apiKey, @@ -255,24 +279,26 @@ ${payload.rawText}'''; userPrompt, ); case LlmProvider.google: - return _callGoogleAI( + content = await _callGoogleAI( dio, resolvedBaseUrl, apiKey, model, systemPrompt, userPrompt, + enableWebSearch: enableWebSearch, ); case LlmProvider.ollama: - return _callOllama( + content = await _callOllama( dio, resolvedBaseUrl, model, systemPrompt, userPrompt, + enableWebSearch: enableWebSearch, ); case LlmProvider.openaiCompatible: - return _callOpenAI( + content = await _callOpenAI( dio, resolvedBaseUrl, apiKey, @@ -281,11 +307,88 @@ ${payload.rawText}'''; userPrompt, ); } + await _recordDiagnostics( + 'request_success', + '${provider.label} returned a response.', + details: _diagnosticsDetails( + provider: provider, + baseUrl: resolvedBaseUrl, + model: model, + enableWebSearch: enableWebSearch, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + responseLength: content.length, + ), + ); + return content; } on DioException catch (error) { + await _recordProviderFailure( + provider: provider, + baseUrl: resolvedBaseUrl, + model: model, + enableWebSearch: enableWebSearch, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + error: error, + ); throw _mapProviderError(provider, error); } } + Future _recordDiagnostics( + String stage, + String message, { + String? details, + }) async { + try { + await _ref + .read(llmDiagnosticsLogProvider) + .record(stage, message, details: details); + } catch (_) { + // Diagnostics must never break the user-facing LLM flow. + } + } + + Future _recordProviderFailure({ + required LlmProvider provider, + required String baseUrl, + required String model, + required bool enableWebSearch, + required String systemPrompt, + required String userPrompt, + required DioException error, + }) { + final int? statusCode = error.response?.statusCode; + final String status = statusCode == null + ? 'without HTTP status' + : '$statusCode'; + final Map context = _diagnosticsMap( + provider: provider, + baseUrl: baseUrl, + model: model, + enableWebSearch: enableWebSearch, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + dioType: error.type.name, + statusCode: statusCode, + providerMessage: _extractProviderMessage(error.response?.data), + responseBody: _safeJson(error.response?.data), + ); + _ref + .read(sentryInitProvider) + .captureException( + error, + stackTrace: error.stackTrace, + area: 'llm_provider', + context: context, + ); + return _recordDiagnostics( + 'provider_error', + '${provider.label} request failed with $status.', + details: const JsonEncoder.withIndent(' ').convert(context), + ); + } + Future _callOpenAI( Dio dio, String baseUrl, @@ -364,8 +467,9 @@ ${payload.rawText}'''; String? apiKey, String model, String systemPrompt, - String userPrompt, - ) async { + String userPrompt, { + bool enableWebSearch = false, + }) async { final String modelPath = model.startsWith('models/') ? model : 'models/$model'; @@ -387,6 +491,10 @@ ${payload.rawText}'''; ], }, ], + if (enableWebSearch) + 'tools': [ + {'google_search': {}}, + ], 'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000}, }), ); @@ -409,8 +517,20 @@ ${payload.rawText}'''; String baseUrl, String model, String systemPrompt, - String userPrompt, - ) async { + String userPrompt, { + bool enableWebSearch = false, + }) async { + if (enableWebSearch) { + return _ref + .read(ollamaToolChatClientProvider) + .complete( + baseUrl: baseUrl, + model: model, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + ); + } + final Response> response = await dio .post>( '$baseUrl/api/chat', @@ -609,6 +729,97 @@ LlmProviderException _mapProviderError( ); } +String _diagnosticsDetails({ + required LlmProvider provider, + required String baseUrl, + required String model, + required bool enableWebSearch, + required String systemPrompt, + required String userPrompt, + int? responseLength, + String? dioType, + int? statusCode, + String? providerMessage, + String? responseBody, +}) { + return const JsonEncoder.withIndent(' ').convert( + _diagnosticsMap( + provider: provider, + baseUrl: baseUrl, + model: model, + enableWebSearch: enableWebSearch, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + responseLength: responseLength, + dioType: dioType, + statusCode: statusCode, + providerMessage: providerMessage, + responseBody: responseBody, + ), + ); +} + +Map _diagnosticsMap({ + required LlmProvider provider, + required String baseUrl, + required String model, + required bool enableWebSearch, + required String systemPrompt, + required String userPrompt, + int? responseLength, + String? dioType, + int? statusCode, + String? providerMessage, + String? responseBody, +}) { + final Map diagnostics = { + 'provider': provider.name, + 'baseUrl': baseUrl, + 'model': model, + 'webSearchRequested': enableWebSearch, + 'systemPromptChars': systemPrompt.length, + 'userPromptChars': userPrompt.length, + }; + if (responseLength != null) { + diagnostics['responseChars'] = responseLength; + } + if (dioType != null) { + diagnostics['dioType'] = dioType; + } + if (statusCode != null) { + diagnostics['statusCode'] = statusCode; + } + if (providerMessage != null) { + diagnostics['providerMessage'] = providerMessage; + } + if (responseBody != null) { + diagnostics['responseBody'] = responseBody; + } + return diagnostics; +} + +String? _safeJson(Object? data) { + if (data == null) { + return null; + } + try { + final String encoded = data is String + ? data + : const JsonEncoder.withIndent(' ').convert(data); + return _compactDiagnosticValue(encoded); + } catch (_) { + return _compactDiagnosticValue('$data'); + } +} + +String _compactDiagnosticValue(String value) { + final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (compacted.length <= 3000) { + return compacted; + } + return '${compacted.substring(0, 2997)}...'; +} + String _normalizeBaseUrl(String baseUrl) { String normalized = baseUrl.trim(); while (normalized.endsWith('/')) { @@ -617,11 +828,11 @@ String _normalizeBaseUrl(String baseUrl) { return normalized; } -Dio _createDio() { +Dio _createDio({Duration receiveTimeout = const Duration(seconds: 20)}) { return Dio( BaseOptions( connectTimeout: const Duration(seconds: 20), - receiveTimeout: const Duration(seconds: 20), + receiveTimeout: receiveTimeout, sendTimeout: const Duration(seconds: 20), ), ); diff --git a/lib/core/llm/ollama/ollama_tool_chat_client.dart b/lib/core/llm/ollama/ollama_tool_chat_client.dart new file mode 100644 index 0000000..df494e3 --- /dev/null +++ b/lib/core/llm/ollama/ollama_tool_chat_client.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/llm/ollama/ollama_web_search_client.dart'; + +class OllamaToolChatClient { + OllamaToolChatClient({ + required OllamaWebSearchClient webSearchClient, + Dio? dio, + int maxToolRounds = 3, + }) : _webSearchClient = webSearchClient, + _dio = dio ?? _createDio(), + _maxToolRounds = maxToolRounds; + + final OllamaWebSearchClient _webSearchClient; + final Dio _dio; + final int _maxToolRounds; + + Future complete({ + required String baseUrl, + required String model, + required String systemPrompt, + required String userPrompt, + }) async { + final List> messages = >[ + {'role': 'system', 'content': _toolSystemPrompt(systemPrompt)}, + {'role': 'user', 'content': userPrompt}, + ]; + + String latestContent = ''; + for (int round = 0; round <= _maxToolRounds; round += 1) { + final Map message = await _chat( + baseUrl: baseUrl, + model: model, + messages: messages, + ); + latestContent = _stringValue(message['content']); + + final List> toolCalls = _toolCalls(message); + if (toolCalls.isEmpty) { + return latestContent; + } + + messages.add({ + 'role': 'assistant', + 'content': latestContent, + 'tool_calls': toolCalls, + }); + + for (final Map toolCall in toolCalls) { + final String toolName = _toolName(toolCall); + final Map arguments = _toolArguments(toolCall); + final String toolResult = await _executeTool(toolName, arguments); + messages.add({ + 'role': 'tool', + 'tool_name': toolName, + 'content': toolResult, + }); + } + } + + if (latestContent.trim().isNotEmpty) { + return latestContent; + } + throw StateError( + 'Ollama did not return a final response after tool calls.', + ); + } + + Future> _chat({ + required String baseUrl, + required String model, + required List> messages, + }) async { + final Response> response = await _dio + .post>( + '$baseUrl/api/chat', + options: Options(headers: {'Content-Type': 'application/json'}), + data: jsonEncode({ + 'model': model, + 'stream': false, + 'messages': messages, + 'tools': _tools, + }), + ); + final Map data = response.data ?? {}; + final Object? message = data['message']; + if (message is Map) { + return message; + } + return {}; + } + + Future _executeTool( + String toolName, + Map arguments, + ) async { + try { + switch (toolName) { + case 'search_web': + final String query = _stringValue(arguments['query']); + final int maxResults = + (arguments['maxResults'] as num?)?.toInt() ?? 5; + final List results = await _webSearchClient + .search(query, maxResults: maxResults); + return jsonEncode({ + 'query': query, + 'results': results + .map((OllamaWebSearchResult result) => result.toJson()) + .toList(growable: false), + }); + case 'fetch_web_page': + final String url = _stringValue(arguments['url']); + final String text = await _webSearchClient.fetchPageText(url); + return jsonEncode({'url': url, 'text': text}); + default: + return jsonEncode({ + 'error': 'Unknown tool: $toolName', + }); + } + } catch (error) { + return jsonEncode({ + 'error': 'Tool $toolName failed: $error', + }); + } + } +} + +String _toolSystemPrompt(String systemPrompt) { + return ''' +$systemPrompt + +When you need current web information, call search_web first. Use fetch_web_page +only for URLs returned by search_web or URLs present in the user input. After +tool results are available, produce the final answer in the exact JSON schema +requested above. Do not expose tool-call internals in the final answer. +'''; +} + +List> _toolCalls(Map message) { + final Object? toolCalls = message['tool_calls']; + if (toolCalls is! List) { + return const >[]; + } + return toolCalls + .whereType>() + .map( + (Map item) => + item.map((dynamic key, dynamic value) => MapEntry('$key', value)), + ) + .toList(growable: false); +} + +String _toolName(Map toolCall) { + final Object? function = toolCall['function']; + if (function is Map) { + return _stringValue(function['name']); + } + return _stringValue(toolCall['name']); +} + +Map _toolArguments(Map toolCall) { + final Object? function = toolCall['function']; + final Object? rawArguments = function is Map + ? function['arguments'] + : toolCall['arguments']; + if (rawArguments is Map) { + return rawArguments; + } + if (rawArguments is Map) { + return rawArguments.map( + (dynamic key, dynamic value) => MapEntry('$key', value), + ); + } + if (rawArguments is String && rawArguments.trim().isNotEmpty) { + final Object? decoded = jsonDecode(rawArguments); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.map( + (dynamic key, dynamic value) => MapEntry('$key', value), + ); + } + } + return const {}; +} + +String _stringValue(Object? value) { + if (value is String) { + return value.trim(); + } + return ''; +} + +List> get _tools { + return >[ + { + 'type': 'function', + 'function': { + 'name': 'search_web', + 'description': + 'Search the public web for current event, venue, date, price, ' + 'availability, and source information.', + 'parameters': { + 'type': 'object', + 'properties': { + 'query': { + 'type': 'string', + 'description': 'Specific search query.', + }, + 'maxResults': { + 'type': 'integer', + 'description': 'Maximum number of search results to return.', + 'minimum': 1, + 'maximum': 10, + }, + }, + 'required': ['query'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'fetch_web_page', + 'description': + 'Fetch readable text from a specific web page when the search ' + 'snippet is not enough to verify event details.', + 'parameters': { + 'type': 'object', + 'properties': { + 'url': { + 'type': 'string', + 'description': 'HTTP or HTTPS URL to fetch.', + }, + }, + 'required': ['url'], + }, + }, + }, + ]; +} + +Dio _createDio() { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(minutes: 5), + sendTimeout: const Duration(seconds: 20), + ), + ); +} + +final Provider ollamaToolChatClientProvider = + Provider((Ref ref) { + return OllamaToolChatClient( + webSearchClient: ref.watch(ollamaWebSearchClientProvider), + ); + }); diff --git a/lib/core/llm/ollama/ollama_web_search_client.dart b/lib/core/llm/ollama/ollama_web_search_client.dart new file mode 100644 index 0000000..5b49f79 --- /dev/null +++ b/lib/core/llm/ollama/ollama_web_search_client.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class OllamaWebSearchResult { + const OllamaWebSearchResult({ + required this.title, + required this.url, + required this.snippet, + }); + + final String title; + final String url; + final String snippet; + + Map toJson() { + return {'title': title, 'url': url, 'snippet': snippet}; + } +} + +abstract interface class OllamaWebSearchClient { + Future> search( + String query, { + int maxResults = 5, + }); + + Future fetchPageText(String url); +} + +class DuckDuckGoOllamaWebSearchClient implements OllamaWebSearchClient { + DuckDuckGoOllamaWebSearchClient({Dio? dio}) : _dio = dio ?? _createDio(); + + final Dio _dio; + + @override + Future> search( + String query, { + int maxResults = 5, + }) async { + final String normalizedQuery = query.trim(); + if (normalizedQuery.isEmpty) { + return const []; + } + + final Response> response = await _dio + .get>( + 'https://api.duckduckgo.com/', + queryParameters: { + 'q': normalizedQuery, + 'format': 'json', + 'no_html': 1, + 'skip_disambig': 1, + }, + ); + final Map data = response.data ?? {}; + final List results = []; + + final String abstractUrl = _stringValue(data['AbstractURL']); + final String abstractText = _stringValue(data['AbstractText']); + final String heading = _stringValue(data['Heading']); + if (abstractUrl.isNotEmpty && abstractText.isNotEmpty) { + results.add( + OllamaWebSearchResult( + title: heading.isEmpty ? abstractUrl : heading, + url: abstractUrl, + snippet: abstractText, + ), + ); + } + + _collectRelatedTopics(data['Results'], results); + _collectRelatedTopics(data['RelatedTopics'], results); + + final Set seenUrls = {}; + return results + .where((OllamaWebSearchResult result) => seenUrls.add(result.url)) + .take(maxResults.clamp(1, 10)) + .toList(growable: false); + } + + @override + Future fetchPageText(String url) async { + final Uri? uri = Uri.tryParse(url.trim()); + if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https')) { + return jsonEncode({'error': 'Invalid URL.'}); + } + + final Response response = await _dio.get( + uri.toString(), + options: Options(responseType: ResponseType.plain), + ); + return _compactText(_htmlToText(response.data ?? '')); + } + + static void _collectRelatedTopics( + Object? value, + List results, + ) { + if (value is! List) { + return; + } + for (final Object? item in value) { + if (item is! Map) { + continue; + } + final Object? nestedTopics = item['Topics']; + if (nestedTopics is List) { + _collectRelatedTopics(nestedTopics, results); + continue; + } + + final String firstUrl = _stringValue(item['FirstURL']); + final String text = _stringValue(item['Text']); + if (firstUrl.isEmpty || text.isEmpty) { + continue; + } + results.add( + OllamaWebSearchResult( + title: text.split(' - ').first.trim(), + url: firstUrl, + snippet: text, + ), + ); + } + } +} + +String _stringValue(Object? value) { + if (value is String) { + return value.trim(); + } + return ''; +} + +String _htmlToText(String html) { + return html + .replaceAll( + RegExp(r'', caseSensitive: false), + ' ', + ) + .replaceAll(RegExp(r'', caseSensitive: false), ' ') + .replaceAll(RegExp(r'<[^>]+>'), ' ') + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'"); +} + +String _compactText(String value) { + final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (compacted.length <= 4000) { + return compacted; + } + return '${compacted.substring(0, 3997)}...'; +} + +Dio _createDio() { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 20), + sendTimeout: const Duration(seconds: 20), + ), + ); +} + +final Provider ollamaWebSearchClientProvider = + Provider((Ref ref) { + return DuckDuckGoOllamaWebSearchClient(); + }); diff --git a/lib/core/observability/sentry_init.dart b/lib/core/observability/sentry_init.dart index 1a1aeea..7db24c3 100644 --- a/lib/core/observability/sentry_init.dart +++ b/lib/core/observability/sentry_init.dart @@ -28,7 +28,7 @@ class SentryInit { Future _initializeInternal({ FutureOr Function()? appRunner, }) async { - if (!kReleaseMode || AppConfig.sentryDsn.isEmpty) { + if (AppConfig.sentryDsn.isEmpty) { if (appRunner != null) { await Future.sync(appRunner); } @@ -37,25 +37,90 @@ class SentryInit { await SentryFlutter.init((options) { options.dsn = AppConfig.sentryDsn; - options.environment = AppConfig.useFakeBackend - ? 'development' - : 'production'; + options.environment = AppConfig.sentryEnvironment; + options.release = AppConfig.sentryRelease; + options.sendDefaultPii = false; options.tracesSampleRate = 0.1; }, appRunner: appRunner); + await Sentry.configureScope((scope) async { + await scope.setTag('service', 'relationship_saver'); + await scope.setTag('build_mode', kReleaseMode ? 'release' : 'debug'); + }); + _installGlobalHandlers(); _initialized = true; } - void captureException(Object error, {StackTrace? stackTrace}) { + void captureException( + Object error, { + StackTrace? stackTrace, + String? area, + Map? context, + }) { if (_initialized) { - Sentry.captureException(error, stackTrace: stackTrace); + unawaited( + Sentry.captureException( + error, + stackTrace: stackTrace, + withScope: (scope) async { + if (area != null) { + await scope.setTag('area', area); + } + if (context != null) { + await scope.setContexts('diagnostics', context); + } + }, + ), + ); } } - void captureMessage(String message, {SentryLevel level = SentryLevel.info}) { + void captureMessage( + String message, { + SentryLevel level = SentryLevel.info, + String? area, + Map? context, + }) { if (_initialized) { - Sentry.captureMessage(message, level: level); + unawaited( + Sentry.captureMessage( + message, + level: level, + withScope: (scope) async { + if (area != null) { + await scope.setTag('area', area); + } + if (context != null) { + await scope.setContexts('diagnostics', context); + } + }, + ), + ); } } + + void _installGlobalHandlers() { + final FlutterExceptionHandler? previousFlutterHandler = + FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + previousFlutterHandler?.call(details); + captureException( + details.exception, + stackTrace: details.stack, + area: 'flutter_framework', + context: { + 'library': details.library, + 'context': details.context?.toDescription(), + }, + ); + }; + + final ErrorCallback? previousPlatformHandler = + PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { + captureException(error, stackTrace: stack, area: 'platform_dispatcher'); + return previousPlatformHandler?.call(error, stack) ?? false; + }; + } } final sentryInitProvider = Provider((Ref ref) { diff --git a/lib/features/ai_digest/application/ai_digest_response_parser.dart b/lib/features/ai_digest/application/ai_digest_response_parser.dart index 3a27da3..ff9f86b 100644 --- a/lib/features/ai_digest/application/ai_digest_response_parser.dart +++ b/lib/features/ai_digest/application/ai_digest_response_parser.dart @@ -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 _sourceUrls(Object? raw) { + final List values = raw is List ? raw : [raw]; + final Set urls = {}; + for (final Object? value in values) { + if (value is Map) { + 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; + } } diff --git a/lib/features/ai_digest/application/anonymized_llm_context_builder.dart b/lib/features/ai_digest/application/anonymized_llm_context_builder.dart index 2b2b4c9..5431fba 100644 --- a/lib/features/ai_digest/application/anonymized_llm_context_builder.dart +++ b/lib/features/ai_digest/application/anonymized_llm_context_builder.dart @@ -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': { '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> _capturedFacts( + LocalDataState state, + PersonProfile person, + DateTime now, + ) { + final List identityTerms = _identityTerms(state); + final Map 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 { + 'type': _factTypeLabel(fact.type), + if (_safeContextText(fact.label, identityTerms).isNotEmpty) + 'label': _safeContextText(fact.label, identityTerms), + 'summary': _safeContextText(fact.text, identityTerms), + if (sharedMessageDateTime != null) ...{ + 'shared_message_datetime': sharedMessageDateTime + .toUtc() + .toIso8601String(), + 'sourceAge': _ageLabel(sharedMessageDateTime, now), + }, + }; + }) + .where( + (Map fact) => + fact['summary'] != null && fact['summary']!.isNotEmpty, + ) + .toList(growable: false); + } + + List> _preferenceSignals( + List signals, + DateTime now, + ) { + return signals + .map( + (PersonPreferenceSignal signal) => { + '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 signal) => signal['label']!.isNotEmpty) + .take(maxSignalsPerPerson) + .toList(growable: false); + } + + Map _sharedMessageTimes(LocalDataState state) { + return { + for (final SharedMessageEntry entry in state.sharedMessages) + if (entry.sharedMessageDateTime != null) + entry.id: entry.sharedMessageDateTime!, + }; + } + List> _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 _safeList(Iterable values) { final List out = []; final Set seen = {}; @@ -297,6 +387,58 @@ class AnonymizedLlmContextBuilder { .toLowerCase(); } + String _safeContextText(String? value, List 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 _identityTerms(LocalDataState state) { + final Set terms = {}; + for (final PersonProfile person in state.people) { + for (final String raw in [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 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; } diff --git a/lib/features/ai_digest/application/calendar_event_launcher.dart b/lib/features/ai_digest/application/calendar_event_launcher.dart new file mode 100644 index 0000000..20aa573 --- /dev/null +++ b/lib/features/ai_digest/application/calendar_event_launcher.dart @@ -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 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 = [ + 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', { + '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'; +} diff --git a/lib/features/ai_digest/application/llm_digest_orchestrator.dart b/lib/features/ai_digest/application/llm_digest_orchestrator.dart index aeae0a1..3b643fb 100644 --- a/lib/features/ai_digest/application/llm_digest_orchestrator.dart +++ b/lib/features/ai_digest/application/llm_digest_orchestrator.dart @@ -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: { + '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: { + 'bypassCooldown': bypassCooldown, + 'notify': notify, + }, + ); await _markFailure(reason); return LlmDigestRunResult( started: true, @@ -196,6 +221,45 @@ class LlmDigestOrchestrator { } } + Future _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: { + 'sourceRunId': sourceRunId, + 'provider': error.provider.name, + 'statusCode': error.statusCode, + }, + ); + return _ref + .read(llmDigestTextClientProvider) + .complete( + systemPrompt: _systemPrompt(enableWebSearch: false), + userPrompt: userPrompt, + enableWebSearch: false, + ); + } + } + Future _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 llmDigestOrchestratorProvider = Provider((Ref ref) => LlmDigestOrchestrator(ref)); diff --git a/lib/features/ai_digest/application/llm_digest_text_client.dart b/lib/features/ai_digest/application/llm_digest_text_client.dart index 800d0e3..e38ca0e 100644 --- a/lib/features/ai_digest/application/llm_digest_text_client.dart +++ b/lib/features/ai_digest/application/llm_digest_text_client.dart @@ -5,6 +5,7 @@ abstract interface class LlmDigestTextClient { Future complete({ required String systemPrompt, required String userPrompt, + bool enableWebSearch = false, }); } @@ -17,10 +18,12 @@ class ConfiguredLlmDigestTextClient implements LlmDigestTextClient { Future complete({ required String systemPrompt, required String userPrompt, + bool enableWebSearch = false, }) { return _service.completeText( systemPrompt: systemPrompt, userPrompt: userPrompt, + enableWebSearch: enableWebSearch, ); } } diff --git a/lib/features/ai_digest/data/llm_digest_config.dart b/lib/features/ai_digest/data/llm_digest_config.dart index 95c3825..edc5bd3 100644 --- a/lib/features/ai_digest/data/llm_digest_config.dart +++ b/lib/features/ai_digest/data/llm_digest_config.dart @@ -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 { Future? _initializeFuture; @@ -29,6 +30,7 @@ class LlmDigestConfigNotifier extends Notifier { 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 { requireCharging: requireCharging, ); } + + Future setWebSearchEnabled(bool enabled) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_enableWebSearchKey, enabled); + state = state.copyWith(enableWebSearch: enabled); + } } final NotifierProvider diff --git a/lib/features/ai_digest/domain/ai_digest_models.dart b/lib/features/ai_digest/domain/ai_digest_models.dart index b3deaa5..abe8dab 100644 --- a/lib/features/ai_digest/domain/ai_digest_models.dart +++ b/lib/features/ai_digest/domain/ai_digest_models.dart @@ -21,6 +21,10 @@ class AiSuggestionDraft { required this.status, required this.sourceRunId, this.suggestedFor, + this.eventEndsAt, + this.eventLocation, + this.eventUrl, + this.sourceUrls = const [], 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 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? 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? ?? []) + .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, ); } } diff --git a/lib/features/ai_digest/presentation/ai_digest_review_view.dart b/lib/features/ai_digest/presentation/ai_digest_review_view.dart index 88c8c08..a4646ed 100644 --- a/lib/features/ai_digest/presentation/ai_digest_review_view.dart +++ b/lib/features/ai_digest/presentation/ai_digest_review_view.dart @@ -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 _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, diff --git a/lib/features/dashboard/presentation/dashboard_view.dart b/lib/features/dashboard/presentation/dashboard_view.dart index 40e4ed3..e478eaa 100644 --- a/lib/features/dashboard/presentation/dashboard_view.dart +++ b/lib/features/dashboard/presentation/dashboard_view.dart @@ -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 tasks = data.tasks; + final DateTime now = DateTime.now(); + final List upcomingEventSuggestions = + _upcomingEventSuggestions(data.aiSuggestionDrafts, now); + final Map peopleById = { + 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 unresolvedShares; + final List upcomingEvents; + final Map peopleById; + final bool compact; + + @override + Widget build(BuildContext context) { + final List sections = [ + _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: [ + Expanded(child: sections[0]), + const SizedBox(width: 14), + Expanded(child: sections[1]), + ], + ); + } +} + +class _UnresolvedSharesPanel extends StatelessWidget { + const _UnresolvedSharesPanel({required this.entries}); + + final List entries; + + @override + Widget build(BuildContext context) { + final List visibleEntries = entries + .take(3) + .toList(growable: false); + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + 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: [ + const Icon(Icons.circle, size: 8, color: Color(0xFFFFA447)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 events; + final Map peopleById; + + @override + Widget build(BuildContext context) { + return FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + 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: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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) ...[ + 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 _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 _upcomingEventSuggestions( + List drafts, + DateTime now, +) { + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime windowEnd = today.add(const Duration(days: 30)); + final List 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 weekdays = [ 'Mon', diff --git a/lib/features/settings/presentation/settings_view.dart b/lib/features/settings/presentation/settings_view.dart index f437aba..b5b3e19 100644 --- a/lib/features/settings/presentation/settings_view.dart +++ b/lib/features/settings/presentation/settings_view.dart @@ -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 _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 _showLlmDebug(BuildContext context) async { await showDialog( 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 _events = const []; @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: [ + 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 _loadDiagnostics() async { + final List events = await ref + .read(llmDiagnosticsLogProvider) + .read(); + if (!mounted) { + return; + } + setState(() { + _events = events; + _loadingEvents = false; + }); + } + + Future _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 _clearDiagnostics() async { + await ref.read(llmDiagnosticsLogProvider).clear(); + if (!mounted) { + return; + } + setState(() { + _events = const []; + }); + } + Future _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: [ + 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) ...[ + 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}); diff --git a/lib/features/share_intake/domain/share_models.dart b/lib/features/share_intake/domain/share_models.dart index fc1eb0a..e51f03f 100644 --- a/lib/features/share_intake/domain/share_models.dart +++ b/lib/features/share_intake/domain/share_models.dart @@ -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; diff --git a/lib/features/share_intake/domain/share_payload_parser.dart b/lib/features/share_intake/domain/share_payload_parser.dart index 25c0ab5..bdd9b8a 100644 --- a/lib/features/share_intake/domain/share_payload_parser.dart +++ b/lib/features/share_intake/domain/share_payload_parser.dart @@ -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: { if (url case final String value) 'primaryUrl': value, + if (sharedMessageDateTime case final DateTime value) + 'shared_message_datetime': value.toUtc().toIso8601String(), }, ); } diff --git a/lib/features/share_intake/domain/shared_message_datetime_extractor.dart b/lib/features/share_intake/domain/shared_message_datetime_extractor.dart new file mode 100644 index 0000000..f0cbfb9 --- /dev/null +++ b/lib/features/share_intake/domain/shared_message_datetime_extractor.dart @@ -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 _months = { + '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 [ + 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 _candidateFragments(String input) { + final List candidates = []; + final List 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 indexes = [ + 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; + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 0a1722c..28722f2 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = @@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) sentry_flutter_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin"); sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index fbd7764..d4e32b2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux sentry_flutter + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5a4d532..5188fe6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import flutter_secure_storage_darwin import package_info_plus import sentry_flutter import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) @@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index d2d3b17..04232e9 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,22 +1,73 @@ PODS: + - battery_plus (0.0.1): + - FlutterMacOS + - connectivity_plus (0.0.1): + - FlutterMacOS + - flutter_local_notifications (0.0.1): + - FlutterMacOS - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS - FlutterMacOS (1.0.0) + - package_info_plus (0.0.1): + - FlutterMacOS + - Sentry/HybridSDK (8.46.0) + - sentry_flutter (8.14.2): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.46.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS DEPENDENCIES: + - battery_plus (from `Flutter/ephemeral/.symlinks/plugins/battery_plus/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - FlutterMacOS (from `Flutter/ephemeral`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +SPEC REPOS: + trunk: + - Sentry EXTERNAL SOURCES: + battery_plus: + :path: Flutter/ephemeral/.symlinks/plugins/battery_plus/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos flutter_secure_storage_darwin: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin FlutterMacOS: :path: Flutter/ephemeral + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + sentry_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: + battery_plus: f51ad29136e025b714b96f7d096f44f604615da7 + connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e + flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 + sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/pubspec.lock b/pubspec.lock index 784921e..b30369d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1029,6 +1029,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index f0d94bd..87d7e10 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: flutter_local_notifications: ^20.1.0 workmanager: ^0.9.0+3 sentry_flutter: ^8.14.2 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: diff --git a/test/core/llm/llm_diagnostics_log_test.dart b/test/core/llm/llm_diagnostics_log_test.dart new file mode 100644 index 0000000..02b8447 --- /dev/null +++ b/test/core/llm/llm_diagnostics_log_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/core/llm/llm_diagnostics_log.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('records and exports LLM diagnostics', () async { + const LlmDiagnosticsLog log = LlmDiagnosticsLog(); + + await log.record( + 'provider_error', + 'Google AI request failed with 429.', + details: '{"statusCode":429,"responseBody":"quota"}', + ); + + final List events = await log.read(); + + expect(events, hasLength(1)); + expect(events.single.stage, 'provider_error'); + expect(events.single.details, contains('statusCode')); + expect(log.exportText(events), contains('Google AI request failed')); + }); + + test('trims very large diagnostics before storing', () async { + const LlmDiagnosticsLog log = LlmDiagnosticsLog(); + + await log.record( + 'provider_error', + 'large', + details: List.filled(9000, 'x').join(), + ); + + final List events = await log.read(); + + expect(events.single.details, endsWith('[truncated]')); + expect(events.single.details!.length, lessThan(9000)); + }); +} diff --git a/test/core/llm/ollama_tool_chat_client_test.dart b/test/core/llm/ollama_tool_chat_client_test.dart new file mode 100644 index 0000000..a329278 --- /dev/null +++ b/test/core/llm/ollama_tool_chat_client_test.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/core/llm/ollama/ollama_tool_chat_client.dart'; +import 'package:relationship_saver/core/llm/ollama/ollama_web_search_client.dart'; +import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; + +import '../../helpers/queue_http_client_adapter.dart'; + +void main() { + test( + 'runs Ollama tool calls and feeds search results back to model', + () async { + final List> requestBodies = >[]; + final QueueHttpClientAdapter adapter = QueueHttpClientAdapter( + [ + ( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requestBodies.add( + jsonDecode(options.data as String) as Map, + ); + return jsonResponse(200, { + 'message': { + 'role': 'assistant', + 'content': '', + 'tool_calls': >[ + { + 'function': { + 'name': 'search_web', + 'arguments': { + 'query': 'Zurich jazz events May 2026', + 'maxResults': 3, + }, + }, + }, + ], + }, + }); + }, + ( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requestBodies.add( + jsonDecode(options.data as String) as Map, + ); + return jsonResponse(200, { + 'message': { + 'role': 'assistant', + 'content': ''' +{ + "suggestions": [ + { + "personToken": "person_001", + "kind": "eventIdea", + "title": "Jazz night", + "details": "A grounded event suggestion.", + "confidence": 0.8 + } + ] +} +''', + }, + }); + }, + ], + ); + final Dio dio = Dio(); + dio.httpClientAdapter = adapter; + final OllamaToolChatClient client = OllamaToolChatClient( + dio: dio, + webSearchClient: const FakeWebSearchClient(), + ); + + final String response = await client.complete( + baseUrl: 'http://macbook.local:11434', + model: 'qwen3:8b', + systemPrompt: 'Return digest JSON.', + userPrompt: '{"people":[]}', + ); + + expect(response, contains(AiSuggestionKind.eventIdea.name)); + expect(adapter.fetchCount, 2); + expect(requestBodies.first['tools'], isA>()); + final List secondMessages = + requestBodies.last['messages'] as List; + final Map toolMessage = secondMessages + .whereType>() + .singleWhere( + (Map message) => message['role'] == 'tool', + ); + expect(toolMessage['tool_name'], 'search_web'); + expect(toolMessage['content'], contains('moods.ch')); + }, + ); +} + +class FakeWebSearchClient implements OllamaWebSearchClient { + const FakeWebSearchClient(); + + @override + Future fetchPageText(String url) async { + return 'Fetched text for $url'; + } + + @override + Future> search( + String query, { + int maxResults = 5, + }) async { + return const [ + OllamaWebSearchResult( + title: 'Moods Jazz Club', + url: 'https://www.moods.ch/', + snippet: 'Upcoming jazz concerts in Zurich.', + ), + ]; + } +} diff --git a/test/features/ai_digest/ai_digest_response_parser_test.dart b/test/features/ai_digest/ai_digest_response_parser_test.dart index 19961bb..5170259 100644 --- a/test/features/ai_digest/ai_digest_response_parser_test.dart +++ b/test/features/ai_digest/ai_digest_response_parser_test.dart @@ -10,10 +10,14 @@ void main() { "suggestions": [ { "personToken": "person_001", - "kind": "giftIdea", + "kind": "eventIdea", "title": "Coffee tasting set", "details": "Pick a small local sampler.", "suggestedTiming": "2026-05-20T18:00:00Z", + "eventEndsAt": "2026-05-20T20:00:00Z", + "eventLocation": "Local roaster hall", + "eventUrl": "https://example.com/events/coffee", + "sourceUrls": ["https://example.com/events/coffee"], "confidence": 0.82, "reason": "Matches known interests." } @@ -28,8 +32,13 @@ void main() { expect(result.success, isTrue); expect(result.drafts, hasLength(1)); expect(result.drafts.single.personId, 'p-1'); - expect(result.drafts.single.kind, AiSuggestionKind.giftIdea); + expect(result.drafts.single.kind, AiSuggestionKind.eventIdea); expect(result.drafts.single.status, AiSuggestionStatus.pending); + expect(result.drafts.single.eventLocation, 'Local roaster hall'); + expect(result.drafts.single.eventUrl, 'https://example.com/events/coffee'); + expect(result.drafts.single.sourceUrls, [ + 'https://example.com/events/coffee', + ]); }); test('rejects unknown person tokens and malformed items', () { diff --git a/test/features/ai_digest/anonymized_llm_context_builder_test.dart b/test/features/ai_digest/anonymized_llm_context_builder_test.dart index d8827d0..8f32332 100644 --- a/test/features/ai_digest/anonymized_llm_context_builder_test.dart +++ b/test/features/ai_digest/anonymized_llm_context_builder_test.dart @@ -53,6 +53,7 @@ void main() { rawText: 'Raw private chat text', createdAt: DateTime(2026, 5, 1), receivedAt: DateTime(2026, 5, 1), + sharedMessageDateTime: DateTime.utc(2026, 2, 1, 10), platform: 'ios', sourceDisplayName: 'Alice Secret', sourceUserId: 'wa:alice', @@ -73,6 +74,27 @@ void main() { updatedAt: DateTime(2026, 5, 1), isSensitive: true, ), + PersonFact( + id: 'pf-2', + personId: 'p-1', + type: CapturedFactType.giftIdea, + text: 'Alice wants ceramic espresso cups', + label: 'Coffee gear', + sourceKind: CaptureSourceKind.shareInbox, + sharedMessageId: 'sm-1', + createdAt: DateTime(2026, 5, 2), + updatedAt: DateTime(2026, 5, 2), + ), + PersonFact( + id: 'pf-3', + personId: 'p-1', + type: CapturedFactType.placeIdea, + text: 'Unreviewed ramen place', + sourceKind: CaptureSourceKind.shareSheet, + createdAt: DateTime(2026, 5, 3), + updatedAt: DateTime(2026, 5, 3), + needsReview: true, + ), ], aiSuggestionDrafts: [ AiSuggestionDraft( @@ -104,6 +126,14 @@ void main() { expect(prompt, isNot(contains('thread-secret'))); expect(prompt, isNot(contains('Raw private chat text'))); expect(prompt, isNot(contains('Secret medical detail'))); + expect(prompt, contains('capturedFacts')); + expect(prompt, contains('shared_message_datetime')); + expect(prompt, contains('2026-02-01T10:00:00.000Z')); + expect(prompt, contains('about 3 months ago')); + expect(prompt, contains('ceramic espresso cups')); + expect(prompt, contains('coffee gear')); + expect(prompt, contains('this person wants ceramic espresso cups')); + expect(prompt, isNot(contains('unreviewed ramen place'))); expect(prompt, contains('priorSuggestions')); expect(prompt, contains('jazz concert downtown')); expect(prompt, contains('dismissed')); diff --git a/test/features/ai_digest/calendar_event_launcher_test.dart b/test/features/ai_digest/calendar_event_launcher_test.dart new file mode 100644 index 0000000..fd6575b --- /dev/null +++ b/test/features/ai_digest/calendar_event_launcher_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.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'; + +void main() { + test('builds Google Calendar template URL for event suggestions', () { + final Uri? uri = calendarUriForSuggestion( + AiSuggestionDraft( + id: 'ai-1', + personId: 'p-1', + kind: AiSuggestionKind.eventIdea, + title: 'Coffee festival', + details: 'Visit the tasting session.', + suggestedAt: DateTime(2026, 5, 16), + suggestedFor: DateTime.utc(2026, 5, 20, 18), + eventEndsAt: DateTime.utc(2026, 5, 20, 20), + eventLocation: 'Local roaster hall', + eventUrl: 'https://example.com/events/coffee', + sourceUrls: const ['https://example.com/events/coffee'], + confidence: 0.8, + status: AiSuggestionStatus.pending, + sourceRunId: 'run-1', + ), + ); + + expect(uri, isNotNull); + expect(uri!.host, 'calendar.google.com'); + expect(uri.queryParameters['action'], 'TEMPLATE'); + expect(uri.queryParameters['text'], 'Coffee festival'); + expect(uri.queryParameters['dates'], '20260520T180000Z/20260520T200000Z'); + expect(uri.queryParameters['location'], 'Local roaster hall'); + expect(uri.queryParameters['details'], contains('Visit the tasting')); + expect(uri.queryParameters['details'], contains('https://example.com')); + }); + + test('does not build a calendar URL without event timing', () { + final Uri? uri = calendarUriForSuggestion( + AiSuggestionDraft( + id: 'ai-2', + personId: 'p-1', + kind: AiSuggestionKind.eventIdea, + title: 'Untimed event', + details: '', + suggestedAt: DateTime(2026, 5, 16), + confidence: 0.6, + status: AiSuggestionStatus.pending, + sourceRunId: 'run-1', + ), + ); + + expect(uri, isNull); + }); +} diff --git a/test/features/ai_digest/llm_digest_orchestrator_test.dart b/test/features/ai_digest/llm_digest_orchestrator_test.dart index 483cda3..9d9639f 100644 --- a/test/features/ai_digest/llm_digest_orchestrator_test.dart +++ b/test/features/ai_digest/llm_digest_orchestrator_test.dart @@ -71,6 +71,7 @@ void main() { expect(result.completed, isTrue); expect(result.createdDraftCount, 1); expect(textClient.calls, 1); + expect(textClient.lastEnableWebSearch, isTrue); expect(textClient.lastPrompt, isNot(contains(state.people.first.name))); expect(state.aiSuggestionDrafts, hasLength(1)); expect( @@ -120,6 +121,139 @@ void main() { expect(textClient.calls, 0); }); + test('manual digest bypasses scheduled network policy', () async { + final FakeTextClient textClient = FakeTextClient( + response: ''' + { + "suggestions": [ + { + "personToken": "person_001", + "kind": "checkIn", + "title": "Send a quick note", + "details": "Ask how the week is going.", + "confidence": 0.7 + } + ] + } + ''', + ); + final ProviderContainer container = _createContainer( + textClient: textClient, + notifier: FakeNotifier(), + environment: const FakeEnvironment(canRunValue: false), + ); + addTearDown(container.dispose); + + await container.read(localRepositoryProvider.future); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Taylor Quinn', + relationship: 'Friend', + notes: '', + tags: const ['tea'], + ); + + final LlmDigestRunResult result = await container + .read(llmDigestOrchestratorProvider) + .runManualDigest(); + + expect(result.started, isTrue); + expect(result.completed, isTrue); + expect(textClient.calls, 1); + }); + + test('digest respects disabled web grounding setting', () async { + final FakeTextClient textClient = FakeTextClient( + response: ''' + { + "suggestions": [ + { + "personToken": "person_001", + "kind": "checkIn", + "title": "Send a quick note", + "details": "Ask how the week is going.", + "confidence": 0.7 + } + ] + } + ''', + ); + final ProviderContainer container = _createContainer( + textClient: textClient, + notifier: FakeNotifier(), + ); + addTearDown(container.dispose); + + await container.read(localRepositoryProvider.future); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Taylor Quinn', + relationship: 'Friend', + notes: '', + tags: const ['tea'], + ); + await container + .read(llmDigestConfigProvider.notifier) + .setWebSearchEnabled(false); + + final LlmDigestRunResult result = await container + .read(llmDigestOrchestratorProvider) + .runManualDigest(); + + expect(result.completed, isTrue); + expect(textClient.lastEnableWebSearch, isFalse); + }); + + test( + 'digest retries without web grounding after grounded quota error', + () async { + final SequenceTextClient textClient = SequenceTextClient([ + const LlmProviderException( + provider: LlmProvider.google, + statusCode: 429, + message: 'Google AI rejected the digest request with 429 quota.', + ), + ''' + { + "suggestions": [ + { + "personToken": "person_001", + "kind": "checkIn", + "title": "Send a quick note", + "details": "Ask how the week is going.", + "confidence": 0.7 + } + ] + } + ''', + ]); + final ProviderContainer container = _createContainer( + textClient: textClient, + notifier: FakeNotifier(), + ); + addTearDown(container.dispose); + + await container.read(localRepositoryProvider.future); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Taylor Quinn', + relationship: 'Friend', + notes: '', + tags: const ['tea'], + ); + + final LlmDigestRunResult result = await container + .read(llmDigestOrchestratorProvider) + .runManualDigest(); + + expect(result.completed, isTrue); + expect(textClient.enableWebSearchValues, [true, false]); + }, + ); + test('manual digest reports provider rate limits without Dio noise', () async { final ThrowingTextClient textClient = ThrowingTextClient( error: const LlmProviderException( @@ -183,14 +317,17 @@ class FakeTextClient implements LlmDigestTextClient { final String response; int calls = 0; String? lastPrompt; + bool? lastEnableWebSearch; @override Future complete({ required String systemPrompt, required String userPrompt, + bool enableWebSearch = false, }) async { calls += 1; lastPrompt = userPrompt; + lastEnableWebSearch = enableWebSearch; return response; } } @@ -204,11 +341,35 @@ class ThrowingTextClient implements LlmDigestTextClient { Future complete({ required String systemPrompt, required String userPrompt, + bool enableWebSearch = false, }) async { throw error; } } +class SequenceTextClient implements LlmDigestTextClient { + SequenceTextClient(this.results); + + final List results; + final List enableWebSearchValues = []; + int _index = 0; + + @override + Future complete({ + required String systemPrompt, + required String userPrompt, + bool enableWebSearch = false, + }) async { + enableWebSearchValues.add(enableWebSearch); + final Object result = results[_index]; + _index += 1; + if (result is Exception) { + throw result; + } + return result as String; + } +} + class FakeNotifier implements AiDigestNotifier { int? lastCount; diff --git a/test/features/share_intake/share_payload_parser_test.dart b/test/features/share_intake/share_payload_parser_test.dart index 042ef30..1e10717 100644 --- a/test/features/share_intake/share_payload_parser_test.dart +++ b/test/features/share_intake/share_payload_parser_test.dart @@ -39,6 +39,33 @@ void main() { expect(payload.rawText, 'loves cozy Italian restaurants'); }); + test('stores source message datetime when WhatsApp text includes one', () { + final SharedPayload payload = SharePayloadParser.parse( + rawText: '[19/05/2026, 14:30] Ava Hart: ginger tea helped', + sourceApp: 'whatsapp', + platform: 'ios', + ); + + expect(payload.sourceDisplayName, 'Ava Hart'); + expect(payload.rawText, 'ginger tea helped'); + expect(payload.sharedMessageDateTime, DateTime(2026, 5, 19, 14, 30)); + expect(payload.parsingHints, contains('shared_message_datetime_detected')); + expect(payload.metadata, contains('shared_message_datetime')); + }); + + test( + 'does not store source message datetime for arbitrary content dates', + () { + final SharedPayload payload = SharePayloadParser.parse( + rawText: 'Ava birthday is on May 20 and she likes ginger tea.', + sourceApp: 'share_sheet', + platform: 'ios', + ); + + expect(payload.sharedMessageDateTime, isNull); + }, + ); + test('uses explicit signal source app hint and extracts sender', () { final SharedPayload payload = SharePayloadParser.parse( rawText: 'Marek: this concert looks promising', diff --git a/test/features/share_intake/shared_message_datetime_extractor_test.dart b/test/features/share_intake/shared_message_datetime_extractor_test.dart new file mode 100644 index 0000000..b2350f5 --- /dev/null +++ b/test/features/share_intake/shared_message_datetime_extractor_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/share_intake/domain/shared_message_datetime_extractor.dart'; + +void main() { + group('SharedMessageDateTimeExtractor', () { + test('extracts WhatsApp bracketed European timestamp', () { + final DateTime? value = SharedMessageDateTimeExtractor.extract( + '[19/05/2026, 14:30] Ava Hart: I was sick but ginger tea helped', + ); + + expect(value, DateTime(2026, 5, 19, 14, 30)); + }); + + test('extracts exported chat prefix with US meridiem timestamp', () { + final DateTime? value = SharedMessageDateTimeExtractor.extract( + '5/19/26, 2:30 PM - Ava Hart: ginger tea helped', + ); + + expect(value, DateTime(2026, 5, 19, 14, 30)); + }); + + test('extracts labelled month-name timestamp', () { + final DateTime? value = SharedMessageDateTimeExtractor.extract( + 'Received at: 19 May 2026 at 14:30\nAva: ginger tea helped', + ); + + expect(value, DateTime(2026, 5, 19, 14, 30)); + }); + + test( + 'extracts relative labelled timestamp using provided reference date', + () { + final DateTime? value = SharedMessageDateTimeExtractor.extract( + 'Yesterday at 21:05 - Ava Hart: ginger tea helped', + now: DateTime(2026, 5, 19, 9), + ); + + expect(value, DateTime(2026, 5, 18, 21, 5)); + }, + ); + + test('does not treat arbitrary content date as message timestamp', () { + final DateTime? value = SharedMessageDateTimeExtractor.extract( + 'Ava birthday is on May 20 and she likes ginger tea.', + ); + + expect(value, isNull); + }); + + test('returns null instead of throwing for invalid dates', () { + final DateTime? value = SharedMessageDateTimeExtractor.extract( + '[99/99/2026, 25:99] Ava: impossible', + ); + + expect(value, isNull); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3806141..2f84833 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { BatteryPlusWindowsPluginRegisterWithRegistrar( @@ -20,4 +21,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); SentryFlutterPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SentryFlutterPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0b5f8d8..56d5305 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus flutter_secure_storage_windows sentry_flutter + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST