From 2d88519abef31e4869cea98aa4f0388e782f413a Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Tue, 25 Nov 2025 00:08:51 +0530 Subject: [PATCH] feat(ios): add ios shortcuts support --- ios/Flutter/AppFrameworkInfo.plist | 2 +- ios/Podfile | 2 +- ios/Podfile.lock | 8 +- ios/Runner.xcodeproj/project.pbxproj | 12 +- ios/Runner/AppDelegate.swift | 245 +++++++++ lib/core/auth/auth_state_manager.dart | 40 +- lib/core/providers/app_startup_providers.dart | 2 + lib/core/providers/storage_providers.dart | 1 - lib/core/services/app_intents_service.dart | 473 ++++++++++++++++++ .../services/secure_credential_storage.dart | 5 +- lib/core/utils/model_icon_utils.dart | 21 +- lib/shared/widgets/model_avatar.dart | 10 +- pubspec.lock | 16 + pubspec.yaml | 1 + 14 files changed, 792 insertions(+), 46 deletions(-) create mode 100644 lib/core/services/app_intents_service.dart diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index b2a56aa..42f8f3e 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 15.1 + 16.0 diff --git a/ios/Podfile b/ios/Podfile index 24026fa..2759f9d 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '15.1' +platform :ios, '16.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b95e124..51a43e7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -40,6 +40,8 @@ PODS: - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) + - flutter_app_intents (0.1.0): + - Flutter - flutter_callkit_incoming (0.0.1): - CryptoSwift - Flutter @@ -102,6 +104,7 @@ DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) + - flutter_app_intents (from `.symlinks/plugins/flutter_app_intents/ios`) - flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) @@ -141,6 +144,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter + flutter_app_intents: + :path: ".symlinks/plugins/flutter_app_intents/ios" flutter_callkit_incoming: :path: ".symlinks/plugins/flutter_callkit_incoming/ios" flutter_local_notifications: @@ -188,6 +193,7 @@ SPEC CHECKSUMS: DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_app_intents: e77f999f398c841ab584a1925dbce33ee0168fb5 flutter_callkit_incoming: cb8138af67cda6dd981f7101a5d709003af21502 flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf @@ -212,6 +218,6 @@ SPEC CHECKSUMS: wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d -PODFILE CHECKSUM: a6ecbec6401c6461e69650e9ef66360aee70610f +PODFILE CHECKSUM: 467be2eaee7e6ac5b7b2d2ef8bea817d4d70b736 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7f9d99d..f8d7e33 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -585,7 +585,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -722,7 +722,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -773,7 +773,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -865,7 +865,7 @@ INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -908,7 +908,7 @@ INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -948,7 +948,7 @@ INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 828f3e1..c7c1f44 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,7 +1,10 @@ import AVFoundation import BackgroundTasks import Flutter +import AppIntents +import flutter_app_intents import UIKit +import UniformTypeIdentifiers final class VoiceBackgroundAudioManager { static let shared = VoiceBackgroundAudioManager() @@ -305,6 +308,248 @@ class BackgroundStreamingHandler: NSObject { NotificationCenter.default.removeObserver(self) endBackgroundTask() VoiceBackgroundAudioManager.shared.deactivate() + } +} + +@available(iOS 16.0, *) +enum AppIntentError: Error { + case executionFailed(String) +} + +@available(iOS 16.0, *) +struct AskConduitIntent: AppIntent { + static var title: LocalizedStringResource = "Ask Conduit" + static var description = IntentDescription( + "Start a Conduit chat with an optional prompt." + ) + static var isDiscoverable = true + static var openAppWhenRun = true + + @Parameter( + title: "Prompt", + requestValueDialog: IntentDialog("What should Conduit answer?") + ) + var prompt: String? + + init() {} + + init(prompt: String?) { + self.prompt = prompt + } + + func perform() async throws + -> some IntentResult & ReturnsValue & OpensIntent + { + let parameters: [String: Any] = prompt?.isEmpty == false + ? ["prompt": prompt ?? ""] + : [:] + let plugin = FlutterAppIntentsPlugin.shared + let result = await plugin.handleIntentInvocation( + identifier: "app.cogwheel.conduit.ask_chat", + parameters: parameters + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "Opening chat" + return .result(value: value) + } + + let message = result["error"] as? String + ?? "Unable to open Conduit chat" + throw AppIntentError.executionFailed(message) + } +} + +@available(iOS 16.0, *) +struct StartVoiceCallIntent: AppIntent { + static var title: LocalizedStringResource = "Start Voice Call" + static var description = IntentDescription( + "Start a live voice call with Conduit." + ) + static var isDiscoverable = true + static var openAppWhenRun = true + + func perform() async throws + -> some IntentResult & ReturnsValue & OpensIntent + { + let plugin = FlutterAppIntentsPlugin.shared + let result = await plugin.handleIntentInvocation( + identifier: "app.cogwheel.conduit.start_voice_call", + parameters: [:] + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "Starting voice call" + return .result(value: value) + } + + let message = result["error"] as? String + ?? "Unable to start voice call" + throw AppIntentError.executionFailed(message) + } +} + +@available(iOS 16.0, *) +struct ConduitSendTextIntent: AppIntent { + static var title: LocalizedStringResource = "Send to Conduit" + static var description = IntentDescription( + "Start a Conduit chat with provided text." + ) + static var isDiscoverable = true + static var openAppWhenRun = true + + @Parameter( + title: "Text", + requestValueDialog: IntentDialog("What should Conduit process?") + ) + var text: String? + + func perform() async throws + -> some IntentResult & ReturnsValue & OpensIntent + { + let plugin = FlutterAppIntentsPlugin.shared + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) + let result = await plugin.handleIntentInvocation( + identifier: "app.cogwheel.conduit.send_text", + parameters: ["text": trimmed ?? ""] + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "Sent to Conduit" + return .result(value: value) + } + + let message = result["error"] as? String ?? "Unable to send text" + throw AppIntentError.executionFailed(message) + } +} + +@available(iOS 16.0, *) +struct ConduitSendUrlIntent: AppIntent { + static var title: LocalizedStringResource = "Send Link to Conduit" + static var description = IntentDescription( + "Send a URL into Conduit for summary or analysis." + ) + static var isDiscoverable = true + static var openAppWhenRun = true + + @Parameter( + title: "URL", + requestValueDialog: IntentDialog("Which link should Conduit analyze?") + ) + var url: URL + + func perform() async throws + -> some IntentResult & ReturnsValue & OpensIntent + { + let plugin = FlutterAppIntentsPlugin.shared + let result = await plugin.handleIntentInvocation( + identifier: "app.cogwheel.conduit.send_url", + parameters: ["url": url.absoluteString] + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "Sent link to Conduit" + return .result(value: value) + } + + let message = result["error"] as? String ?? "Unable to send link" + throw AppIntentError.executionFailed(message) + } +} + +@available(iOS 16.0, *) +struct ConduitSendImageIntent: AppIntent { + static var title: LocalizedStringResource = "Send Image to Conduit" + static var description = IntentDescription( + "Send an image into Conduit for analysis." + ) + static var isDiscoverable = true + static var openAppWhenRun = true + + @Parameter( + title: "Image", + requestValueDialog: IntentDialog("Choose an image for Conduit.") + ) + var image: IntentFile + + func perform() async throws + -> some IntentResult & ReturnsValue & OpensIntent + { + if let type = image.type, !type.conforms(to: .image) { + throw AppIntentError.executionFailed( + "Only image files are supported." + ) + } + + let data = try image.data + let base64 = data.base64EncodedString() + let name = image.filename ?? "shared_image.jpg" + + let plugin = FlutterAppIntentsPlugin.shared + let result = await plugin.handleIntentInvocation( + identifier: "app.cogwheel.conduit.send_image", + parameters: [ + "filename": name, + "bytes": base64, + ] + ) + + if let success = result["success"] as? Bool, success { + let value = result["value"] as? String ?? "Sent image to Conduit" + return .result(value: value) + } + + let message = result["error"] as? String ?? "Unable to send image" + throw AppIntentError.executionFailed(message) + } +} + +@available(iOS 16.0, *) +struct AppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + return [ + AppShortcut( + intent: AskConduitIntent(), + phrases: [ + "Ask with \(.applicationName)", + "Start chat in \(.applicationName)", + "Open composer in \(.applicationName)", + ] + ), + AppShortcut( + intent: StartVoiceCallIntent(), + phrases: [ + "Start voice call in \(.applicationName)", + "Call with \(.applicationName)", + "Begin voice chat in \(.applicationName)", + ] + ), + AppShortcut( + intent: ConduitSendTextIntent(), + phrases: [ + "Send text to \(.applicationName)", + "Share text with \(.applicationName)", + "Summarize this in \(.applicationName)", + ] + ), + AppShortcut( + intent: ConduitSendUrlIntent(), + phrases: [ + "Summarize link in \(.applicationName)", + "Analyze link with \(.applicationName)", + "Send URL to \(.applicationName)", + ] + ), + AppShortcut( + intent: ConduitSendImageIntent(), + phrases: [ + "Send image to \(.applicationName)", + "Analyze image with \(.applicationName)", + "Share photo to \(.applicationName)", + ] + ), + ] } } diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index 44ccb87..a5f58fc 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -104,22 +104,26 @@ class AuthStateManager extends _$AuthStateManager { // Persist user and avatar asynchronously without blocking state update unawaited(_persistUserWithAvatar(next, storage)); } else if (!next.isAuthenticated) { - unawaited(storage.saveLocalUser(null).onError((error, stack) { - DebugLogger.error( - 'Failed to clear local user on logout', - scope: 'auth/persistence', - error: error, - stackTrace: stack, - ); - })); - unawaited(storage.saveLocalUserAvatar(null).onError((error, stack) { - DebugLogger.error( - 'Failed to clear local user avatar on logout', - scope: 'auth/persistence', - error: error, - stackTrace: stack, - ); - })); + unawaited( + storage.saveLocalUser(null).onError((error, stack) { + DebugLogger.error( + 'Failed to clear local user on logout', + scope: 'auth/persistence', + error: error, + stackTrace: stack, + ); + }), + ); + unawaited( + storage.saveLocalUserAvatar(null).onError((error, stack) { + DebugLogger.error( + 'Failed to clear local user avatar on logout', + scope: 'auth/persistence', + error: error, + stackTrace: stack, + ); + }), + ); } state = AsyncValue.data(next); if (cache) { @@ -140,8 +144,8 @@ class AuthStateManager extends _$AuthStateManager { ); final userWithAvatar = resolvedAvatar != null && resolvedAvatar != user.profileImage - ? user.copyWith(profileImage: resolvedAvatar) - : user; + ? user.copyWith(profileImage: resolvedAvatar) + : user; await storage.saveLocalUser(userWithAvatar); if (resolvedAvatar != null) { await storage.saveLocalUserAvatar(resolvedAvatar); diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart index aecdd7b..1eac896 100644 --- a/lib/core/providers/app_startup_providers.dart +++ b/lib/core/providers/app_startup_providers.dart @@ -9,6 +9,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../providers/app_providers.dart'; import '../../features/auth/providers/unified_auth_providers.dart'; import '../services/navigation_service.dart'; +import '../services/app_intents_service.dart'; import '../models/conversation.dart'; import '../services/background_streaming_handler.dart'; import '../services/persistent_streaming_service.dart'; @@ -169,6 +170,7 @@ class AppStartupFlow extends _$AppStartupFlow { keepAlive(authApiIntegrationProvider); keepAlive(apiTokenUpdaterProvider); keepAlive(silentLoginCoordinatorProvider); + keepAlive(appIntentCoordinatorProvider); // Kick background model loading flow (non-blocking) Future.delayed(const Duration(milliseconds: 120), () { diff --git a/lib/core/providers/storage_providers.dart b/lib/core/providers/storage_providers.dart index 91ff513..c8de3bc 100644 --- a/lib/core/providers/storage_providers.dart +++ b/lib/core/providers/storage_providers.dart @@ -33,4 +33,3 @@ final optimizedStorageServiceProvider = Provider(( workerManager: ref.watch(workerManagerProvider), ); }); - diff --git a/lib/core/services/app_intents_service.dart b/lib/core/services/app_intents_service.dart new file mode 100644 index 0000000..94f3194 --- /dev/null +++ b/lib/core/services/app_intents_service.dart @@ -0,0 +1,473 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_app_intents/flutter_app_intents.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../providers/app_providers.dart'; +import '../utils/debug_logger.dart'; +import 'navigation_service.dart'; +import '../../features/chat/providers/chat_providers.dart'; +import '../../features/auth/providers/unified_auth_providers.dart'; +import '../../features/chat/views/voice_call_page.dart'; +import '../../features/chat/services/file_attachment_service.dart'; +import '../../shared/services/tasks/task_queue.dart'; + +part 'app_intents_service.g.dart'; + +const _askIntentId = 'app.cogwheel.conduit.ask_chat'; +const _voiceCallIntentId = 'app.cogwheel.conduit.start_voice_call'; +const _sendTextIntentId = 'app.cogwheel.conduit.send_text'; +const _sendUrlIntentId = 'app.cogwheel.conduit.send_url'; +const _sendImageIntentId = 'app.cogwheel.conduit.send_image'; + +/// Registers and handles iOS App Intents for Siri/Shortcuts. +@Riverpod(keepAlive: true) +class AppIntentCoordinator extends _$AppIntentCoordinator { + @override + FutureOr build() { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) { + return null; + } + unawaited(_registerAskIntent()); + unawaited(_registerVoiceCallIntent()); + unawaited(_registerSendTextIntent()); + unawaited(_registerSendUrlIntent()); + unawaited(_registerSendImageIntent()); + } + + Future _registerAskIntent() async { + final client = FlutterAppIntentsClient.instance; + final intent = AppIntentBuilder() + .identifier(_askIntentId) + .title('Ask Conduit') + .description('Start a chat with an optional prompt.') + .parameter( + const AppIntentParameter( + name: 'prompt', + title: 'Prompt', + description: 'What should Conduit answer?', + type: AppIntentParameterType.string, + isOptional: true, + ), + ) + .build(); + + try { + await client.registerIntent(intent, _handleAskIntent); + await FlutterAppIntentsService.donateIntentWithMetadata( + _askIntentId, + const {}, + relevanceScore: 0.7, + context: {'feature': 'chat', 'source': 'app_intent'}, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-register', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _registerVoiceCallIntent() async { + final client = FlutterAppIntentsClient.instance; + final intent = AppIntentBuilder() + .identifier(_voiceCallIntentId) + .title('Start Voice Call') + .description('Start a live voice call with Conduit.') + .build(); + + try { + await client.registerIntent(intent, _handleVoiceCallIntent); + await FlutterAppIntentsService.donateIntentWithMetadata( + _voiceCallIntentId, + const {}, + relevanceScore: 0.8, + context: {'feature': 'voice_call', 'source': 'app_intent'}, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-register-voice', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _registerSendTextIntent() async { + final client = FlutterAppIntentsClient.instance; + final intent = AppIntentBuilder() + .identifier(_sendTextIntentId) + .title('Send to Conduit') + .description('Start a new chat with provided text.') + .parameter( + const AppIntentParameter( + name: 'text', + title: 'Text', + description: 'Text to send into Conduit.', + type: AppIntentParameterType.string, + isOptional: true, + ), + ) + .build(); + + try { + await client.registerIntent(intent, _handleSendTextIntent); + await FlutterAppIntentsService.donateIntentWithMetadata( + _sendTextIntentId, + const {}, + relevanceScore: 0.75, + context: {'feature': 'share_text', 'source': 'app_intent'}, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-register-text', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _registerSendUrlIntent() async { + final client = FlutterAppIntentsClient.instance; + final intent = AppIntentBuilder() + .identifier(_sendUrlIntentId) + .title('Send Link to Conduit') + .description('Start a chat with a link to summarize or analyze.') + .parameter( + const AppIntentParameter( + name: 'url', + title: 'URL', + description: 'Link to summarize or process.', + type: AppIntentParameterType.url, + isOptional: false, + ), + ) + .build(); + + try { + await client.registerIntent(intent, _handleSendUrlIntent); + await FlutterAppIntentsService.donateIntentWithMetadata( + _sendUrlIntentId, + const {}, + relevanceScore: 0.75, + context: {'feature': 'share_url', 'source': 'app_intent'}, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-register-url', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _registerSendImageIntent() async { + final client = FlutterAppIntentsClient.instance; + final intent = AppIntentBuilder() + .identifier(_sendImageIntentId) + .title('Send Image to Conduit') + .description('Start a chat with an attached image.') + .parameter( + const AppIntentParameter( + name: 'filename', + title: 'Filename', + description: 'Preferred filename for the image.', + type: AppIntentParameterType.string, + isOptional: true, + ), + ) + .parameter( + const AppIntentParameter( + name: 'bytes', + title: 'Image Bytes', + description: 'Base64 encoded image bytes.', + type: AppIntentParameterType.string, + isOptional: false, + ), + ) + .build(); + + try { + await client.registerIntent(intent, _handleSendImageIntent); + await FlutterAppIntentsService.donateIntentWithMetadata( + _sendImageIntentId, + const {}, + relevanceScore: 0.85, + context: {'feature': 'share_image', 'source': 'app_intent'}, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-register-image', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _handleAskIntent( + Map parameters, + ) async { + final prompt = (parameters['prompt'] as String?)?.trim(); + + try { + await _prepareChat(prompt: prompt); + final summary = prompt != null && prompt.isNotEmpty + ? 'Opening chat for "$prompt"' + : 'Opening Conduit chat'; + + return AppIntentResult.successful( + value: summary, + needsToContinueInApp: true, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-handle', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + return AppIntentResult.failed(error: 'Unable to open chat: $error'); + } + } + + Future _handleVoiceCallIntent( + Map parameters, + ) async { + try { + await _startVoiceCall(); + return AppIntentResult.successful( + value: 'Starting Conduit voice call', + needsToContinueInApp: true, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-voice', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + return AppIntentResult.failed( + error: 'Unable to start voice call: $error', + ); + } + } + + Future _handleSendTextIntent( + Map parameters, + ) async { + final text = (parameters['text'] as String?)?.trim(); + if (text == null || text.isEmpty) { + return AppIntentResult.failed(error: 'No text provided.'); + } + + try { + await _prepareChatWithOptions( + prompt: text, + focusComposer: true, + resetChat: true, + ); + return AppIntentResult.successful( + value: 'Sent to Conduit', + needsToContinueInApp: true, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-text', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + return AppIntentResult.failed(error: 'Unable to send text: $error'); + } + } + + Future _handleSendUrlIntent( + Map parameters, + ) async { + final url = (parameters['url'] as String?)?.trim(); + if (url == null || url.isEmpty) { + return AppIntentResult.failed(error: 'No URL provided.'); + } + + final prompt = 'Please summarize or analyze:\n$url'; + try { + await _prepareChatWithOptions( + prompt: prompt, + focusComposer: true, + resetChat: true, + ); + return AppIntentResult.successful( + value: 'Opening Conduit for this link', + needsToContinueInApp: true, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-url', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + return AppIntentResult.failed(error: 'Unable to send URL: $error'); + } + } + + Future _handleSendImageIntent( + Map parameters, + ) async { + final base64 = parameters['bytes'] as String?; + if (base64 == null || base64.isEmpty) { + return AppIntentResult.failed(error: 'No image data provided.'); + } + final filenameRaw = (parameters['filename'] as String?)?.trim(); + + try { + final file = await _materializeTempFile( + base64, + preferredName: filenameRaw, + ); + await _attachFiles([file]); + await _prepareChatWithOptions(focusComposer: true, resetChat: true); + return AppIntentResult.successful( + value: 'Image attached in Conduit', + needsToContinueInApp: true, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-image', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + return AppIntentResult.failed(error: 'Unable to send image: $error'); + } + } + + Future _prepareChat({String? prompt}) async { + await _prepareChatWithOptions( + prompt: prompt, + focusComposer: false, + resetChat: false, + ); + } + + Future _prepareChatWithOptions({ + String? prompt, + bool focusComposer = false, + bool resetChat = false, + }) async { + if (!ref.mounted) return; + + NavigationService.navigateToChat(); + + final navState = ref.read(authNavigationStateProvider); + if (prompt != null && prompt.isNotEmpty) { + ref.read(prefilledInputTextProvider.notifier).set(prompt); + } + + if (navState == AuthNavigationState.authenticated && resetChat) { + startNewChat(ref); + } + + if (focusComposer) { + final tick = ref.read(inputFocusTriggerProvider); + ref.read(inputFocusTriggerProvider.notifier).set(tick + 1); + } + } + + Future _startVoiceCall() async { + if (!ref.mounted) return; + + final navState = ref.read(authNavigationStateProvider); + if (navState != AuthNavigationState.authenticated) { + throw StateError('Sign in to start a voice call.'); + } + + final model = ref.read(selectedModelProvider); + if (model == null) { + throw StateError('Choose a model before starting a voice call.'); + } + + await NavigationService.navigateToChat(); + + // Wait a tick for navigation to settle so navigator/context are present. + await Future.delayed(const Duration(milliseconds: 50)); + + final navigator = NavigationService.navigator; + final context = NavigationService.navigatorKey.currentContext; + if (navigator == null || context == null) { + throw StateError('Navigation is not available.'); + } + + await navigator.push( + MaterialPageRoute( + builder: (_) => const VoiceCallPage(), + fullscreenDialog: true, + ), + ); + } + + Future _materializeTempFile( + String base64Data, { + String? preferredName, + }) async { + final bytes = base64Decode(base64Data); + const maxBytes = 20 * 1024 * 1024; // 20 MB guardrail + if (bytes.length > maxBytes) { + throw StateError('Image too large (max 20 MB).'); + } + + final tempDir = await getTemporaryDirectory(); + final safeName = (preferredName != null && preferredName.isNotEmpty) + ? preferredName + : 'conduit_image_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final sanitizedName = safeName.replaceAll(RegExp(r'[^\w\.\-]'), '_'); + final file = File(p.join(tempDir.path, sanitizedName)); + await file.writeAsBytes(bytes, flush: true); + return file; + } + + Future _attachFiles(List files) async { + if (files.isEmpty) return; + // Warm the attachment service to ensure dependencies are ready. + final _ = ref.read(fileAttachmentServiceProvider); + final notifier = ref.read(attachedFilesProvider.notifier); + final taskQueue = ref.read(taskQueueProvider.notifier); + final activeConv = ref.read(activeConversationProvider); + + final attachments = files + .map((f) => LocalAttachment(file: f, displayName: p.basename(f.path))) + .toList(); + + notifier.addFiles(attachments); + + for (final attachment in attachments) { + try { + await taskQueue.enqueueUploadMedia( + conversationId: activeConv?.id, + filePath: attachment.file.path, + fileName: attachment.displayName, + fileSize: await attachment.file.length(), + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-upload', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + } + } + } +} diff --git a/lib/core/services/secure_credential_storage.dart b/lib/core/services/secure_credential_storage.dart index 1bdc7b7..d0028e0 100644 --- a/lib/core/services/secure_credential_storage.dart +++ b/lib/core/services/secure_credential_storage.dart @@ -285,7 +285,10 @@ class SecureCredentialStorage { Future clearAll() async { try { await _secureStorage.deleteAll(); - DebugLogger.storage('clear-ok (all secure data including server configs with custom headers)', scope: 'credentials'); + DebugLogger.storage( + 'clear-ok (all secure data including server configs with custom headers)', + scope: 'credentials', + ); } catch (e) { DebugLogger.error('clear-failed', scope: 'credentials', error: e); } diff --git a/lib/core/utils/model_icon_utils.dart b/lib/core/utils/model_icon_utils.dart index a0deb7d..b37eaa8 100644 --- a/lib/core/utils/model_icon_utils.dart +++ b/lib/core/utils/model_icon_utils.dart @@ -2,7 +2,7 @@ import '../models/model.dart'; import '../services/api_service.dart'; /// Extracts the profile image URL from a model's metadata. -/// +/// /// Note: After OpenWebUI updates, the profile_image_url field is stripped from /// the /api/models response. This function still checks for legacy data but /// clients should use [buildModelAvatarUrl] to construct the proper endpoint URL. @@ -53,10 +53,10 @@ String? deriveModelIcon(Model? model) { } /// Builds the model avatar URL using the new OpenWebUI endpoint. -/// +/// /// OpenWebUI now serves model avatars through a dedicated endpoint: /// `/api/v1/models/model/profile/image?id={modelId}` -/// +/// /// This endpoint: /// - Requires authentication /// - Handles external URLs (returns 302 redirect) @@ -76,12 +76,9 @@ String? buildModelAvatarUrl(ApiService? api, String? modelId) { final baseUri = Uri.parse(baseUrl); final path = '/api/v1/models/model/profile/image'; final queryParams = {'id': modelId}; - - final avatarUri = baseUri.replace( - path: path, - queryParameters: queryParams, - ); - + + final avatarUri = baseUri.replace(path: path, queryParameters: queryParams); + return avatarUri.toString(); } catch (_) { // Fallback to manual URL construction @@ -140,11 +137,11 @@ String? resolveModelIconUrl(ApiService? api, String? rawUrl) { } /// Resolves the final model icon URL for a given model. -/// +/// /// This function first checks for a legacy profile_image_url in the model's /// metadata (for backwards compatibility with older OpenWebUI versions). /// If found and it's an external URL or data URI, it uses that directly. -/// +/// /// Otherwise, it constructs the URL using the new OpenWebUI endpoint: /// `/api/v1/models/model/profile/image?id={modelId}` String? resolveModelIconUrlForModel(ApiService? api, Model? model) { @@ -152,7 +149,7 @@ String? resolveModelIconUrlForModel(ApiService? api, Model? model) { // Check for legacy profile_image_url in metadata final legacyUrl = deriveModelIcon(model); - + // If we have a legacy URL that's external or a data URI, use it directly if (legacyUrl != null && legacyUrl.isNotEmpty) { final trimmed = legacyUrl.trim(); diff --git a/lib/shared/widgets/model_avatar.dart b/lib/shared/widgets/model_avatar.dart index 7c16d0c..0b21dfa 100644 --- a/lib/shared/widgets/model_avatar.dart +++ b/lib/shared/widgets/model_avatar.dart @@ -4,16 +4,16 @@ import '../theme/theme_extensions.dart'; import 'user_avatar.dart'; /// Displays a model's avatar image with automatic caching and fallback UI. -/// +/// /// The avatar can display: /// - Network images from the OpenWebUI model avatar endpoint /// - Data URIs (base64-encoded images) /// - A fallback UI showing the first letter of the model name or a brain icon -/// +/// /// Images are automatically cached using [CachedNetworkImage] with proper /// authentication headers. The cache respects self-signed certificates if /// configured. -/// +/// /// Usage: /// ```dart /// final avatarUrl = resolveModelIconUrlForModel(apiService, model); @@ -22,11 +22,11 @@ import 'user_avatar.dart'; class ModelAvatar extends StatelessWidget { /// The size (width and height) of the avatar in logical pixels. final double size; - + /// The URL of the avatar image. Should be obtained via /// [resolveModelIconUrlForModel] to use the correct OpenWebUI endpoint. final String? imageUrl; - + /// The model name, used for the fallback UI (shows first letter). final String? label; diff --git a/pubspec.lock b/pubspec.lock index 2ec1d34..66c17dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -393,6 +393,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -478,6 +486,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + flutter_app_intents: + dependency: "direct main" + description: + name: flutter_app_intents + sha256: bec5a1ec991b93d475435205dbdca6efdd8979749f2a3c73ebb9f8334b9d3fa2 + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_cache_manager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3e91f7b..68d74d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,6 +74,7 @@ dependencies: flutter_cache_manager: ^3.4.1 http: ^1.5.0 flutter_callkit_incoming: ^3.0.0 + flutter_app_intents: ^0.7.0 # Clipboard functionality is available through flutter/services (part of Flutter SDK)