refactor(app-intents): Replace flutter_app_intents with method channel

This commit is contained in:
cogwheel0
2025-12-04 22:33:48 +05:30
parent 39864c9088
commit fa857e7c57
5 changed files with 175 additions and 357 deletions

View File

@@ -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<void> _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<Map<String, dynamic>> _handleMethodCall(MethodCall call) async {
final parameters = (call.arguments as Map?)?.cast<String, dynamic>() ?? {};
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<void> _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<void> _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<void> _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<void> _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<void> _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<AppIntentResult> _handleAskIntent(
Future<Map<String, dynamic>> _handleAskIntent(
Map<String, dynamic> 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<AppIntentResult> _handleVoiceCallIntent(
Future<Map<String, dynamic>> _handleVoiceCallIntent(
Map<String, dynamic> 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<AppIntentResult> _handleSendTextIntent(
Future<Map<String, dynamic>> _handleSendTextIntent(
Map<String, dynamic> 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<AppIntentResult> _handleSendUrlIntent(
Future<Map<String, dynamic>> _handleSendUrlIntent(
Map<String, dynamic> 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<String, dynamic>();
final fileData =
(file?['data'] as Map?)?.cast<String, dynamic>();
final file = (result?['file'] as Map?)?.cast<String, dynamic>();
final fileData = (file?['data'] as Map?)?.cast<String, dynamic>();
content = fileData?['content']?.toString() ?? '';
final meta = (file?['meta'] as Map?)?.cast<String, dynamic>();
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<AppIntentResult> _handleSendImageIntent(
Future<Map<String, dynamic>> _handleSendImageIntent(
Map<String, dynamic> 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<AppIntentResult> _handleAttachKnowledgeIntent(
Map<String, dynamic> 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<void> _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<void>.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,