diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 7c61b0ea..a5d75e8d 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,7 +3,6 @@ import WidgetKit import UIKit import WatchConnectivity import AppIntents -import flutter_app_intents @main @objc class AppDelegate: FlutterAppDelegate { @@ -16,7 +15,7 @@ import flutter_app_intents ) -> Bool { syncDefaultsToGroup() WidgetCenter.shared.reloadAllTimelines() - + UNUserNotificationCenter.current().delegate = notifyDelegate let replyableMessageCategory = UNNotificationCategory( @@ -32,26 +31,24 @@ import flutter_app_intents options: [] ) UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory]) - + GeneratedPluginRegistrant.register(with: self) - - // Setup widget sync method channel + setupWidgetSyncChannel() - - // Always initialize and retain a strong reference + if WCSession.isSupported() { AppDelegate.sharedWatchConnectivityService = WatchConnectivityService.shared } else { print("[iOS] WCSession not supported on this device.") } - + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - + private func setupWidgetSyncChannel() { let controller = window?.rootViewController as? FlutterViewController let channel = FlutterMethodChannel(name: "dev.solsynth.solian/widget", binaryMessenger: controller!.binaryMessenger) - + channel.setMethodCallHandler { [weak self] (call, result) in if call.method == "syncToWidget" { syncDefaultsToGroup() @@ -62,12 +59,12 @@ import flutter_app_intents } } } - + override func applicationDidEnterBackground(_ application: UIApplication) { syncDefaultsToGroup() WidgetCenter.shared.reloadAllTimelines() } - + override func applicationWillTerminate(_ application: UIApplication) { syncDefaultsToGroup() } @@ -76,7 +73,7 @@ import flutter_app_intents final class WatchConnectivityService: NSObject, WCSessionDelegate { static let shared = WatchConnectivityService() private let session: WCSession = .default - + private override init() { super.init() print("[iOS] Activating WCSession...") @@ -84,8 +81,6 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate { session.activate() } - // MARK: - WCSessionDelegate - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { if let error = error { print("[iOS] WCSession activation failed: \(error.localizedDescription)") @@ -98,7 +93,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate { } func sessionDidBecomeInactive(_ session: WCSession) {} - + func sessionDidDeactivate(_ session: WCSession) { session.activate() } @@ -108,12 +103,12 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate { if let request = message["request"] as? String, request == "data" { let token = UserDefaults.standard.getFlutterToken() let serverUrl = UserDefaults.standard.getServerUrl() - + var data: [String: Any] = ["serverUrl": serverUrl] if let token = token { data["token"] = token } - + print("[iOS] Replying with data: \(data)") replyHandler(data) } @@ -123,15 +118,15 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate { guard session.activationState == .activated else { return } - + let token = UserDefaults.standard.getFlutterToken() let serverUrl = UserDefaults.standard.getServerUrl() - + var data: [String: Any] = ["serverUrl": serverUrl] if let token = token { data["token"] = token } - + do { try session.updateApplicationContext(data) print("[iOS] Sent application context: \(data)") @@ -140,412 +135,3 @@ 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: "\(value)" - ) - } else { - let errorMessage = result["error"] as? String ?? "Failed to check notifications" - throw AppIntentError.executionFailed(errorMessage) - } - } -} - -@available(iOS 16.0, *) -struct SendMessageIntent: AppIntent { - static var title: LocalizedStringResource = "Send Message" - static var description = IntentDescription("Send a message to a chat channel") - static var isDiscoverable = true - static var openAppWhenRun = false - - @Parameter(title: "Channel ID") - var channelId: String? - - @Parameter(title: "Message Content") - var content: String? - - func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { - guard let channelId = channelId, !channelId.isEmpty else { - throw AppIntentError.executionFailed("Channel ID is required") - } - - guard let content = content, !content.isEmpty else { - throw AppIntentError.executionFailed("Message content is required") - } - - let plugin = FlutterAppIntentsPlugin.shared - let result = await plugin.handleIntentInvocation( - identifier: "send_message", - parameters: ["channelId": channelId, "content": content] - ) - - if let success = result["success"] as? Bool, success { - let value = result["value"] as? String ?? "Message sent" - return .result( - value: value, - dialog: "\(value)" - ) - } else { - let errorMessage = result["error"] as? String ?? "Failed to send message" - throw AppIntentError.executionFailed(errorMessage) - } - } -} - -@available(iOS 16.0, *) -struct ReadMessagesIntent: AppIntent { - static var title: LocalizedStringResource = "Read Messages" - static var description = IntentDescription("Read recent messages from a chat channel") - static var isDiscoverable = true - static var openAppWhenRun = false - - @Parameter(title: "Channel ID") - var channelId: String? - - @Parameter(title: "Number of Messages", default: "5") - var limit: String? - - func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { - guard let channelId = channelId, !channelId.isEmpty else { - throw AppIntentError.executionFailed("Channel ID is required") - } - - let limit = limit ?? "5" - var parameters: [String: Any] = ["channelId": channelId] - parameters["limit"] = limit - - let plugin = FlutterAppIntentsPlugin.shared - let result = await plugin.handleIntentInvocation( - identifier: "read_messages", - parameters: parameters - ) - - if let success = result["success"] as? Bool, success { - let value = result["value"] as? String ?? "Messages retrieved" - return .result( - value: value, - dialog: "\(value)" - ) - } else { - let errorMessage = result["error"] as? String ?? "Failed to read messages" - throw AppIntentError.executionFailed(errorMessage) - } - } -} - -@available(iOS 16.0, *) -struct CheckUnreadChatsIntent: AppIntent { - static var title: LocalizedStringResource = "Check Unread Chats" - static var description = IntentDescription("Check number of unread chat messages") - 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_unread_chats", - parameters: [:] - ) - - if let success = result["success"] as? Bool, success { - let value = result["value"] as? String ?? "No unread messages" - return .result( - value: value, - dialog: "\(value)" - ) - } else { - let errorMessage = result["error"] as? String ?? "Failed to check unread chats" - throw AppIntentError.executionFailed(errorMessage) - } - } -} - -@available(iOS 16.0, *) -struct MarkNotificationsReadIntent: AppIntent { - static var title: LocalizedStringResource = "Mark Notifications Read" - static var description = IntentDescription("Mark all notifications as read") - 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: "mark_notifications_read", - parameters: [:] - ) - - if let success = result["success"] as? Bool, success { - let value = result["value"] as? String ?? "Notifications marked as read" - return .result( - value: value, - dialog: "\(value)" - ) - } else { - let errorMessage = result["error"] as? String ?? "Failed to mark notifications as read" - 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)" - ] - ), - // Send message - AppShortcut( - intent: SendMessageIntent(), - phrases: [ - "Send message with \(.applicationName)", - "Post message using \(.applicationName)", - "Send text using \(.applicationName)" - ] - ), - // Read messages - AppShortcut( - intent: ReadMessagesIntent(), - phrases: [ - "Read messages with \(.applicationName)", - "Get chat using \(.applicationName)", - "Show messages with \(.applicationName)" - ] - ), - // Check unread chats - AppShortcut( - intent: CheckUnreadChatsIntent(), - phrases: [ - "Check unread chats with \(.applicationName)", - "Do I have messages using \(.applicationName)", - "Get unread messages with \(.applicationName)" - ] - ), - // Mark notifications read - AppShortcut( - intent: MarkNotificationsReadIntent(), - phrases: [ - "Mark notifications read with \(.applicationName)", - "Clear notifications using \(.applicationName)", - "Mark all read with \(.applicationName)" - ] - ) - ] - } -} diff --git a/ios/Runner/Services/AppIntentConfiguration.swift b/ios/Runner/Services/AppIntentConfiguration.swift new file mode 100644 index 00000000..0f62ca9b --- /dev/null +++ b/ios/Runner/Services/AppIntentConfiguration.swift @@ -0,0 +1,86 @@ +// +// AppIntentConfiguration.swift +// Runner +// +// Created by LittleSheep on 2026/1/16. +// + +import AppIntents + +@available(iOS 16.0, *) +struct AppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + [ + AppShortcut( + intent: OpenChatIntent(), + phrases: [ + "Open chat with \(.applicationName)", + "Go to chat using \(.applicationName)", + "Show chat in \(.applicationName)" + ] + ), + AppShortcut( + intent: OpenPostIntent(), + phrases: [ + "Open post with \(.applicationName)", + "Show post using \(.applicationName)" + ] + ), + AppShortcut( + intent: OpenComposeIntent(), + phrases: [ + "Open compose with \(.applicationName)", + "New post using \(.applicationName)", + "Write post in \(.applicationName)" + ] + ), + AppShortcut( + intent: SearchContentIntent(), + phrases: [ + "Search in \(.applicationName)", + "Find content using \(.applicationName)" + ] + ), + AppShortcut( + intent: CheckNotificationsIntent(), + phrases: [ + "Check notifications with \(.applicationName)", + "Get notifications using \(.applicationName)", + "Do I have notifications in \(.applicationName)" + ] + ), + AppShortcut( + intent: SendMessageIntent(), + phrases: [ + "Send message with \(.applicationName)", + "Post message using \(.applicationName)", + "Send text using \(.applicationName)" + ] + ), + AppShortcut( + intent: ReadMessagesIntent(), + phrases: [ + "Read messages with \(.applicationName)", + "Get chat using \(.applicationName)", + "Show messages with \(.applicationName)" + ] + ), + AppShortcut( + intent: CheckUnreadChatsIntent(), + phrases: [ + "Check unread chats with \(.applicationName)", + "Do I have messages using \(.applicationName)", + "Get unread messages with \(.applicationName)" + ] + ), + AppShortcut( + intent: MarkNotificationsReadIntent(), + phrases: [ + "Mark notifications read with \(.applicationName)", + "Clear notifications using \(.applicationName)", + "Mark all read with \(.applicationName)" + ] + ) + ] + } +} diff --git a/ios/Runner/Services/AppIntentHandlers.swift b/ios/Runner/Services/AppIntentHandlers.swift new file mode 100644 index 00000000..9ddfd0eb --- /dev/null +++ b/ios/Runner/Services/AppIntentHandlers.swift @@ -0,0 +1,296 @@ +// +// AppIntentHandlers.swift +// Runner +// +// Created by LittleSheep on 2026/1/16. +// + +import AppIntents +import UIKit + +@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 & OpensIntent { + guard let channelId = channelId, !channelId.isEmpty else { + throw AppIntentError.requiredParameter("Channel ID") + } + + DeepLinkHandler.shared.handle(url: URL(string: "solian://chat/\(channelId)")!) + + return .result(value: "Opening chat \(channelId)") + } +} + +@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 & OpensIntent { + guard let postId = postId, !postId.isEmpty else { + throw AppIntentError.requiredParameter("Post ID") + } + + DeepLinkHandler.shared.handle(url: URL(string: "solian://posts/\(postId)")!) + + return .result(value: "Opening post \(postId)") + } +} + +@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 & OpensIntent { + DeepLinkHandler.shared.handle(url: URL(string: "solian://compose")!) + + return .result(value: "Opening compose screen") + } +} + +@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 & OpensIntent { + DeepLinkHandler.shared.handle(url: URL(string: "solian://compose")!) + + return .result(value: "Opening compose screen") + } +} + +@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 & OpensIntent { + guard let query = query, !query.isEmpty else { + throw AppIntentError.requiredParameter("Search Query") + } + + let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + DeepLinkHandler.shared.handle(url: URL(string: "solian://search?q=\(encodedQuery)")!) + + return .result(value: "Searching for \"\(query)\"") + } +} + +@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 & OpensIntent { + DeepLinkHandler.shared.handle(url: URL(string: "solian://notifications")!) + + return .result(value: "Opening notifications") + } +} + +@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 & ProvidesDialog { + do { + let count = try await NetworkService.shared.getNotificationCount() + + let message: String + if count == 0 { + message = "You have no new notifications" + } else if count == 1 { + message = "You have 1 new notification" + } else { + message = "You have \(count) new notifications" + } + + return .result( + value: message, + dialog: "\(message)" + ) + } catch { + throw AppIntentError.networkError("Failed to check notifications: \(error.localizedDescription)") + } + } +} + +@available(iOS 16.0, *) +struct SendMessageIntent: AppIntent { + static var title: LocalizedStringResource = "Send Message" + static var description = IntentDescription("Send a message to a chat channel") + static var isDiscoverable = true + static var openAppWhenRun = false + + @Parameter(title: "Channel ID") + var channelId: String? + + @Parameter(title: "Message Content") + var content: String? + + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let channelId = channelId, !channelId.isEmpty else { + throw AppIntentError.requiredParameter("Channel ID") + } + + guard let content = content, !content.isEmpty else { + throw AppIntentError.requiredParameter("Message Content") + } + + do { + try await NetworkService.shared.sendMessage(channelId: channelId, content: content) + + return .result( + value: "Message sent to channel \(channelId)", + dialog: "Message sent successfully" + ) + } catch { + throw AppIntentError.networkError("Failed to send message: \(error.localizedDescription)") + } + } +} + +@available(iOS 16.0, *) +struct ReadMessagesIntent: AppIntent { + static var title: LocalizedStringResource = "Read Messages" + static var description = IntentDescription("Read recent messages from a chat channel") + static var isDiscoverable = true + static var openAppWhenRun = false + + @Parameter(title: "Channel ID") + var channelId: String? + + @Parameter(title: "Number of Messages", default: "5") + var limit: String? + + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let channelId = channelId, !channelId.isEmpty else { + throw AppIntentError.requiredParameter("Channel ID") + } + + let limitValue = Int(limit ?? "5") ?? 5 + let safeLimit = max(1, min(20, limitValue)) + + do { + let messages = try await NetworkService.shared.getMessages( + channelId: channelId, + offset: 0, + take: safeLimit + ) + + if messages.isEmpty { + return .result( + value: "No messages found in channel \(channelId)", + dialog: "No messages found" + ) + } + + let formattedMessages = messages.compactMap { message -> String? in + let senderName = message.sender?.account?.name ?? "Unknown" + let content = message.content ?? "" + return "\(senderName): \(content)" + }.joined(separator: "\n") + + return .result( + value: formattedMessages, + dialog: "Found \(messages.count) messages" + ) + } catch { + throw AppIntentError.networkError("Failed to read messages: \(error.localizedDescription)") + } + } +} + +@available(iOS 16.0, *) +struct CheckUnreadChatsIntent: AppIntent { + static var title: LocalizedStringResource = "Check Unread Chats" + static var description = IntentDescription("Check number of unread chat messages") + static var isDiscoverable = true + static var openAppWhenRun = false + + func perform() async throws -> some IntentResult & ProvidesDialog { + do { + let count = try await NetworkService.shared.getUnreadChatsCount() + + let message: String + if count == 0 { + message = "You have no unread messages" + } else if count == 1 { + message = "You have 1 unread message" + } else { + message = "You have \(count) unread messages" + } + + return .result( + value: message, + dialog: "\(message)" + ) + } catch { + throw AppIntentError.networkError("Failed to check unread chats: \(error.localizedDescription)") + } + } +} + +@available(iOS 16.0, *) +struct MarkNotificationsReadIntent: AppIntent { + static var title: LocalizedStringResource = "Mark Notifications Read" + static var description = IntentDescription("Mark all notifications as read") + static var isDiscoverable = true + static var openAppWhenRun = false + + func perform() async throws -> some IntentResult & ProvidesDialog { + do { + try await NetworkService.shared.markNotificationsRead() + + return .result( + value: "All notifications marked as read", + dialog: "All notifications marked as read" + ) + } catch { + throw AppIntentError.networkError("Failed to mark notifications: \(error.localizedDescription)") + } + } +} + +enum AppIntentError: Error, CustomLocalizedStringResourceConvertible { + case requiredParameter(String) + case networkError(String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .requiredParameter(let param): + return "\(param) is required" + case .networkError(let message): + return "Network error: \(message)" + } + } +} diff --git a/ios/Runner/Services/DeepLinkHandler.swift b/ios/Runner/Services/DeepLinkHandler.swift new file mode 100644 index 00000000..d3ed642c --- /dev/null +++ b/ios/Runner/Services/DeepLinkHandler.swift @@ -0,0 +1,103 @@ +// +// DeepLinkHandler.swift +// Runner +// +// Created by LittleSheep on 2026/1/16. +// + +import Foundation +import UIKit + +final class DeepLinkHandler { + static let shared = DeepLinkHandler() + + private init() {} + + func handle(url: URL) -> Bool { + guard url.scheme == SharedConstants.urlScheme else { + return false + } + + let host = url.host ?? "" + let path = url.path + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + + switch host { + case "chat": + if let channelId = url.pathComponents.count > 1 ? url.pathComponents[1] : nil { + openUrl("solian://chat/\(channelId)") + return true + } + + case "posts": + if let postId = url.pathComponents.count > 1 ? url.pathComponents[1] : nil { + openUrl("solian://posts/\(postId)") + return true + } + + case "search": + if let query = queryItems?.first(where: { $0.name == "query" })?.value { + let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + openUrl("solian://search?q=\(encodedQuery)") + return true + } + + case "notifications": + openUrl("solian://notifications") + return true + + case "compose": + openUrl("solian://compose") + return true + + default: + if path.hasPrefix("/chat/") { + let channelId = path.replacingOccurrences(of: "/chat/", with: "") + openUrl("solian://chat/\(channelId)") + return true + } + if path.hasPrefix("/posts/") { + let postId = path.replacingOccurrences(of: "/posts/", with: "") + openUrl("solian://posts/\(postId)") + return true + } + if path.hasPrefix("/search") { + let query = queryItems?.first(where: { $0.name == "q" })?.value ?? "" + let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + openUrl("solian://search?q=\(encodedQuery)") + return true + } + if path == "/notifications" { + openUrl("solian://notifications") + return true + } + if path.hasPrefix("/compose") || path == "/compose" { + openUrl("solian://compose") + return true + } + if path.hasPrefix("/dashboard") { + openUrl("solian://dashboard") + return true + } + } + + return false + } + + private func openUrl(_ urlString: String) { + guard let url = URL(string: urlString) else { + print("[DeepLinkHandler] Invalid URL: \(urlString)") + return + } + + DispatchQueue.main.async { + UIApplication.shared.open(url) { success in + if success { + print("[DeepLinkHandler] Opened URL: \(urlString)") + } else { + print("[DeepLinkHandler] Failed to open URL: \(urlString)") + } + } + } + } +} diff --git a/ios/Runner/Services/NetworkService.swift b/ios/Runner/Services/NetworkService.swift new file mode 100644 index 00000000..dcc1f56d --- /dev/null +++ b/ios/Runner/Services/NetworkService.swift @@ -0,0 +1,164 @@ +// +// NetworkService.swift +// Runner +// +// Created by LittleSheep on 2026/1/16. +// + +import Foundation + +final class NetworkService { + static let shared = NetworkService() + + private let session: URLSession + private let decoder: JSONDecoder + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 10 + config.timeoutIntervalForResource = 30 + self.session = URLSession(configuration: config) + + self.decoder = JSONDecoder() + self.decoder.keyDecodingStrategy = .convertFromSnakeCase + } + + private var baseUrl: String { + UserDefaults.shared.getServerUrl() + } + + private var authHeaders: [String: String] { + var headers = [ + "Accept": "application/json", + "Content-Type": "application/json" + ] + if let token = UserDefaults.shared.getAuthToken() { + headers["Authorization"] = "AtField \(token)" + } + return headers + } + + func getNotificationCount() async throws -> Int { + let url = try buildUrl(path: SharedConstants.API.notificationsCount) + let response: NotificationCountResponse = try await get(url: url) + return response.count + } + + func markNotificationsRead() async throws { + let url = try buildUrl(path: SharedConstants.API.notificationsMarkRead) + let _: EmptyResponse = try await post(url: url, body: nil) + } + + func getUnreadChatsCount() async throws -> Int { + let url = try buildUrl(path: SharedConstants.API.unreadChats) + let response: UnreadChatsResponse = try await get(url: url) + return response.unreadCount + } + + func getMessages(channelId: String, offset: Int = 0, take: Int = 5) async throws -> [MessageResponse] { + let path = String(format: SharedConstants.API.messages, channelId) + let url = try buildUrl(path: path, queryItems: [ + URLQueryItem(name: "offset", value: String(offset)), + URLQueryItem(name: "take", value: String(take)) + ]) + let response: MessagesResponse = try await get(url: url) + return response.messages + } + + func sendMessage(channelId: String, content: String) async throws { + let path = String(format: SharedConstants.API.sendMessage, channelId) + let url = try buildUrl(path: path) + let body = SendMessageBody(content: content, nonce: generateNonce()) + let _: EmptyResponse = try await post(url: url, body: body) + } + + private func buildUrl(path: String, queryItems: [URLQueryItem]? = nil) throws -> URL { + var components = URLComponents(string: baseUrl + path) + if let queryItems = queryItems, !queryItems.isEmpty { + components?.queryItems = queryItems + } + guard let url = components?.url else { + throw NetworkError.invalidUrl + } + return url + } + + private func get(url: URL) async throws -> T { + var request = URLRequest(url: url) + authHeaders.forEach { request.setValue($1, forHTTPHeaderField: $0) } + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + return try decoder.decode(T.self, from: data) + } + + private func post(url: URL, body: B?) async throws -> T { + var request = URLRequest(url: url) + request.httpMethod = "POST" + authHeaders.forEach { request.setValue($1, forHTTPHeaderField: $0) } + + if let body = body { + request.httpBody = try JSONEncoder().encode(body) + } + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + if T.self == EmptyResponse.self { + return EmptyResponse() as! T + } + + return try decoder.decode(T.self, from: data) + } + + private func validateResponse(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse + } + guard (200...299).contains(httpResponse.statusCode) else { + throw NetworkError.httpError(statusCode: httpResponse.statusCode) + } + } + + private func generateNonce() -> String { + "\(Date().timeIntervalSince1970)" + } +} + +enum NetworkError: Error { + case invalidUrl + case invalidResponse + case httpError(statusCode: Int) +} + +private struct NotificationCountResponse: Decodable { + let count: Int +} + +private struct UnreadChatsResponse: Decodable { + let unreadCount: Int +} + +private struct MessagesResponse: Decodable { + let messages: [MessageResponse] +} + +private struct MessageResponse: Decodable { + let content: String? + let sender: SenderResponse? + + struct SenderResponse: Decodable { + let account: AccountResponse? + + struct AccountResponse: Decodable { + let name: String? + } + } +} + +private struct SendMessageBody: Encodable { + let content: String + let nonce: String +} + +private struct EmptyResponse: Decodable {} diff --git a/ios/Runner/Services/SharedConstants.swift b/ios/Runner/Services/SharedConstants.swift new file mode 100644 index 00000000..57ef1986 --- /dev/null +++ b/ios/Runner/Services/SharedConstants.swift @@ -0,0 +1,45 @@ +// +// SharedConstants.swift +// Runner +// +// Created by LittleSheep on 2026/1/16. +// + +import Foundation + +enum SharedConstants { + static let appGroupId = "group.solsynth.solian" + static let urlScheme = "solian" + static let serverUrlKey = "flutter.app_server_url" + static let tokenKey = "flutter.dyn_user_tk" + static let defaultServerUrl = "https://api.solian.app" + + enum API { + static let notificationsCount = "/ring/notifications/count" + static let notificationsMarkRead = "/ring/notifications/all/read" + static let unreadChats = "/messager/chat/unread" + static let messages = "/messager/chat/%@/messages" + static let sendMessage = "/messager/chat/%@/messages" + } +} + +extension UserDefaults { + static let shared: UserDefaults = { + UserDefaults(suiteName: SharedConstants.appGroupId) ?? UserDefaults.standard + }() + + func getServerUrl() -> String { + string(forKey: SharedConstants.serverUrlKey) ?? SharedConstants.defaultServerUrl + } + + func getAuthToken() -> String? { + guard let jsonString = string(forKey: SharedConstants.tokenKey), + let data = jsonString.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: data), + let jsonDict = jsonObject as? [String: Any], + let token = jsonDict["token"] as? String else { + return nil + } + return token + } +} diff --git a/lib/main.dart b/lib/main.dart index e6ad4610..240c9a2e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,7 +25,6 @@ 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/services/quick_actions.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; @@ -103,17 +102,6 @@ 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", - ); - } - try { talker.info("[QuickActions] Initializing Quick Actions service..."); final quickActionsService = QuickActionsService(); diff --git a/lib/services/app_intents.dart b/lib/services/app_intents.dart deleted file mode 100644 index a3a09a5b..00000000 --- a/lib/services/app_intents.dart +++ /dev/null @@ -1 +0,0 @@ -export 'app_intents/ios.dart'; diff --git a/lib/services/app_intents/ios.dart b/lib/services/app_intents/ios.dart deleted file mode 100644 index c555a6c9..00000000 --- a/lib/services/app_intents/ios.dart +++ /dev/null @@ -1,778 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'package:firebase_analytics/firebase_analytics.dart'; -import 'package:flutter/foundation.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/services/event_bus.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 (kIsWeb || !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, - ); - - // Message Intents - await _client!.registerIntent( - AppIntentBuilder() - .identifier('send_message') - .title('Send Message') - .description('Send a message to a chat channel') - .parameter( - const AppIntentParameter( - name: 'channelId', - title: 'Channel ID', - type: AppIntentParameterType.string, - isOptional: false, - ), - ) - .parameter( - const AppIntentParameter( - name: 'content', - title: 'Message Content', - type: AppIntentParameterType.string, - isOptional: false, - ), - ) - .build(), - _handleSendMessageIntent, - ); - - await _client!.registerIntent( - AppIntentBuilder() - .identifier('read_messages') - .title('Read Messages') - .description('Read recent messages from a chat channel') - .parameter( - const AppIntentParameter( - name: 'channelId', - title: 'Channel ID', - type: AppIntentParameterType.string, - isOptional: false, - ), - ) - .parameter( - const AppIntentParameter( - name: 'limit', - title: 'Number of Messages', - type: AppIntentParameterType.string, - isOptional: true, - ), - ) - .build(), - _handleReadMessagesIntent, - ); - - await _client!.registerIntent( - AppIntentBuilder() - .identifier('check_unread_chats') - .title('Check Unread Chats') - .description('Check number of unread chat messages') - .build(), - _handleCheckUnreadChatsIntent, - ); - - await _client!.registerIntent( - AppIntentBuilder() - .identifier('mark_notifications_read') - .title('Mark Notifications Read') - .description('Mark all notifications as read') - .build(), - _handleMarkNotificationsReadIntent, - ); - } - - 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'); - - eventBus.fire(ShowComposeSheetEvent()); - - 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'); - - eventBus.fire(ShowComposeSheetEvent()); - - 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'); - } - } - - Future _handleSendMessageIntent( - Map parameters, - ) async { - try { - final channelId = parameters['channelId'] as String?; - final content = parameters['content'] as String?; - - if (channelId == null) { - throw ArgumentError('channelId is required'); - } - if (content == null || content.isEmpty) { - throw ArgumentError('content is required'); - } - - talker.info('[AppIntents] Sending message to $channelId: $content'); - - if (_dio == null) { - return AppIntentResult.failed(error: 'API client not initialized'); - } - - try { - final nonce = _generateNonce(); - - await _dio!.post( - '/messager/chat/$channelId/messages', - data: {'content': content, 'nonce': nonce}, - ); - - talker.info('[AppIntents] Message sent successfully'); - return AppIntentResult.successful( - value: 'Message sent to channel $channelId', - needsToContinueInApp: false, - ); - } on DioException catch (e) { - talker.error('[AppIntents] API error sending message', e); - return AppIntentResult.failed( - error: 'Failed to send message: ${e.message ?? 'Network error'}', - ); - } - } catch (e, stack) { - talker.error('[AppIntents] Failed to send message', e, stack); - return AppIntentResult.failed(error: 'Failed to send message: $e'); - } - } - - Future _handleReadMessagesIntent( - Map parameters, - ) async { - try { - final channelId = parameters['channelId'] as String?; - final limitParam = parameters['limit'] as String?; - final limit = limitParam != null ? int.tryParse(limitParam) ?? 5 : 5; - - if (channelId == null) { - throw ArgumentError('channelId is required'); - } - if (limit < 1 || limit > 20) { - return AppIntentResult.failed(error: 'limit must be between 1 and 20'); - } - - talker.info('[AppIntents] Reading $limit messages from $channelId'); - - if (_dio == null) { - return AppIntentResult.failed(error: 'API client not initialized'); - } - - try { - final response = await _dio!.get( - '/messager/chat/$channelId/messages', - queryParameters: {'offset': 0, 'take': limit}, - ); - - final messages = response.data as List; - if (messages.isEmpty) { - return AppIntentResult.successful( - value: 'No messages found in channel $channelId', - needsToContinueInApp: false, - ); - } - - final formattedMessages = messages - .map((msg) { - final senderName = - msg['sender']?['account']?['name'] ?? 'Unknown'; - final messageContent = msg['content'] ?? ''; - return '$senderName: $messageContent'; - }) - .join('\n'); - - talker.info('[AppIntents] Retrieved ${messages.length} messages'); - return AppIntentResult.successful( - value: formattedMessages, - needsToContinueInApp: false, - ); - } on DioException catch (e) { - talker.error('[AppIntents] API error reading messages', e); - return AppIntentResult.failed( - error: 'Failed to read messages: ${e.message ?? 'Network error'}', - ); - } - } catch (e, stack) { - talker.error('[AppIntents] Failed to read messages', e, stack); - return AppIntentResult.failed(error: 'Failed to read messages: $e'); - } - } - - String _generateNonce() { - return '${DateTime.now().millisecondsSinceEpoch}-${DateTime.now().microsecondsSinceEpoch}'; - } - - void _logDonation(String eventName, Map parameters) { - try { - FirebaseAnalytics.instance.logEvent( - name: eventName, - parameters: parameters.isEmpty ? null : parameters, - ); - } catch (e) { - talker.warning('[AppIntents] Failed to log analytics: $e'); - } - } - - Future _handleCheckUnreadChatsIntent( - Map parameters, - ) async { - try { - talker.info('[AppIntents] Checking unread chats count'); - - if (_dio == null) { - return AppIntentResult.failed(error: 'API client not initialized'); - } - - try { - final response = await _dio!.get('/messager/chat/unread'); - final count = response.data as int? ?? 0; - - String message; - if (count == 0) { - message = 'You have no unread messages'; - } else if (count == 1) { - message = 'You have 1 unread message'; - } else { - message = 'You have $count unread messages'; - } - - return AppIntentResult.successful( - value: message, - needsToContinueInApp: false, - ); - } on DioException catch (e) { - talker.error('[AppIntents] API error checking unread chats', e); - return AppIntentResult.failed( - error: - 'Failed to fetch unread chats: ${e.message ?? 'Network error'}', - ); - } - } catch (e, stack) { - talker.error('[AppIntents] Failed to check unread chats', e, stack); - return AppIntentResult.failed(error: 'Failed to check unread chats: $e'); - } - } - - Future _handleMarkNotificationsReadIntent( - Map parameters, - ) async { - try { - talker.info('[AppIntents] Marking all notifications as read'); - - if (_dio == null) { - return AppIntentResult.failed(error: 'API client not initialized'); - } - - try { - await _dio!.post('/ring/notifications/all/read'); - - talker.info('[AppIntents] Notifications marked as read'); - return AppIntentResult.successful( - value: 'All notifications marked as read', - needsToContinueInApp: false, - ); - } on DioException catch (e) { - talker.error('[AppIntents] API error marking notifications read', e); - return AppIntentResult.failed( - error: - 'Failed to mark notifications: ${e.message ?? 'Network error'}', - ); - } - } catch (e, stack) { - talker.error('[AppIntents] Failed to mark notifications read', e, stack); - return AppIntentResult.failed( - error: 'Failed to mark notifications read: $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}, - ); - _logDonation('open_chat', {'channel_id': channelId}); - 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}, - ); - _logDonation('open_post', {'post_id': postId}); - 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}, - ); - _logDonation('open_compose', {}); - 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}, - ); - _logDonation('search_content', {'query': query}); - 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}, - ); - _logDonation('check_notifications', {}); - talker.info('[AppIntents] Donated check_notifications intent'); - } catch (e, stack) { - talker.error( - '[AppIntents] Failed to donate check_notifications', - e, - stack, - ); - } - } - - Future donateSendMessage(String channelId, String content) async { - if (!_initialized) return; - try { - await FlutterAppIntentsService.donateIntentWithMetadata( - 'send_message', - {'channelId': channelId, 'content': content}, - relevanceScore: 0.8, - context: {'feature': 'chat', 'userAction': true}, - ); - _logDonation('send_message', {'channel_id': channelId}); - talker.info('[AppIntents] Donated send_message intent'); - } catch (e, stack) { - talker.error('[AppIntents] Failed to donate send_message', e, stack); - } - } - - Future donateReadMessages(String channelId) async { - if (!_initialized) return; - try { - await FlutterAppIntentsService.donateIntentWithMetadata( - 'read_messages', - {'channelId': channelId}, - relevanceScore: 0.7, - context: {'feature': 'chat', 'userAction': true}, - ); - _logDonation('read_messages', {'channel_id': channelId}); - talker.info('[AppIntents] Donated read_messages intent'); - } catch (e, stack) { - talker.error('[AppIntents] Failed to donate read_messages', e, stack); - } - } - - Future donateCheckUnreadChats() async { - if (!_initialized) return; - try { - await FlutterAppIntentsService.donateIntentWithMetadata( - 'check_unread_chats', - {}, - relevanceScore: 0.7, - context: {'feature': 'chat', 'userAction': true}, - ); - _logDonation('check_unread_chats', {}); - talker.info('[AppIntents] Donated check_unread_chats intent'); - } catch (e, stack) { - talker.error( - '[AppIntents] Failed to donate check_unread_chats', - e, - stack, - ); - } - } - - Future donateMarkNotificationsRead() async { - if (!_initialized) return; - try { - await FlutterAppIntentsService.donateIntentWithMetadata( - 'mark_notifications_read', - {}, - relevanceScore: 0.6, - context: {'feature': 'notifications', 'userAction': true}, - ); - _logDonation('mark_notifications_read', {}); - talker.info('[AppIntents] Donated mark_notifications_read intent'); - } catch (e, stack) { - talker.error( - '[AppIntents] Failed to donate mark_notifications_read', - e, - stack, - ); - } - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 997461cf..303ec00b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,7 +61,6 @@ dependencies: media_kit_video: ^2.0.1 media_kit_libs_video: ^1.0.7 flutter_cache_manager: ^3.4.1 - email_validator: ^3.0.0 easy_localization: ^3.0.8 flutter_inappwebview: ^6.1.5 @@ -108,7 +107,6 @@ dependencies: qr_flutter: ^4.1.0 flutter_otp_text_field: ^1.5.1+1 flutter_staggered_grid_view: ^0.7.0 - flutter_popup_card: ^0.0.6 timezone: ^0.10.1 flutter_timezone: ^5.0.1 @@ -174,7 +172,6 @@ dependencies: shake: ^3.0.0 in_app_review: ^2.0.11 snow_fall_animation: ^0.0.1+3 - flutter_app_intents: ^0.7.0 video_thumbnail: ^0.5.6 just_audio: ^0.10.5 audio_session: ^0.2.2 @@ -279,4 +276,3 @@ msix_config: protocol_activation: solian, https app_uri_handler_hosts: solian.app capabilities: internetClientServer, location, microphone, webcam -