♻️ Use fully native implmentation of app intents on iOS

This commit is contained in:
2026-01-16 21:17:44 +08:00
parent 387d19d85c
commit 4898825124
10 changed files with 710 additions and 1225 deletions

View File

@@ -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<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: "\(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<String> & 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<String> & 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<String> & 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<String> & 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)"
]
)
]
}
}

View File

@@ -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)"
]
)
]
}
}

View File

@@ -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)"
}
}
}

View File

@@ -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)")
}
}
}
}
}

View File

@@ -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<T: Decodable>(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<T: Decodable, B: Encodable>(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 {}

View File

@@ -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
}
}

View File

@@ -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();

View File

@@ -1 +0,0 @@
export 'app_intents/ios.dart';

View File

@@ -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<void> 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<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,
);
// 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<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');
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<AppIntentResult> _handleComposePostIntent(
Map<String, dynamic> 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<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');
}
}
Future<AppIntentResult> _handleSendMessageIntent(
Map<String, dynamic> 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<AppIntentResult> _handleReadMessagesIntent(
Map<String, dynamic> 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<String, Object> parameters) {
try {
FirebaseAnalytics.instance.logEvent(
name: eventName,
parameters: parameters.isEmpty ? null : parameters,
);
} catch (e) {
talker.warning('[AppIntents] Failed to log analytics: $e');
}
}
Future<AppIntentResult> _handleCheckUnreadChatsIntent(
Map<String, dynamic> 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<AppIntentResult> _handleMarkNotificationsReadIntent(
Map<String, dynamic> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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,
);
}
}
}

View File

@@ -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