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)