From 6d56f5d160cf22ed41d4d079ec03cbaabf2d226f Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Tue, 25 Nov 2025 00:53:13 +0530 Subject: [PATCH] feat(ios): Add App Intents support for Conduit interactions --- ios/Podfile.lock | 6 + lib/core/providers/app_startup_providers.dart | 2 + lib/core/services/app_intents_service.dart | 16 ++- lib/core/services/quick_actions_service.dart | 108 ++++++++++++++++++ lib/core/utils/android_assistant_handler.dart | 4 +- lib/features/chat/views/voice_call_page.dart | 10 +- pubspec.lock | 32 ++++++ pubspec.yaml | 1 + 8 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 lib/core/services/quick_actions_service.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 51a43e7..835d4f6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -64,6 +64,8 @@ PODS: - onnxruntime-c (= 1.22.0) - package_info_plus (0.4.5): - Flutter + - quick_actions_ios (0.0.1): + - Flutter - record_ios (1.1.0): - Flutter - SDWebImage (5.21.1): @@ -113,6 +115,7 @@ DEPENDENCIES: - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - objective_c (from `.symlinks/plugins/objective_c/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`) - share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`) - share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`) @@ -162,6 +165,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/objective_c/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" + quick_actions_ios: + :path: ".symlinks/plugins/quick_actions_ios/ios" record_ios: :path: ".symlinks/plugins/record_ios/ios" share_handler_ios: @@ -204,6 +209,7 @@ SPEC CHECKSUMS: onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a onnxruntime-objc: 83d28b87525bd971259a66e153ea32b5d023de19 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779 record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 SDWebImage: f29024626962457f3470184232766516dee8dfea share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart index 1eac896..aa2aa29 100644 --- a/lib/core/providers/app_startup_providers.dart +++ b/lib/core/providers/app_startup_providers.dart @@ -10,6 +10,7 @@ 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 '../services/quick_actions_service.dart'; import '../models/conversation.dart'; import '../services/background_streaming_handler.dart'; import '../services/persistent_streaming_service.dart'; @@ -171,6 +172,7 @@ class AppStartupFlow extends _$AppStartupFlow { keepAlive(apiTokenUpdaterProvider); keepAlive(silentLoginCoordinatorProvider); keepAlive(appIntentCoordinatorProvider); + keepAlive(quickActionsCoordinatorProvider); // Kick background model loading flow (non-blocking) Future.delayed(const Duration(milliseconds: 120), () { diff --git a/lib/core/services/app_intents_service.dart b/lib/core/services/app_intents_service.dart index 94f3194..ebfbaa8 100644 --- a/lib/core/services/app_intents_service.dart +++ b/lib/core/services/app_intents_service.dart @@ -362,6 +362,20 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { ); } + Future openChatFromExternal({ + String? prompt, + bool focusComposer = false, + bool resetChat = false, + }) { + return _prepareChatWithOptions( + prompt: prompt, + focusComposer: focusComposer, + resetChat: resetChat, + ); + } + + Future startVoiceCallFromExternal() => _startVoiceCall(); + Future _prepareChatWithOptions({ String? prompt, bool focusComposer = false, @@ -412,7 +426,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { await navigator.push( MaterialPageRoute( - builder: (_) => const VoiceCallPage(), + builder: (_) => const VoiceCallPage(startNewConversation: true), fullscreenDialog: true, ), ); diff --git a/lib/core/services/quick_actions_service.dart b/lib/core/services/quick_actions_service.dart new file mode 100644 index 0000000..028f69e --- /dev/null +++ b/lib/core/services/quick_actions_service.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:conduit/l10n/app_localizations.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:quick_actions/quick_actions.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../providers/app_providers.dart'; +import '../utils/debug_logger.dart'; +import 'app_intents_service.dart'; +import 'navigation_service.dart'; + +part 'quick_actions_service.g.dart'; + +const _quickActionNewChat = 'conduit_new_chat'; +const _quickActionVoiceCall = 'conduit_voice_call'; + +@Riverpod(keepAlive: true) +class QuickActionsCoordinator extends _$QuickActionsCoordinator { + final QuickActions _quickActions = const QuickActions(); + + @override + FutureOr build() { + if (kIsWeb) return Future.value(); + if (!Platform.isIOS && !Platform.isAndroid) { + return Future.value(); + } + + _quickActions.initialize(_handleAction); + unawaited(_setShortcuts()); + + ref.listen(appLocaleProvider, (prev, next) { + unawaited(_setShortcuts()); + }); + } + + Future _setShortcuts() async { + final titles = _resolveTitles(); + try { + await _quickActions.setShortcutItems([ + ShortcutItem(type: _quickActionNewChat, localizedTitle: titles.newChat), + ShortcutItem( + type: _quickActionVoiceCall, + localizedTitle: titles.voiceCall, + ), + ]); + } catch (error, stackTrace) { + DebugLogger.error( + 'quick-actions-register', + scope: 'platform', + error: error, + stackTrace: stackTrace, + ); + } + } + + _QuickActionTitles _resolveTitles() { + final context = NavigationService.context; + final l10n = context != null ? AppLocalizations.of(context) : null; + return _QuickActionTitles( + newChat: l10n?.newChat ?? 'New Chat', + voiceCall: l10n?.voiceCallTitle ?? 'Voice Call', + ); + } + + void _handleAction(String type) { + unawaited(_handleActionAsync(type)); + } + + Future _handleActionAsync(String? type) async { + if (type == null || type.isEmpty) return; + + await Future.delayed(const Duration(milliseconds: 16)); + + switch (type) { + case _quickActionNewChat: + await ref + .read(appIntentCoordinatorProvider.notifier) + .openChatFromExternal(focusComposer: true, resetChat: true); + break; + case _quickActionVoiceCall: + try { + await ref + .read(appIntentCoordinatorProvider.notifier) + .startVoiceCallFromExternal(); + } catch (error, stackTrace) { + DebugLogger.error( + 'quick-actions-voice', + scope: 'platform', + error: error, + stackTrace: stackTrace, + ); + } + break; + default: + DebugLogger.info('Unknown quick action: $type'); + } + } +} + +class _QuickActionTitles { + const _QuickActionTitles({required this.newChat, required this.voiceCall}); + + final String newChat; + final String voiceCall; +} diff --git a/lib/core/utils/android_assistant_handler.dart b/lib/core/utils/android_assistant_handler.dart index 2a14345..e86ac9a 100644 --- a/lib/core/utils/android_assistant_handler.dart +++ b/lib/core/utils/android_assistant_handler.dart @@ -156,10 +156,10 @@ class AndroidAssistantHandler { return; } - // Navigate to voice call page + // Navigate to voice call page with new conversation flag await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => const VoiceCallPage(), + builder: (context) => const VoiceCallPage(startNewConversation: true), fullscreenDialog: true, ), ); diff --git a/lib/features/chat/views/voice_call_page.dart b/lib/features/chat/views/voice_call_page.dart index 4774346..741b36c 100644 --- a/lib/features/chat/views/voice_call_page.dart +++ b/lib/features/chat/views/voice_call_page.dart @@ -8,10 +8,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/utils/markdown_to_text.dart'; import '../../../l10n/app_localizations.dart'; +import '../providers/chat_providers.dart'; import '../services/voice_call_service.dart'; class VoiceCallPage extends ConsumerStatefulWidget { - const VoiceCallPage({super.key}); + const VoiceCallPage({super.key, this.startNewConversation = false}); + + final bool startNewConversation; @override ConsumerState createState() => _VoiceCallPageState(); @@ -54,6 +57,11 @@ class _VoiceCallPageState extends ConsumerState Future _initializeCall() async { try { + // Start a new conversation if requested + if (widget.startNewConversation) { + startNewChat(ref); + } + _service = ref.read(voiceCallServiceProvider); // Subscribe to service streams diff --git a/pubspec.lock b/pubspec.lock index 66c17dd..30acfb7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1181,6 +1181,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + quick_actions: + dependency: "direct main" + description: + name: quick_actions + sha256: "7e35dd6a21f5bbd21acf6899039eaf85001a5ac26d52cbd6a8a2814505b90798" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + quick_actions_android: + dependency: transitive + description: + name: quick_actions_android + sha256: "23f04632ada7fc16665d84ba54a0c792c09727e7fda6c989c6e6ba1853aa15dc" + url: "https://pub.dev" + source: hosted + version: "1.0.27" + quick_actions_ios: + dependency: transitive + description: + name: quick_actions_ios + sha256: a2e08ceb01f9d26e1b1826b1c4f5da6b7b6bbf61bcbaacd8e93dfff58b91f996 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + quick_actions_platform_interface: + dependency: transitive + description: + name: quick_actions_platform_interface + sha256: "1fec7068db5122cd019e9340d3d7be5d36eab099695ef3402c7059ee058329a4" + url: "https://pub.dev" + source: hosted + version: "1.1.0" record: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 68d74d1..f30d03f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,6 +75,7 @@ dependencies: http: ^1.5.0 flutter_callkit_incoming: ^3.0.0 flutter_app_intents: ^0.7.0 + quick_actions: 1.1.0 # Clipboard functionality is available through flutter/services (part of Flutter SDK)