diff --git a/ios/Podfile b/ios/Podfile index 2dc5b301..93c79531 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -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' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 015e2d18..c4bd4c18 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3c0ca096..98d66fb2 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; @@ -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", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c54ff4b7..dbfaccd7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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 & 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 & 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 & 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 & 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 & 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 & 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 & 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)" + ] + ) + ] + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 152d736b..3ac3a031 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -63,6 +63,18 @@ Allow the Solar Network verify your ownership of the logged in account and continue your action quickly. NSMicrophoneUsageDescription Grant access to Microphone will allow Solian record audio for your post. + NSSpeechRecognitionUsageDescription + Solian uses speech recognition for Siri integration + NSAppIntentsConfiguration + + NSAppIntentsPackage + dev.solsynth.solian + + NSAppIntentsMetadata + + NSAppIntentsSupported + + NSPhotoLibraryAddUsageDescription Grant access to Photo Library will allow Solian download photo to album for you. NSPhotoLibraryUsageDescription diff --git a/lib/main.dart b/lib/main.dart index 7383c857..d20382cb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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)) { diff --git a/lib/services/app_intents.dart b/lib/services/app_intents.dart new file mode 100644 index 00000000..a3a09a5b --- /dev/null +++ b/lib/services/app_intents.dart @@ -0,0 +1 @@ +export 'app_intents/ios.dart'; diff --git a/lib/services/app_intents/ios.dart b/lib/services/app_intents/ios.dart new file mode 100644 index 00000000..3bf3b21f --- /dev/null +++ b/lib/services/app_intents/ios.dart @@ -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 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 _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 _handleOpenChatIntent( + Map 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 _handleOpenPostIntent( + Map 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 _handleOpenComposeIntent( + Map 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 _handleComposePostIntent( + Map 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 _handleSearchContentIntent( + Map 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 _handleViewNotificationsIntent( + Map 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 _handleCheckNotificationsIntent( + Map 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 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 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 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 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 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, + ); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index acf42bf2..fd3d6b41 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 58060dd9..31c5aeeb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: