diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e21ec0d..aa8f151 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -43,8 +43,6 @@ PODS: - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) - - flutter_app_intents (0.1.0): - - Flutter - flutter_callkit_incoming (0.0.1): - CryptoSwift - Flutter @@ -112,7 +110,6 @@ 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`) @@ -155,8 +152,6 @@ 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: @@ -208,7 +203,6 @@ 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 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c7c1f44..14c9fc5 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,7 +2,6 @@ import AVFoundation import BackgroundTasks import Flutter import AppIntents -import flutter_app_intents import UIKit import UniformTypeIdentifiers @@ -311,6 +310,48 @@ class BackgroundStreamingHandler: NSObject { } } +/// Manages the method channel for App Intent invocations to Flutter. +/// Native Swift intents call this to invoke Flutter-side business logic. +final class AppIntentMethodChannel { + static var shared: AppIntentMethodChannel? + + private let channel: FlutterMethodChannel + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel( + name: "conduit/app_intents", + binaryMessenger: messenger + ) + } + + /// Invokes a Flutter handler for the given intent identifier. + func invokeIntent( + identifier: String, + parameters: [String: Any] + ) async -> [String: Any] { + // No [weak self] needed here - the closure executes immediately on the + // main queue and there's no retain cycle risk. Using weak self would + // risk the continuation never resuming if self became nil. + return await withCheckedContinuation { continuation in + DispatchQueue.main.async { + self.channel.invokeMethod( + identifier, + arguments: parameters + ) { result in + if let dict = result as? [String: Any] { + continuation.resume(returning: dict) + } else { + continuation.resume(returning: [ + "success": false, + "error": "Invalid response from Flutter" + ]) + } + } + } + } + } +} + @available(iOS 16.0, *) enum AppIntentError: Error { case executionFailed(String) @@ -340,11 +381,14 @@ struct AskConduitIntent: AppIntent { func perform() async throws -> some IntentResult & ReturnsValue & OpensIntent { + guard let channel = AppIntentMethodChannel.shared else { + throw AppIntentError.executionFailed("App not ready") + } + let parameters: [String: Any] = prompt?.isEmpty == false ? ["prompt": prompt ?? ""] : [:] - let plugin = FlutterAppIntentsPlugin.shared - let result = await plugin.handleIntentInvocation( + let result = await channel.invokeIntent( identifier: "app.cogwheel.conduit.ask_chat", parameters: parameters ) @@ -372,8 +416,11 @@ struct StartVoiceCallIntent: AppIntent { func perform() async throws -> some IntentResult & ReturnsValue & OpensIntent { - let plugin = FlutterAppIntentsPlugin.shared - let result = await plugin.handleIntentInvocation( + guard let channel = AppIntentMethodChannel.shared else { + throw AppIntentError.executionFailed("App not ready") + } + + let result = await channel.invokeIntent( identifier: "app.cogwheel.conduit.start_voice_call", parameters: [:] ) @@ -407,9 +454,12 @@ struct ConduitSendTextIntent: AppIntent { func perform() async throws -> some IntentResult & ReturnsValue & OpensIntent { - let plugin = FlutterAppIntentsPlugin.shared + guard let channel = AppIntentMethodChannel.shared else { + throw AppIntentError.executionFailed("App not ready") + } + let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) - let result = await plugin.handleIntentInvocation( + let result = await channel.invokeIntent( identifier: "app.cogwheel.conduit.send_text", parameters: ["text": trimmed ?? ""] ) @@ -442,8 +492,11 @@ struct ConduitSendUrlIntent: AppIntent { func perform() async throws -> some IntentResult & ReturnsValue & OpensIntent { - let plugin = FlutterAppIntentsPlugin.shared - let result = await plugin.handleIntentInvocation( + guard let channel = AppIntentMethodChannel.shared else { + throw AppIntentError.executionFailed("App not ready") + } + + let result = await channel.invokeIntent( identifier: "app.cogwheel.conduit.send_url", parameters: ["url": url.absoluteString] ) @@ -476,6 +529,10 @@ struct ConduitSendImageIntent: AppIntent { func perform() async throws -> some IntentResult & ReturnsValue & OpensIntent { + guard let channel = AppIntentMethodChannel.shared else { + throw AppIntentError.executionFailed("App not ready") + } + if let type = image.type, !type.conforms(to: .image) { throw AppIntentError.executionFailed( "Only image files are supported." @@ -486,8 +543,7 @@ struct ConduitSendImageIntent: AppIntent { let base64 = data.base64EncodedString() let name = image.filename ?? "shared_image.jpg" - let plugin = FlutterAppIntentsPlugin.shared - let result = await plugin.handleIntentInvocation( + let result = await channel.invokeIntent( identifier: "app.cogwheel.conduit.send_image", parameters: [ "filename": name, @@ -563,6 +619,13 @@ struct AppShortcuts: AppShortcutsProvider { ) -> Bool { GeneratedPluginRegistrant.register(with: self) + // Setup App Intents method channel for native -> Flutter communication + if let registrar = self.registrar(forPlugin: "AppIntentMethodChannel") { + AppIntentMethodChannel.shared = AppIntentMethodChannel( + messenger: registrar.messenger() + ) + } + // Setup background streaming handler using the plugin registry messenger if let registrar = self.registrar(forPlugin: "BackgroundStreamingHandler") { let channel = FlutterMethodChannel( diff --git a/lib/core/services/app_intents_service.dart b/lib/core/services/app_intents_service.dart index 1026fdb..26c200b 100644 --- a/lib/core/services/app_intents_service.dart +++ b/lib/core/services/app_intents_service.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_app_intents/flutter_app_intents.dart'; +import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -14,7 +14,6 @@ import '../utils/debug_logger.dart'; import 'navigation_service.dart'; import '../../features/chat/providers/chat_providers.dart'; import '../../features/chat/providers/context_attachments_provider.dart'; -import '../../features/chat/providers/knowledge_cache_provider.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'; @@ -27,9 +26,17 @@ 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'; -const _attachKnowledgeIntentId = 'app.cogwheel.conduit.attach_knowledge'; -/// Registers and handles iOS App Intents for Siri/Shortcuts. +/// Method channel for receiving App Intent invocations from native iOS code. +/// Native Swift code defines the intents with proper titles and metadata. +/// This Flutter code handles the business logic (navigation, state management). +const _appIntentsChannel = MethodChannel('conduit/app_intents'); + +/// Handles iOS App Intents for Siri/Shortcuts. +/// +/// Native Swift code in AppDelegate.swift defines the App Intents with proper +/// titles, descriptions, and parameters. This coordinator sets up a method +/// channel to receive invocations and execute Flutter-side business logic. @Riverpod(keepAlive: true) class AppIntentCoordinator extends _$AppIntentCoordinator { @override @@ -37,225 +44,43 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) { return null; } - unawaited(_registerAskIntent()); - unawaited(_registerVoiceCallIntent()); - unawaited(_registerSendTextIntent()); - unawaited(_registerSendUrlIntent()); - unawaited(_registerSendImageIntent()); - unawaited(_registerAttachKnowledgeIntent()); + _setupMethodChannel(); } - 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(); + void _setupMethodChannel() { + _appIntentsChannel.setMethodCallHandler(_handleMethodCall); + } + + Future> _handleMethodCall(MethodCall call) async { + final parameters = (call.arguments as Map?)?.cast() ?? {}; try { - await client.registerIntent(intent, _handleAskIntent); - await FlutterAppIntentsService.donateIntentWithMetadata( - _askIntentId, - const {}, - relevanceScore: 0.7, - context: {'feature': 'chat', 'source': 'app_intent'}, - ); + switch (call.method) { + case _askIntentId: + return await _handleAskIntent(parameters); + case _voiceCallIntentId: + return await _handleVoiceCallIntent(parameters); + case _sendTextIntentId: + return await _handleSendTextIntent(parameters); + case _sendUrlIntentId: + return await _handleSendUrlIntent(parameters); + case _sendImageIntentId: + return await _handleSendImageIntent(parameters); + default: + return {'success': false, 'error': 'Unknown intent: ${call.method}'}; + } } catch (error, stackTrace) { DebugLogger.error( - 'app-intents-register', + 'app-intents-dispatch', scope: 'siri', error: error, stackTrace: stackTrace, ); + return {'success': false, 'error': error.toString()}; } } - 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 _registerAttachKnowledgeIntent() async { - final client = FlutterAppIntentsClient.instance; - final intent = AppIntentBuilder() - .identifier(_attachKnowledgeIntentId) - .title('Attach Knowledge') - .description('Attach a document from your knowledge base to the chat.') - .parameter( - const AppIntentParameter( - name: 'documentName', - title: 'Document Name', - description: 'Name of the knowledge base document to attach.', - type: AppIntentParameterType.string, - isOptional: false, - ), - ) - .build(); - - try { - await client.registerIntent(intent, _handleAttachKnowledgeIntent); - await FlutterAppIntentsService.donateIntentWithMetadata( - _attachKnowledgeIntentId, - const {}, - relevanceScore: 0.7, - context: {'feature': 'knowledge', 'source': 'app_intent'}, - ); - } catch (error, stackTrace) { - DebugLogger.error( - 'app-intents-register-knowledge', - scope: 'siri', - error: error, - stackTrace: stackTrace, - ); - } - } - - Future _handleAskIntent( + Future> _handleAskIntent( Map parameters, ) async { final prompt = (parameters['prompt'] as String?)?.trim(); @@ -266,10 +91,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { ? 'Opening chat for "$prompt"' : 'Opening Conduit chat'; - return AppIntentResult.successful( - value: summary, - needsToContinueInApp: true, - ); + return {'success': true, 'value': summary}; } catch (error, stackTrace) { DebugLogger.error( 'app-intents-handle', @@ -277,19 +99,41 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { error: error, stackTrace: stackTrace, ); - return AppIntentResult.failed(error: 'Unable to open chat: $error'); + return {'success': false, 'error': 'Unable to open chat: $error'}; } } - Future _handleVoiceCallIntent( + Future> _handleVoiceCallIntent( Map parameters, ) async { + DebugLogger.log('Starting voice call from Siri/Shortcuts', scope: 'siri'); + + if (!ref.mounted) { + DebugLogger.log('Ref not mounted for voice call', scope: 'siri'); + return {'success': false, 'error': 'App not ready'}; + } + + // Check authentication state + final navState = ref.read(authNavigationStateProvider); + if (navState != AuthNavigationState.authenticated) { + DebugLogger.log('Not authenticated for voice call', scope: 'siri'); + return { + 'success': false, + 'error': 'Please sign in to start a voice call', + }; + } + + // Check if a model is selected + final model = ref.read(selectedModelProvider); + if (model == null) { + DebugLogger.log('No model selected for voice call', scope: 'siri'); + return {'success': false, 'error': 'Please select a model first'}; + } + try { await _startVoiceCall(); - return AppIntentResult.successful( - value: 'Starting Conduit voice call', - needsToContinueInApp: true, - ); + DebugLogger.log('Voice call launched from Siri/Shortcuts', scope: 'siri'); + return {'success': true, 'value': 'Starting Conduit voice call'}; } catch (error, stackTrace) { DebugLogger.error( 'app-intents-voice', @@ -297,18 +141,16 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { error: error, stackTrace: stackTrace, ); - return AppIntentResult.failed( - error: 'Unable to start voice call: $error', - ); + return {'success': false, 'error': 'Unable to start voice call: $error'}; } } - Future _handleSendTextIntent( + Future> _handleSendTextIntent( Map parameters, ) async { final text = (parameters['text'] as String?)?.trim(); if (text == null || text.isEmpty) { - return AppIntentResult.failed(error: 'No text provided.'); + return {'success': false, 'error': 'No text provided.'}; } try { @@ -317,10 +159,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { focusComposer: true, resetChat: true, ); - return AppIntentResult.successful( - value: 'Sent to Conduit', - needsToContinueInApp: true, - ); + return {'success': true, 'value': 'Sent to Conduit'}; } catch (error, stackTrace) { DebugLogger.error( 'app-intents-text', @@ -328,21 +167,22 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { error: error, stackTrace: stackTrace, ); - return AppIntentResult.failed(error: 'Unable to send text: $error'); + return {'success': false, 'error': 'Unable to send text: $error'}; } } - Future _handleSendUrlIntent( + Future> _handleSendUrlIntent( Map parameters, ) async { final url = (parameters['url'] as String?)?.trim(); if (url == null || url.isEmpty) { - return AppIntentResult.failed(error: 'No URL provided.'); + return {'success': false, 'error': 'No URL provided.'}; } try { // Determine if this is a YouTube URL - final isYoutube = url.startsWith('https://www.youtube.com') || + final isYoutube = + url.startsWith('https://www.youtube.com') || url.startsWith('https://youtu.be') || url.startsWith('https://youtube.com') || url.startsWith('https://m.youtube.com'); @@ -357,10 +197,8 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { ? await api.processYoutube(url: url) : await api.processWebpage(url: url); - final file = - (result?['file'] as Map?)?.cast(); - final fileData = - (file?['data'] as Map?)?.cast(); + final file = (result?['file'] as Map?)?.cast(); + final fileData = (file?['data'] as Map?)?.cast(); content = fileData?['content']?.toString() ?? ''; final meta = (file?['meta'] as Map?)?.cast(); name = meta?['name']?.toString() ?? Uri.parse(url).host; @@ -400,17 +238,17 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { } if (contentAttached) { - return AppIntentResult.successful( - value: isYoutube + return { + 'success': true, + 'value': isYoutube ? 'YouTube video attached in Conduit' : 'Webpage attached in Conduit', - needsToContinueInApp: true, - ); + }; } else { - return AppIntentResult.successful( - value: 'Opening Conduit with URL (content could not be fetched)', - needsToContinueInApp: true, - ); + return { + 'success': true, + 'value': 'Opening Conduit with URL (content could not be fetched)', + }; } } catch (error, stackTrace) { DebugLogger.error( @@ -419,16 +257,16 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { error: error, stackTrace: stackTrace, ); - return AppIntentResult.failed(error: 'Unable to send URL: $error'); + return {'success': false, 'error': 'Unable to send URL: $error'}; } } - Future _handleSendImageIntent( + Future> _handleSendImageIntent( Map parameters, ) async { final base64 = parameters['bytes'] as String?; if (base64 == null || base64.isEmpty) { - return AppIntentResult.failed(error: 'No image data provided.'); + return {'success': false, 'error': 'No image data provided.'}; } final filenameRaw = (parameters['filename'] as String?)?.trim(); @@ -439,10 +277,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { ); await _attachFiles([file]); await _prepareChatWithOptions(focusComposer: true, resetChat: true); - return AppIntentResult.successful( - value: 'Image attached in Conduit', - needsToContinueInApp: true, - ); + return {'success': true, 'value': 'Image attached in Conduit'}; } catch (error, stackTrace) { DebugLogger.error( 'app-intents-image', @@ -450,70 +285,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { error: error, stackTrace: stackTrace, ); - return AppIntentResult.failed(error: 'Unable to send image: $error'); - } - } - - Future _handleAttachKnowledgeIntent( - Map parameters, - ) async { - final documentName = (parameters['documentName'] as String?)?.trim(); - if (documentName == null || documentName.isEmpty) { - return AppIntentResult.failed(error: 'No document name provided.'); - } - - try { - // Ensure knowledge bases are loaded - final cacheNotifier = ref.read(knowledgeCacheProvider.notifier); - await cacheNotifier.ensureBases(); - - final cacheState = ref.read(knowledgeCacheProvider); - final bases = cacheState.bases; - - // Search through all knowledge bases for matching items - for (final base in bases) { - await cacheNotifier.fetchItemsForBase(base.id); - final updatedState = ref.read(knowledgeCacheProvider); - final items = updatedState.items[base.id] ?? const []; - - for (final item in items) { - final itemTitle = item.title ?? item.metadata['name']?.toString() ?? ''; - if (itemTitle.toLowerCase().contains(documentName.toLowerCase())) { - // Reset chat first, then add attachment (startNewChat clears attachments) - await _prepareChatWithOptions( - focusComposer: true, - resetChat: true, - ); - - // Add attachment after reset so it isn't cleared - ref.read(contextAttachmentsProvider.notifier).addKnowledge( - displayName: itemTitle, - fileId: item.id, - collectionName: base.name, - url: item.metadata['source']?.toString(), - ); - - return AppIntentResult.successful( - value: 'Attached "$itemTitle" from ${base.name}', - needsToContinueInApp: true, - ); - } - } - } - - return AppIntentResult.failed( - error: 'No document found matching "$documentName".', - ); - } catch (error, stackTrace) { - DebugLogger.error( - 'app-intents-knowledge', - scope: 'siri', - error: error, - stackTrace: stackTrace, - ); - return AppIntentResult.failed( - error: 'Unable to attach knowledge: $error', - ); + return {'success': false, 'error': 'Unable to send image: $error'}; } } @@ -566,11 +338,13 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { Future _startVoiceCall() async { if (!ref.mounted) return; + // Validate authentication state final navState = ref.read(authNavigationStateProvider); if (navState != AuthNavigationState.authenticated) { throw StateError('Sign in to start a voice call.'); } + // Validate model selection final model = ref.read(selectedModelProvider); if (model == null) { throw StateError('Choose a model before starting a voice call.'); @@ -578,7 +352,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { // Pre-warm socket connection before navigating to voice call. // This reduces the chance of "websocket not connected" errors when - // opening voice call right after app start or from deep links. + // opening voice call right after app start or from Siri/Shortcuts. final socketService = ref.read(socketServiceProvider); if (socketService != null && !socketService.isConnected) { // Start connection attempt in parallel, don't wait for full connection @@ -586,21 +360,25 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { unawaited(socketService.connect()); } - await NavigationService.navigateToChat(); + // Navigate to chat first if not already there + final isOnChatRoute = NavigationService.currentRoute == Routes.chat; + if (!isOnChatRoute) { + 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.'); + if (context == null) { + throw StateError('Navigation context not available.'); } // Dismiss keyboard before navigating - FocusManager.instance.primaryFocus?.unfocus(); + FocusScope.of(context).unfocus(); - await navigator.push( + // Navigate to voice call page with new conversation flag + await Navigator.of(context).push( MaterialPageRoute( builder: (_) => const VoiceCallPage(startNewConversation: true), fullscreenDialog: true, diff --git a/pubspec.lock b/pubspec.lock index d2c2ea6..091c906 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -393,14 +393,6 @@ 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: @@ -486,14 +478,6 @@ 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 9dedf53..8d09f42 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,7 +74,6 @@ dependencies: flutter_cache_manager: ^3.4.1 http: ^1.5.0 flutter_callkit_incoming: ^3.0.0 - flutter_app_intents: ^0.7.0 quick_actions: 1.1.0 flutter_svg: ^2.2.3 html_unescape: ^2.0.0