Merge pull request #174 from cogwheel0/ios-shortcuts-support
feat(ios): add ios shortcuts support
This commit is contained in:
@@ -21,6 +21,6 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>15.1</string>
|
<string>16.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# 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.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ PODS:
|
|||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- flutter_app_intents (0.1.0):
|
||||||
|
- Flutter
|
||||||
- flutter_callkit_incoming (0.0.1):
|
- flutter_callkit_incoming (0.0.1):
|
||||||
- CryptoSwift
|
- CryptoSwift
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -102,6 +104,7 @@ DEPENDENCIES:
|
|||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_app_intents (from `.symlinks/plugins/flutter_app_intents/ios`)
|
||||||
- flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`)
|
- flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`)
|
||||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
@@ -141,6 +144,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
flutter_app_intents:
|
||||||
|
:path: ".symlinks/plugins/flutter_app_intents/ios"
|
||||||
flutter_callkit_incoming:
|
flutter_callkit_incoming:
|
||||||
:path: ".symlinks/plugins/flutter_callkit_incoming/ios"
|
:path: ".symlinks/plugins/flutter_callkit_incoming/ios"
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
@@ -188,6 +193,7 @@ SPEC CHECKSUMS:
|
|||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_app_intents: e77f999f398c841ab584a1925dbce33ee0168fb5
|
||||||
flutter_callkit_incoming: cb8138af67cda6dd981f7101a5d709003af21502
|
flutter_callkit_incoming: cb8138af67cda6dd981f7101a5d709003af21502
|
||||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
@@ -212,6 +218,6 @@ SPEC CHECKSUMS:
|
|||||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
PODFILE CHECKSUM: a6ecbec6401c6461e69650e9ef66360aee70610f
|
PODFILE CHECKSUM: 467be2eaee7e6ac5b7b2d2ef8bea817d4d70b736
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -585,7 +585,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -722,7 +722,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -773,7 +773,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -865,7 +865,7 @@
|
|||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -908,7 +908,7 @@
|
|||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -948,7 +948,7 @@
|
|||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import AppIntents
|
||||||
|
import flutter_app_intents
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
final class VoiceBackgroundAudioManager {
|
final class VoiceBackgroundAudioManager {
|
||||||
static let shared = VoiceBackgroundAudioManager()
|
static let shared = VoiceBackgroundAudioManager()
|
||||||
@@ -305,6 +308,248 @@ class BackgroundStreamingHandler: NSObject {
|
|||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
endBackgroundTask()
|
endBackgroundTask()
|
||||||
VoiceBackgroundAudioManager.shared.deactivate()
|
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<String> & 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<String> & 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<String> & 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<String> & 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<String> & 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)",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,22 +104,26 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
// Persist user and avatar asynchronously without blocking state update
|
// Persist user and avatar asynchronously without blocking state update
|
||||||
unawaited(_persistUserWithAvatar(next, storage));
|
unawaited(_persistUserWithAvatar(next, storage));
|
||||||
} else if (!next.isAuthenticated) {
|
} else if (!next.isAuthenticated) {
|
||||||
unawaited(storage.saveLocalUser(null).onError((error, stack) {
|
unawaited(
|
||||||
DebugLogger.error(
|
storage.saveLocalUser(null).onError((error, stack) {
|
||||||
'Failed to clear local user on logout',
|
DebugLogger.error(
|
||||||
scope: 'auth/persistence',
|
'Failed to clear local user on logout',
|
||||||
error: error,
|
scope: 'auth/persistence',
|
||||||
stackTrace: stack,
|
error: error,
|
||||||
);
|
stackTrace: stack,
|
||||||
}));
|
);
|
||||||
unawaited(storage.saveLocalUserAvatar(null).onError((error, stack) {
|
}),
|
||||||
DebugLogger.error(
|
);
|
||||||
'Failed to clear local user avatar on logout',
|
unawaited(
|
||||||
scope: 'auth/persistence',
|
storage.saveLocalUserAvatar(null).onError((error, stack) {
|
||||||
error: error,
|
DebugLogger.error(
|
||||||
stackTrace: stack,
|
'Failed to clear local user avatar on logout',
|
||||||
);
|
scope: 'auth/persistence',
|
||||||
}));
|
error: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
state = AsyncValue.data(next);
|
state = AsyncValue.data(next);
|
||||||
if (cache) {
|
if (cache) {
|
||||||
@@ -140,8 +144,8 @@ class AuthStateManager extends _$AuthStateManager {
|
|||||||
);
|
);
|
||||||
final userWithAvatar =
|
final userWithAvatar =
|
||||||
resolvedAvatar != null && resolvedAvatar != user.profileImage
|
resolvedAvatar != null && resolvedAvatar != user.profileImage
|
||||||
? user.copyWith(profileImage: resolvedAvatar)
|
? user.copyWith(profileImage: resolvedAvatar)
|
||||||
: user;
|
: user;
|
||||||
await storage.saveLocalUser(userWithAvatar);
|
await storage.saveLocalUser(userWithAvatar);
|
||||||
if (resolvedAvatar != null) {
|
if (resolvedAvatar != null) {
|
||||||
await storage.saveLocalUserAvatar(resolvedAvatar);
|
await storage.saveLocalUserAvatar(resolvedAvatar);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
import '../providers/app_providers.dart';
|
import '../providers/app_providers.dart';
|
||||||
import '../../features/auth/providers/unified_auth_providers.dart';
|
import '../../features/auth/providers/unified_auth_providers.dart';
|
||||||
import '../services/navigation_service.dart';
|
import '../services/navigation_service.dart';
|
||||||
|
import '../services/app_intents_service.dart';
|
||||||
import '../models/conversation.dart';
|
import '../models/conversation.dart';
|
||||||
import '../services/background_streaming_handler.dart';
|
import '../services/background_streaming_handler.dart';
|
||||||
import '../services/persistent_streaming_service.dart';
|
import '../services/persistent_streaming_service.dart';
|
||||||
@@ -169,6 +170,7 @@ class AppStartupFlow extends _$AppStartupFlow {
|
|||||||
keepAlive(authApiIntegrationProvider);
|
keepAlive(authApiIntegrationProvider);
|
||||||
keepAlive(apiTokenUpdaterProvider);
|
keepAlive(apiTokenUpdaterProvider);
|
||||||
keepAlive(silentLoginCoordinatorProvider);
|
keepAlive(silentLoginCoordinatorProvider);
|
||||||
|
keepAlive(appIntentCoordinatorProvider);
|
||||||
|
|
||||||
// Kick background model loading flow (non-blocking)
|
// Kick background model loading flow (non-blocking)
|
||||||
Future<void>.delayed(const Duration(milliseconds: 120), () {
|
Future<void>.delayed(const Duration(milliseconds: 120), () {
|
||||||
|
|||||||
@@ -33,4 +33,3 @@ final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
|
|||||||
workerManager: ref.watch(workerManagerProvider),
|
workerManager: ref.watch(workerManagerProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
473
lib/core/services/app_intents_service.dart
Normal file
473
lib/core/services/app_intents_service.dart
Normal file
@@ -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<void> build() {
|
||||||
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
unawaited(_registerAskIntent());
|
||||||
|
unawaited(_registerVoiceCallIntent());
|
||||||
|
unawaited(_registerSendTextIntent());
|
||||||
|
unawaited(_registerSendUrlIntent());
|
||||||
|
unawaited(_registerSendImageIntent());
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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<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<AppIntentResult> _handleAskIntent(
|
||||||
|
Map<String, dynamic> 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<AppIntentResult> _handleVoiceCallIntent(
|
||||||
|
Map<String, dynamic> 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<AppIntentResult> _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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AppIntentResult> _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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AppIntentResult> _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.');
|
||||||
|
}
|
||||||
|
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<void> _prepareChat({String? prompt}) async {
|
||||||
|
await _prepareChatWithOptions(
|
||||||
|
prompt: prompt,
|
||||||
|
focusComposer: false,
|
||||||
|
resetChat: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const VoiceCallPage(),
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _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<void> _attachFiles(List<File> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,7 +285,10 @@ class SecureCredentialStorage {
|
|||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
try {
|
try {
|
||||||
await _secureStorage.deleteAll();
|
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) {
|
} catch (e) {
|
||||||
DebugLogger.error('clear-failed', scope: 'credentials', error: e);
|
DebugLogger.error('clear-failed', scope: 'credentials', error: e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import '../models/model.dart';
|
|||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
|
|
||||||
/// Extracts the profile image URL from a model's metadata.
|
/// Extracts the profile image URL from a model's metadata.
|
||||||
///
|
///
|
||||||
/// Note: After OpenWebUI updates, the profile_image_url field is stripped from
|
/// Note: After OpenWebUI updates, the profile_image_url field is stripped from
|
||||||
/// the /api/models response. This function still checks for legacy data but
|
/// the /api/models response. This function still checks for legacy data but
|
||||||
/// clients should use [buildModelAvatarUrl] to construct the proper endpoint URL.
|
/// 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.
|
/// Builds the model avatar URL using the new OpenWebUI endpoint.
|
||||||
///
|
///
|
||||||
/// OpenWebUI now serves model avatars through a dedicated endpoint:
|
/// OpenWebUI now serves model avatars through a dedicated endpoint:
|
||||||
/// `/api/v1/models/model/profile/image?id={modelId}`
|
/// `/api/v1/models/model/profile/image?id={modelId}`
|
||||||
///
|
///
|
||||||
/// This endpoint:
|
/// This endpoint:
|
||||||
/// - Requires authentication
|
/// - Requires authentication
|
||||||
/// - Handles external URLs (returns 302 redirect)
|
/// - Handles external URLs (returns 302 redirect)
|
||||||
@@ -76,12 +76,9 @@ String? buildModelAvatarUrl(ApiService? api, String? modelId) {
|
|||||||
final baseUri = Uri.parse(baseUrl);
|
final baseUri = Uri.parse(baseUrl);
|
||||||
final path = '/api/v1/models/model/profile/image';
|
final path = '/api/v1/models/model/profile/image';
|
||||||
final queryParams = {'id': modelId};
|
final queryParams = {'id': modelId};
|
||||||
|
|
||||||
final avatarUri = baseUri.replace(
|
final avatarUri = baseUri.replace(path: path, queryParameters: queryParams);
|
||||||
path: path,
|
|
||||||
queryParameters: queryParams,
|
|
||||||
);
|
|
||||||
|
|
||||||
return avatarUri.toString();
|
return avatarUri.toString();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Fallback to manual URL construction
|
// 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.
|
/// Resolves the final model icon URL for a given model.
|
||||||
///
|
///
|
||||||
/// This function first checks for a legacy profile_image_url in the model's
|
/// This function first checks for a legacy profile_image_url in the model's
|
||||||
/// metadata (for backwards compatibility with older OpenWebUI versions).
|
/// metadata (for backwards compatibility with older OpenWebUI versions).
|
||||||
/// If found and it's an external URL or data URI, it uses that directly.
|
/// 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:
|
/// Otherwise, it constructs the URL using the new OpenWebUI endpoint:
|
||||||
/// `/api/v1/models/model/profile/image?id={modelId}`
|
/// `/api/v1/models/model/profile/image?id={modelId}`
|
||||||
String? resolveModelIconUrlForModel(ApiService? api, Model? model) {
|
String? resolveModelIconUrlForModel(ApiService? api, Model? model) {
|
||||||
@@ -152,7 +149,7 @@ String? resolveModelIconUrlForModel(ApiService? api, Model? model) {
|
|||||||
|
|
||||||
// Check for legacy profile_image_url in metadata
|
// Check for legacy profile_image_url in metadata
|
||||||
final legacyUrl = deriveModelIcon(model);
|
final legacyUrl = deriveModelIcon(model);
|
||||||
|
|
||||||
// If we have a legacy URL that's external or a data URI, use it directly
|
// If we have a legacy URL that's external or a data URI, use it directly
|
||||||
if (legacyUrl != null && legacyUrl.isNotEmpty) {
|
if (legacyUrl != null && legacyUrl.isNotEmpty) {
|
||||||
final trimmed = legacyUrl.trim();
|
final trimmed = legacyUrl.trim();
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import '../theme/theme_extensions.dart';
|
|||||||
import 'user_avatar.dart';
|
import 'user_avatar.dart';
|
||||||
|
|
||||||
/// Displays a model's avatar image with automatic caching and fallback UI.
|
/// Displays a model's avatar image with automatic caching and fallback UI.
|
||||||
///
|
///
|
||||||
/// The avatar can display:
|
/// The avatar can display:
|
||||||
/// - Network images from the OpenWebUI model avatar endpoint
|
/// - Network images from the OpenWebUI model avatar endpoint
|
||||||
/// - Data URIs (base64-encoded images)
|
/// - Data URIs (base64-encoded images)
|
||||||
/// - A fallback UI showing the first letter of the model name or a brain icon
|
/// - A fallback UI showing the first letter of the model name or a brain icon
|
||||||
///
|
///
|
||||||
/// Images are automatically cached using [CachedNetworkImage] with proper
|
/// Images are automatically cached using [CachedNetworkImage] with proper
|
||||||
/// authentication headers. The cache respects self-signed certificates if
|
/// authentication headers. The cache respects self-signed certificates if
|
||||||
/// configured.
|
/// configured.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final avatarUrl = resolveModelIconUrlForModel(apiService, model);
|
/// final avatarUrl = resolveModelIconUrlForModel(apiService, model);
|
||||||
@@ -22,11 +22,11 @@ import 'user_avatar.dart';
|
|||||||
class ModelAvatar extends StatelessWidget {
|
class ModelAvatar extends StatelessWidget {
|
||||||
/// The size (width and height) of the avatar in logical pixels.
|
/// The size (width and height) of the avatar in logical pixels.
|
||||||
final double size;
|
final double size;
|
||||||
|
|
||||||
/// The URL of the avatar image. Should be obtained via
|
/// The URL of the avatar image. Should be obtained via
|
||||||
/// [resolveModelIconUrlForModel] to use the correct OpenWebUI endpoint.
|
/// [resolveModelIconUrlForModel] to use the correct OpenWebUI endpoint.
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
|
|
||||||
/// The model name, used for the fallback UI (shows first letter).
|
/// The model name, used for the fallback UI (shows first letter).
|
||||||
final String? label;
|
final String? label;
|
||||||
|
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -393,6 +393,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.1.1"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.7"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -478,6 +486,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
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:
|
flutter_cache_manager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ dependencies:
|
|||||||
flutter_cache_manager: ^3.4.1
|
flutter_cache_manager: ^3.4.1
|
||||||
http: ^1.5.0
|
http: ^1.5.0
|
||||||
flutter_callkit_incoming: ^3.0.0
|
flutter_callkit_incoming: ^3.0.0
|
||||||
|
flutter_app_intents: ^0.7.0
|
||||||
|
|
||||||
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user