✨ App intents (aims to support Siri on iOS) (untested)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
platform :ios, '15.0'
|
||||
platform :ios, '16.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -125,6 +125,8 @@ PODS:
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesSwift (~> 2.1)
|
||||
- Flutter (1.0.0)
|
||||
- flutter_app_intents (0.1.0):
|
||||
- Flutter
|
||||
- flutter_app_update (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview_ios (0.0.1):
|
||||
@@ -336,6 +338,7 @@ DEPENDENCIES:
|
||||
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
|
||||
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_app_intents (from `.symlinks/plugins/flutter_app_intents/ios`)
|
||||
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
|
||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||
@@ -428,6 +431,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/firebase_messaging/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_app_intents:
|
||||
:path: ".symlinks/plugins/flutter_app_intents/ios"
|
||||
flutter_app_update:
|
||||
:path: ".symlinks/plugins/flutter_app_update/ios"
|
||||
flutter_inappwebview_ios:
|
||||
@@ -525,6 +530,7 @@ SPEC CHECKSUMS:
|
||||
FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4
|
||||
FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_app_intents: e77f999f398c841ab584a1925dbce33ee0168fb5
|
||||
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
|
||||
@@ -578,6 +584,6 @@ SPEC CHECKSUMS:
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
|
||||
|
||||
PODFILE CHECKSUM: 585198f58dca90ac6492607c83a8d17045ab3852
|
||||
PODFILE CHECKSUM: 5a3652182a0f0e1093d3ac36dcabc72f6cc46b3a
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -216,8 +216,6 @@
|
||||
};
|
||||
7310A7D52EB10962002C0FD3 /* Solian Watch App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = "Solian Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -759,10 +757,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -820,10 +822,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -874,10 +880,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks.sh\"\n";
|
||||
@@ -1096,7 +1106,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Solian;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1597,7 +1607,7 @@
|
||||
INFOPLIST_FILE = SolianShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1642,7 +1652,7 @@
|
||||
INFOPLIST_FILE = SolianShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1684,7 +1694,7 @@
|
||||
INFOPLIST_FILE = SolianShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1950,7 +1960,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Solian;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1981,7 +1991,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Solian;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -2,6 +2,8 @@ import Flutter
|
||||
import WidgetKit
|
||||
import UIKit
|
||||
import WatchConnectivity
|
||||
import AppIntents
|
||||
import flutter_app_intents
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -107,7 +109,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
let token = UserDefaults.standard.getFlutterToken()
|
||||
let serverUrl = UserDefaults.standard.getServerUrl()
|
||||
|
||||
var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
|
||||
var data: [String: Any] = ["serverUrl": serverUrl]
|
||||
if let token = token {
|
||||
data["token"] = token
|
||||
}
|
||||
@@ -125,7 +127,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
let token = UserDefaults.standard.getFlutterToken()
|
||||
let serverUrl = UserDefaults.standard.getServerUrl()
|
||||
|
||||
var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
|
||||
var data: [String: Any] = ["serverUrl": serverUrl]
|
||||
if let token = token {
|
||||
data["token"] = token
|
||||
}
|
||||
@@ -138,3 +140,240 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Intents
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct OpenChatIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Chat"
|
||||
static var description = IntentDescription("Open a specific chat room")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Channel ID")
|
||||
var channelId: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "open_chat",
|
||||
parameters: ["channelId": channelId ?? ""]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Chat opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to open chat"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct OpenPostIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Post"
|
||||
static var description = IntentDescription("Open a specific post")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Post ID")
|
||||
var postId: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "open_post",
|
||||
parameters: ["postId": postId ?? ""]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Post opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to open post"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct OpenComposeIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Compose"
|
||||
static var description = IntentDescription("Open compose post screen")
|
||||
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: "open_compose",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Compose screen opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to open compose"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ComposePostIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Compose Post"
|
||||
static var description = IntentDescription("Create a new post")
|
||||
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: "compose_post",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Compose screen opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to compose post"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct SearchContentIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Search Content"
|
||||
static var description = IntentDescription("Search for content")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Search Query")
|
||||
var query: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "search_content",
|
||||
parameters: ["query": query ?? ""]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Search opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to search"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ViewNotificationsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "View Notifications"
|
||||
static var description = IntentDescription("View notifications")
|
||||
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: "view_notifications",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Notifications opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to view notifications"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct CheckNotificationsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Check Notifications"
|
||||
static var description = IntentDescription("Check notification count")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "check_notifications",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "You have new notifications"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "Dialog: \(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to check notifications"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppIntentError: Error {
|
||||
case executionFailed(String)
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct AppShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
return [
|
||||
// Open chat
|
||||
AppShortcut(
|
||||
intent: OpenChatIntent(),
|
||||
phrases: [
|
||||
"Open chat with \(.applicationName)",
|
||||
"Go to chat using \(.applicationName)",
|
||||
"Show chat in \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Open post
|
||||
AppShortcut(
|
||||
intent: OpenPostIntent(),
|
||||
phrases: [
|
||||
"Open post with \(.applicationName)",
|
||||
"Show post using \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Compose
|
||||
AppShortcut(
|
||||
intent: OpenComposeIntent(),
|
||||
phrases: [
|
||||
"Open compose with \(.applicationName)",
|
||||
"New post using \(.applicationName)",
|
||||
"Write post in \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Search
|
||||
AppShortcut(
|
||||
intent: SearchContentIntent(),
|
||||
phrases: [
|
||||
"Search in \(.applicationName)",
|
||||
"Find content using \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Check notifications
|
||||
AppShortcut(
|
||||
intent: CheckNotificationsIntent(),
|
||||
phrases: [
|
||||
"Check notifications with \(.applicationName)",
|
||||
"Get notifications using \(.applicationName)",
|
||||
"Do I have notifications in \(.applicationName)"
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@
|
||||
<string>Allow the Solar Network verify your ownership of the logged in account and continue your action quickly.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Grant access to Microphone will allow Solian record audio for your post.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Solian uses speech recognition for Siri integration</string>
|
||||
<key>NSAppIntentsConfiguration</key>
|
||||
<dict>
|
||||
<key>NSAppIntentsPackage</key>
|
||||
<string>dev.solsynth.solian</string>
|
||||
</dict>
|
||||
<key>NSAppIntentsMetadata</key>
|
||||
<dict>
|
||||
<key>NSAppIntentsSupported</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
|
||||
@@ -23,6 +23,7 @@ import 'package:island/route.dart';
|
||||
import 'package:island/services/notify.dart';
|
||||
import 'package:island/services/widget_sync_service.dart';
|
||||
import 'package:island/services/timezone.dart';
|
||||
import 'package:island/services/app_intents.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
@@ -99,6 +100,17 @@ void main() async {
|
||||
talker.error("[SplashScreen] Failed to load timezone database... $err");
|
||||
}
|
||||
|
||||
try {
|
||||
talker.info("[AppIntents] Initializing App Intents service...");
|
||||
final appIntentsService = AppIntentsService();
|
||||
await appIntentsService.initialize();
|
||||
talker.info("[AppIntents] App Intents service is ready!");
|
||||
} catch (err) {
|
||||
talker.error(
|
||||
"[AppIntents] Failed to initialize App Intents service... $err",
|
||||
);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
|
||||
|
||||
1
lib/services/app_intents.dart
Normal file
1
lib/services/app_intents.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'app_intents/ios.dart';
|
||||
443
lib/services/app_intents/ios.dart
Normal file
443
lib/services/app_intents/ios.dart
Normal file
@@ -0,0 +1,443 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_app_intents/flutter_app_intents.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/models/auth.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/route.dart';
|
||||
|
||||
class AppIntentsService {
|
||||
static final AppIntentsService _instance = AppIntentsService._internal();
|
||||
factory AppIntentsService() => _instance;
|
||||
AppIntentsService._internal();
|
||||
|
||||
FlutterAppIntentsClient? _client;
|
||||
bool _initialized = false;
|
||||
Dio? _dio;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!Platform.isIOS) {
|
||||
talker.warning('[AppIntents] App Intents only supported on iOS');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initialized) {
|
||||
talker.info('[AppIntents] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
talker.info('[AppIntents] Initializing App Intents client...');
|
||||
_client = FlutterAppIntentsClient.instance;
|
||||
|
||||
// Initialize Dio for API calls
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final serverUrl =
|
||||
prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
|
||||
final tokenString = prefs.getString(kTokenPairStoreKey);
|
||||
|
||||
final headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (tokenString != null) {
|
||||
try {
|
||||
final token = AppToken.fromJson(jsonDecode(tokenString));
|
||||
headers['Authorization'] = 'AtField ${token.token}';
|
||||
} catch (e) {
|
||||
talker.warning('[AppIntents] Failed to parse token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: serverUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: headers,
|
||||
),
|
||||
);
|
||||
|
||||
await _registerIntents();
|
||||
_initialized = true;
|
||||
talker.info('[AppIntents] All intents registered successfully');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Initialization failed', e, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerIntents() async {
|
||||
if (_client == null) {
|
||||
throw StateError('Client not initialized');
|
||||
}
|
||||
|
||||
// Navigation Intents
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('open_chat')
|
||||
.title('Open Chat')
|
||||
.description('Open a specific chat room')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'channelId',
|
||||
title: 'Channel ID',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleOpenChatIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('open_post')
|
||||
.title('Open Post')
|
||||
.description('Open a specific post')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'postId',
|
||||
title: 'Post ID',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleOpenPostIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('open_compose')
|
||||
.title('Open Compose')
|
||||
.description('Open compose post screen')
|
||||
.build(),
|
||||
_handleOpenComposeIntent,
|
||||
);
|
||||
|
||||
// Action Intent
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('compose_post')
|
||||
.title('Compose Post')
|
||||
.description('Create a new post')
|
||||
.build(),
|
||||
_handleComposePostIntent,
|
||||
);
|
||||
|
||||
// Query Intents
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('search_content')
|
||||
.title('Search Content')
|
||||
.description('Search for content')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'query',
|
||||
title: 'Search Query',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleSearchContentIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('view_notifications')
|
||||
.title('View Notifications')
|
||||
.description('View notifications')
|
||||
.build(),
|
||||
_handleViewNotificationsIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('check_notifications')
|
||||
.title('Check Notifications')
|
||||
.description('Check notification count')
|
||||
.build(),
|
||||
_handleCheckNotificationsIntent,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_client = null;
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleOpenChatIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final channelId = parameters['channelId'] as String?;
|
||||
if (channelId == null) {
|
||||
throw ArgumentError('channelId is required');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Opening chat: $channelId');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/chat/$channelId');
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening chat $channelId',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to open chat', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to open chat: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleOpenPostIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final postId = parameters['postId'] as String?;
|
||||
if (postId == null) {
|
||||
throw ArgumentError('postId is required');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Opening post: $postId');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/posts/$postId');
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening post $postId',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to open post', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to open post: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleOpenComposeIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Opening compose screen');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/posts/compose');
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening compose screen',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to open compose', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to open compose: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleComposePostIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Composing new post');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/posts/compose');
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening compose screen',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to compose post', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to compose post: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleSearchContentIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final query = parameters['query'] as String?;
|
||||
if (query == null) {
|
||||
throw ArgumentError('query is required');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Searching for: $query');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/search?q=$query');
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Searching for "$query"',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to search', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to search: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleViewNotificationsIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Opening notifications');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
// Note: You may need to adjust the route based on your actual notifications route
|
||||
// This is a common pattern - check your route.dart for exact path
|
||||
// If you don't have a dedicated notifications route, you might need to add one
|
||||
return AppIntentResult.failed(
|
||||
error: 'Notifications route not implemented',
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to view notifications', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to view notifications: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleCheckNotificationsIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Checking notifications count');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio!.get('/ring/notifications/count');
|
||||
final count = (response.data as num).toInt();
|
||||
final countValue = count;
|
||||
|
||||
String message;
|
||||
if (countValue == 0) {
|
||||
message = 'You have no new notifications';
|
||||
} else if (countValue == 1) {
|
||||
message = 'You have 1 new notification';
|
||||
} else {
|
||||
message = 'You have $countValue new notifications';
|
||||
}
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: message,
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error checking notifications', e);
|
||||
return AppIntentResult.failed(
|
||||
error:
|
||||
'Failed to fetch notifications: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to check notifications', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to check notifications: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Donation Methods - to be called manually from your app code
|
||||
|
||||
Future<void> donateOpenChat(String channelId) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'open_chat',
|
||||
{'channelId': channelId},
|
||||
relevanceScore: 0.8,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
talker.info('[AppIntents] Donated open_chat intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate open_chat', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateOpenPost(String postId) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'open_post',
|
||||
{'postId': postId},
|
||||
relevanceScore: 0.8,
|
||||
context: {'feature': 'posts', 'userAction': true},
|
||||
);
|
||||
talker.info('[AppIntents] Donated open_post intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate open_post', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateCompose() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'open_compose',
|
||||
{},
|
||||
relevanceScore: 0.9,
|
||||
context: {'feature': 'compose', 'userAction': true},
|
||||
);
|
||||
talker.info('[AppIntents] Donated compose intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate compose', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateSearch(String query) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'search_content',
|
||||
{'query': query},
|
||||
relevanceScore: 0.7,
|
||||
context: {'feature': 'search', 'userAction': true},
|
||||
);
|
||||
talker.info('[AppIntents] Donated search intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate search', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateCheckNotifications() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'check_notifications',
|
||||
{},
|
||||
relevanceScore: 0.6,
|
||||
context: {'feature': 'notifications', 'userAction': true},
|
||||
);
|
||||
talker.info('[AppIntents] Donated check_notifications intent');
|
||||
} catch (e, stack) {
|
||||
talker.error(
|
||||
'[AppIntents] Failed to donate check_notifications',
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -774,6 +774,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_app_update:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -173,6 +173,7 @@ dependencies:
|
||||
shake: ^3.0.0
|
||||
in_app_review: ^2.0.11
|
||||
snow_fall_animation: ^0.0.1+3
|
||||
flutter_app_intents: ^0.7.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user