refactor(app-intents): Replace flutter_app_intents with method channel
This commit is contained in:
@@ -43,8 +43,6 @@ 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
|
||||||
@@ -112,7 +110,6 @@ 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`)
|
||||||
@@ -155,8 +152,6 @@ 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:
|
||||||
@@ -208,7 +203,6 @@ 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
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import AVFoundation
|
|||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import Flutter
|
import Flutter
|
||||||
import AppIntents
|
import AppIntents
|
||||||
import flutter_app_intents
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@@ -311,6 +310,48 @@ class BackgroundStreamingHandler: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manages the method channel for App Intent invocations to Flutter.
|
||||||
|
/// Native Swift intents call this to invoke Flutter-side business logic.
|
||||||
|
final class AppIntentMethodChannel {
|
||||||
|
static var shared: AppIntentMethodChannel?
|
||||||
|
|
||||||
|
private let channel: FlutterMethodChannel
|
||||||
|
|
||||||
|
init(messenger: FlutterBinaryMessenger) {
|
||||||
|
channel = FlutterMethodChannel(
|
||||||
|
name: "conduit/app_intents",
|
||||||
|
binaryMessenger: messenger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invokes a Flutter handler for the given intent identifier.
|
||||||
|
func invokeIntent(
|
||||||
|
identifier: String,
|
||||||
|
parameters: [String: Any]
|
||||||
|
) async -> [String: Any] {
|
||||||
|
// No [weak self] needed here - the closure executes immediately on the
|
||||||
|
// main queue and there's no retain cycle risk. Using weak self would
|
||||||
|
// risk the continuation never resuming if self became nil.
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.channel.invokeMethod(
|
||||||
|
identifier,
|
||||||
|
arguments: parameters
|
||||||
|
) { result in
|
||||||
|
if let dict = result as? [String: Any] {
|
||||||
|
continuation.resume(returning: dict)
|
||||||
|
} else {
|
||||||
|
continuation.resume(returning: [
|
||||||
|
"success": false,
|
||||||
|
"error": "Invalid response from Flutter"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
enum AppIntentError: Error {
|
enum AppIntentError: Error {
|
||||||
case executionFailed(String)
|
case executionFailed(String)
|
||||||
@@ -340,11 +381,14 @@ struct AskConduitIntent: AppIntent {
|
|||||||
func perform() async throws
|
func perform() async throws
|
||||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||||
{
|
{
|
||||||
|
guard let channel = AppIntentMethodChannel.shared else {
|
||||||
|
throw AppIntentError.executionFailed("App not ready")
|
||||||
|
}
|
||||||
|
|
||||||
let parameters: [String: Any] = prompt?.isEmpty == false
|
let parameters: [String: Any] = prompt?.isEmpty == false
|
||||||
? ["prompt": prompt ?? ""]
|
? ["prompt": prompt ?? ""]
|
||||||
: [:]
|
: [:]
|
||||||
let plugin = FlutterAppIntentsPlugin.shared
|
let result = await channel.invokeIntent(
|
||||||
let result = await plugin.handleIntentInvocation(
|
|
||||||
identifier: "app.cogwheel.conduit.ask_chat",
|
identifier: "app.cogwheel.conduit.ask_chat",
|
||||||
parameters: parameters
|
parameters: parameters
|
||||||
)
|
)
|
||||||
@@ -372,8 +416,11 @@ struct StartVoiceCallIntent: AppIntent {
|
|||||||
func perform() async throws
|
func perform() async throws
|
||||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||||
{
|
{
|
||||||
let plugin = FlutterAppIntentsPlugin.shared
|
guard let channel = AppIntentMethodChannel.shared else {
|
||||||
let result = await plugin.handleIntentInvocation(
|
throw AppIntentError.executionFailed("App not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await channel.invokeIntent(
|
||||||
identifier: "app.cogwheel.conduit.start_voice_call",
|
identifier: "app.cogwheel.conduit.start_voice_call",
|
||||||
parameters: [:]
|
parameters: [:]
|
||||||
)
|
)
|
||||||
@@ -407,9 +454,12 @@ struct ConduitSendTextIntent: AppIntent {
|
|||||||
func perform() async throws
|
func perform() async throws
|
||||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||||
{
|
{
|
||||||
let plugin = FlutterAppIntentsPlugin.shared
|
guard let channel = AppIntentMethodChannel.shared else {
|
||||||
|
throw AppIntentError.executionFailed("App not ready")
|
||||||
|
}
|
||||||
|
|
||||||
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let result = await plugin.handleIntentInvocation(
|
let result = await channel.invokeIntent(
|
||||||
identifier: "app.cogwheel.conduit.send_text",
|
identifier: "app.cogwheel.conduit.send_text",
|
||||||
parameters: ["text": trimmed ?? ""]
|
parameters: ["text": trimmed ?? ""]
|
||||||
)
|
)
|
||||||
@@ -442,8 +492,11 @@ struct ConduitSendUrlIntent: AppIntent {
|
|||||||
func perform() async throws
|
func perform() async throws
|
||||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||||
{
|
{
|
||||||
let plugin = FlutterAppIntentsPlugin.shared
|
guard let channel = AppIntentMethodChannel.shared else {
|
||||||
let result = await plugin.handleIntentInvocation(
|
throw AppIntentError.executionFailed("App not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await channel.invokeIntent(
|
||||||
identifier: "app.cogwheel.conduit.send_url",
|
identifier: "app.cogwheel.conduit.send_url",
|
||||||
parameters: ["url": url.absoluteString]
|
parameters: ["url": url.absoluteString]
|
||||||
)
|
)
|
||||||
@@ -476,6 +529,10 @@ struct ConduitSendImageIntent: AppIntent {
|
|||||||
func perform() async throws
|
func perform() async throws
|
||||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||||
{
|
{
|
||||||
|
guard let channel = AppIntentMethodChannel.shared else {
|
||||||
|
throw AppIntentError.executionFailed("App not ready")
|
||||||
|
}
|
||||||
|
|
||||||
if let type = image.type, !type.conforms(to: .image) {
|
if let type = image.type, !type.conforms(to: .image) {
|
||||||
throw AppIntentError.executionFailed(
|
throw AppIntentError.executionFailed(
|
||||||
"Only image files are supported."
|
"Only image files are supported."
|
||||||
@@ -486,8 +543,7 @@ struct ConduitSendImageIntent: AppIntent {
|
|||||||
let base64 = data.base64EncodedString()
|
let base64 = data.base64EncodedString()
|
||||||
let name = image.filename ?? "shared_image.jpg"
|
let name = image.filename ?? "shared_image.jpg"
|
||||||
|
|
||||||
let plugin = FlutterAppIntentsPlugin.shared
|
let result = await channel.invokeIntent(
|
||||||
let result = await plugin.handleIntentInvocation(
|
|
||||||
identifier: "app.cogwheel.conduit.send_image",
|
identifier: "app.cogwheel.conduit.send_image",
|
||||||
parameters: [
|
parameters: [
|
||||||
"filename": name,
|
"filename": name,
|
||||||
@@ -563,6 +619,13 @@ struct AppShortcuts: AppShortcutsProvider {
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
|
// Setup App Intents method channel for native -> Flutter communication
|
||||||
|
if let registrar = self.registrar(forPlugin: "AppIntentMethodChannel") {
|
||||||
|
AppIntentMethodChannel.shared = AppIntentMethodChannel(
|
||||||
|
messenger: registrar.messenger()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Setup background streaming handler using the plugin registry messenger
|
// Setup background streaming handler using the plugin registry messenger
|
||||||
if let registrar = self.registrar(forPlugin: "BackgroundStreamingHandler") {
|
if let registrar = self.registrar(forPlugin: "BackgroundStreamingHandler") {
|
||||||
let channel = FlutterMethodChannel(
|
let channel = FlutterMethodChannel(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.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/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@@ -14,7 +14,6 @@ import '../utils/debug_logger.dart';
|
|||||||
import 'navigation_service.dart';
|
import 'navigation_service.dart';
|
||||||
import '../../features/chat/providers/chat_providers.dart';
|
import '../../features/chat/providers/chat_providers.dart';
|
||||||
import '../../features/chat/providers/context_attachments_provider.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/auth/providers/unified_auth_providers.dart';
|
||||||
import '../../features/chat/views/voice_call_page.dart';
|
import '../../features/chat/views/voice_call_page.dart';
|
||||||
import '../../features/chat/services/file_attachment_service.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 _sendTextIntentId = 'app.cogwheel.conduit.send_text';
|
||||||
const _sendUrlIntentId = 'app.cogwheel.conduit.send_url';
|
const _sendUrlIntentId = 'app.cogwheel.conduit.send_url';
|
||||||
const _sendImageIntentId = 'app.cogwheel.conduit.send_image';
|
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)
|
@Riverpod(keepAlive: true)
|
||||||
class AppIntentCoordinator extends _$AppIntentCoordinator {
|
class AppIntentCoordinator extends _$AppIntentCoordinator {
|
||||||
@override
|
@override
|
||||||
@@ -37,225 +44,43 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
unawaited(_registerAskIntent());
|
_setupMethodChannel();
|
||||||
unawaited(_registerVoiceCallIntent());
|
|
||||||
unawaited(_registerSendTextIntent());
|
|
||||||
unawaited(_registerSendUrlIntent());
|
|
||||||
unawaited(_registerSendImageIntent());
|
|
||||||
unawaited(_registerAttachKnowledgeIntent());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _registerAskIntent() async {
|
void _setupMethodChannel() {
|
||||||
final client = FlutterAppIntentsClient.instance;
|
_appIntentsChannel.setMethodCallHandler(_handleMethodCall);
|
||||||
final intent = AppIntentBuilder()
|
}
|
||||||
.identifier(_askIntentId)
|
|
||||||
.title('Ask Conduit')
|
Future<Map<String, dynamic>> _handleMethodCall(MethodCall call) async {
|
||||||
.description('Start a chat with an optional prompt.')
|
final parameters = (call.arguments as Map?)?.cast<String, dynamic>() ?? {};
|
||||||
.parameter(
|
|
||||||
const AppIntentParameter(
|
|
||||||
name: 'prompt',
|
|
||||||
title: 'Prompt',
|
|
||||||
description: 'What should Conduit answer?',
|
|
||||||
type: AppIntentParameterType.string,
|
|
||||||
isOptional: true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.registerIntent(intent, _handleAskIntent);
|
switch (call.method) {
|
||||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
case _askIntentId:
|
||||||
_askIntentId,
|
return await _handleAskIntent(parameters);
|
||||||
const {},
|
case _voiceCallIntentId:
|
||||||
relevanceScore: 0.7,
|
return await _handleVoiceCallIntent(parameters);
|
||||||
context: {'feature': 'chat', 'source': 'app_intent'},
|
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) {
|
} catch (error, stackTrace) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'app-intents-register',
|
'app-intents-dispatch',
|
||||||
scope: 'siri',
|
scope: 'siri',
|
||||||
error: error,
|
error: error,
|
||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
);
|
);
|
||||||
|
return {'success': false, 'error': error.toString()};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _registerVoiceCallIntent() async {
|
Future<Map<String, dynamic>> _handleAskIntent(
|
||||||
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(
|
|
||||||
Map<String, dynamic> parameters,
|
Map<String, dynamic> parameters,
|
||||||
) async {
|
) async {
|
||||||
final prompt = (parameters['prompt'] as String?)?.trim();
|
final prompt = (parameters['prompt'] as String?)?.trim();
|
||||||
@@ -266,10 +91,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
? 'Opening chat for "$prompt"'
|
? 'Opening chat for "$prompt"'
|
||||||
: 'Opening Conduit chat';
|
: 'Opening Conduit chat';
|
||||||
|
|
||||||
return AppIntentResult.successful(
|
return {'success': true, 'value': summary};
|
||||||
value: summary,
|
|
||||||
needsToContinueInApp: true,
|
|
||||||
);
|
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'app-intents-handle',
|
'app-intents-handle',
|
||||||
@@ -277,19 +99,41 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
error: error,
|
error: error,
|
||||||
stackTrace: stackTrace,
|
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,
|
Map<String, dynamic> parameters,
|
||||||
) async {
|
) 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 {
|
try {
|
||||||
await _startVoiceCall();
|
await _startVoiceCall();
|
||||||
return AppIntentResult.successful(
|
DebugLogger.log('Voice call launched from Siri/Shortcuts', scope: 'siri');
|
||||||
value: 'Starting Conduit voice call',
|
return {'success': true, 'value': 'Starting Conduit voice call'};
|
||||||
needsToContinueInApp: true,
|
|
||||||
);
|
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'app-intents-voice',
|
'app-intents-voice',
|
||||||
@@ -297,18 +141,16 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
error: error,
|
error: error,
|
||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
);
|
);
|
||||||
return AppIntentResult.failed(
|
return {'success': false, 'error': 'Unable to start voice call: $error'};
|
||||||
error: 'Unable to start voice call: $error',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AppIntentResult> _handleSendTextIntent(
|
Future<Map<String, dynamic>> _handleSendTextIntent(
|
||||||
Map<String, dynamic> parameters,
|
Map<String, dynamic> parameters,
|
||||||
) async {
|
) async {
|
||||||
final text = (parameters['text'] as String?)?.trim();
|
final text = (parameters['text'] as String?)?.trim();
|
||||||
if (text == null || text.isEmpty) {
|
if (text == null || text.isEmpty) {
|
||||||
return AppIntentResult.failed(error: 'No text provided.');
|
return {'success': false, 'error': 'No text provided.'};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -317,10 +159,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
focusComposer: true,
|
focusComposer: true,
|
||||||
resetChat: true,
|
resetChat: true,
|
||||||
);
|
);
|
||||||
return AppIntentResult.successful(
|
return {'success': true, 'value': 'Sent to Conduit'};
|
||||||
value: 'Sent to Conduit',
|
|
||||||
needsToContinueInApp: true,
|
|
||||||
);
|
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'app-intents-text',
|
'app-intents-text',
|
||||||
@@ -328,21 +167,22 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
error: error,
|
error: error,
|
||||||
stackTrace: stackTrace,
|
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,
|
Map<String, dynamic> parameters,
|
||||||
) async {
|
) async {
|
||||||
final url = (parameters['url'] as String?)?.trim();
|
final url = (parameters['url'] as String?)?.trim();
|
||||||
if (url == null || url.isEmpty) {
|
if (url == null || url.isEmpty) {
|
||||||
return AppIntentResult.failed(error: 'No URL provided.');
|
return {'success': false, 'error': 'No URL provided.'};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine if this is a YouTube URL
|
// 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://youtu.be') ||
|
||||||
url.startsWith('https://youtube.com') ||
|
url.startsWith('https://youtube.com') ||
|
||||||
url.startsWith('https://m.youtube.com');
|
url.startsWith('https://m.youtube.com');
|
||||||
@@ -357,10 +197,8 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
? await api.processYoutube(url: url)
|
? await api.processYoutube(url: url)
|
||||||
: await api.processWebpage(url: url);
|
: await api.processWebpage(url: url);
|
||||||
|
|
||||||
final file =
|
final file = (result?['file'] as Map?)?.cast<String, dynamic>();
|
||||||
(result?['file'] as Map?)?.cast<String, dynamic>();
|
final fileData = (file?['data'] as Map?)?.cast<String, dynamic>();
|
||||||
final fileData =
|
|
||||||
(file?['data'] as Map?)?.cast<String, dynamic>();
|
|
||||||
content = fileData?['content']?.toString() ?? '';
|
content = fileData?['content']?.toString() ?? '';
|
||||||
final meta = (file?['meta'] as Map?)?.cast<String, dynamic>();
|
final meta = (file?['meta'] as Map?)?.cast<String, dynamic>();
|
||||||
name = meta?['name']?.toString() ?? Uri.parse(url).host;
|
name = meta?['name']?.toString() ?? Uri.parse(url).host;
|
||||||
@@ -400,17 +238,17 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contentAttached) {
|
if (contentAttached) {
|
||||||
return AppIntentResult.successful(
|
return {
|
||||||
value: isYoutube
|
'success': true,
|
||||||
|
'value': isYoutube
|
||||||
? 'YouTube video attached in Conduit'
|
? 'YouTube video attached in Conduit'
|
||||||
: 'Webpage attached in Conduit',
|
: 'Webpage attached in Conduit',
|
||||||
needsToContinueInApp: true,
|
};
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return AppIntentResult.successful(
|
return {
|
||||||
value: 'Opening Conduit with URL (content could not be fetched)',
|
'success': true,
|
||||||
needsToContinueInApp: true,
|
'value': 'Opening Conduit with URL (content could not be fetched)',
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
@@ -419,16 +257,16 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
error: error,
|
error: error,
|
||||||
stackTrace: stackTrace,
|
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,
|
Map<String, dynamic> parameters,
|
||||||
) async {
|
) async {
|
||||||
final base64 = parameters['bytes'] as String?;
|
final base64 = parameters['bytes'] as String?;
|
||||||
if (base64 == null || base64.isEmpty) {
|
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();
|
final filenameRaw = (parameters['filename'] as String?)?.trim();
|
||||||
|
|
||||||
@@ -439,10 +277,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
);
|
);
|
||||||
await _attachFiles([file]);
|
await _attachFiles([file]);
|
||||||
await _prepareChatWithOptions(focusComposer: true, resetChat: true);
|
await _prepareChatWithOptions(focusComposer: true, resetChat: true);
|
||||||
return AppIntentResult.successful(
|
return {'success': true, 'value': 'Image attached in Conduit'};
|
||||||
value: 'Image attached in Conduit',
|
|
||||||
needsToContinueInApp: true,
|
|
||||||
);
|
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'app-intents-image',
|
'app-intents-image',
|
||||||
@@ -450,70 +285,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
error: error,
|
error: error,
|
||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
);
|
);
|
||||||
return AppIntentResult.failed(error: 'Unable to send image: $error');
|
return {'success': false, '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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,11 +338,13 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
Future<void> _startVoiceCall() async {
|
Future<void> _startVoiceCall() async {
|
||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
|
// Validate authentication state
|
||||||
final navState = ref.read(authNavigationStateProvider);
|
final navState = ref.read(authNavigationStateProvider);
|
||||||
if (navState != AuthNavigationState.authenticated) {
|
if (navState != AuthNavigationState.authenticated) {
|
||||||
throw StateError('Sign in to start a voice call.');
|
throw StateError('Sign in to start a voice call.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate model selection
|
||||||
final model = ref.read(selectedModelProvider);
|
final model = ref.read(selectedModelProvider);
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw StateError('Choose a model before starting a voice call.');
|
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.
|
// Pre-warm socket connection before navigating to voice call.
|
||||||
// This reduces the chance of "websocket not connected" errors when
|
// 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);
|
final socketService = ref.read(socketServiceProvider);
|
||||||
if (socketService != null && !socketService.isConnected) {
|
if (socketService != null && !socketService.isConnected) {
|
||||||
// Start connection attempt in parallel, don't wait for full connection
|
// Start connection attempt in parallel, don't wait for full connection
|
||||||
@@ -586,21 +360,25 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
|
|||||||
unawaited(socketService.connect());
|
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.
|
// Wait a tick for navigation to settle so navigator/context are present.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
final navigator = NavigationService.navigator;
|
|
||||||
final context = NavigationService.navigatorKey.currentContext;
|
final context = NavigationService.navigatorKey.currentContext;
|
||||||
if (navigator == null || context == null) {
|
if (context == null) {
|
||||||
throw StateError('Navigation is not available.');
|
throw StateError('Navigation context not available.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss keyboard before navigating
|
// 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(
|
MaterialPageRoute(
|
||||||
builder: (_) => const VoiceCallPage(startNewConversation: true),
|
builder: (_) => const VoiceCallPage(startNewConversation: true),
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -393,14 +393,6 @@ 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:
|
||||||
@@ -486,14 +478,6 @@ 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,7 +74,6 @@ 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
|
|
||||||
quick_actions: 1.1.0
|
quick_actions: 1.1.0
|
||||||
flutter_svg: ^2.2.3
|
flutter_svg: ^2.2.3
|
||||||
html_unescape: ^2.0.0
|
html_unescape: ^2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user