♻️ Use fully native implmentation of app intents on iOS
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
86
ios/Runner/Services/AppIntentConfiguration.swift
Normal file
86
ios/Runner/Services/AppIntentConfiguration.swift
Normal 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)"
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
296
ios/Runner/Services/AppIntentHandlers.swift
Normal file
296
ios/Runner/Services/AppIntentHandlers.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
103
ios/Runner/Services/DeepLinkHandler.swift
Normal file
103
ios/Runner/Services/DeepLinkHandler.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
ios/Runner/Services/NetworkService.swift
Normal file
164
ios/Runner/Services/NetworkService.swift
Normal 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 {}
|
||||
45
ios/Runner/Services/SharedConstants.swift
Normal file
45
ios/Runner/Services/SharedConstants.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user