diff --git a/Sources/XuqmSDK/Core/XuqmSDK.swift b/Sources/XuqmSDK/Core/XuqmSDK.swift
index 8a45d39..44af928 100644
--- a/Sources/XuqmSDK/Core/XuqmSDK.swift
+++ b/Sources/XuqmSDK/Core/XuqmSDK.swift
@@ -1,13 +1,22 @@
import Foundation
@MainActor
-public final class XuqmSDK {
+public final class XuqmSDK: NSObject {
public static let shared = XuqmSDK()
private(set) var config: SDKConfig?
private(set) var tokenStore: TokenStore?
- private init() {}
+ public private(set) var currentUserId: String?
+ public var onUserSigExpired: (() -> Void)?
+
+ private var userSig: String?
+ private var userSigTimer: Timer?
+ private var cachedDeviceToken: String?
+
+ private override init() {
+ super.init()
+ }
public func initialize(config: SDKConfig) {
self.config = config
@@ -21,4 +30,93 @@ public final class XuqmSDK {
}
return config
}
+
+ public func login(userId: String, userSig: String) async {
+ self.currentUserId = userId
+ self.userSig = userSig
+ startUserSigExpirationTimer(userSig: userSig)
+
+ do {
+ try await ImSDK.shared.loginWithUserSig(userId, userSig)
+ } catch {
+ // IM login failed; silently ignored per facade pattern
+ }
+
+ if let cachedDeviceToken {
+ do {
+ try await PushSDK.shared.registerToken(cachedDeviceToken, userId: userId)
+ } catch {
+ // Push registration failed
+ }
+ }
+ }
+
+ public func logout() async {
+ userSigTimer?.invalidate()
+ userSigTimer = nil
+
+ ImSDK.shared.disconnect()
+
+ if let userId = currentUserId {
+ do {
+ try await PushSDK.shared.unregisterToken(userId: userId)
+ } catch {
+ // Push unregistration failed
+ }
+ }
+
+ tokenStore?.clear()
+ currentUserId = nil
+ userSig = nil
+ cachedDeviceToken = nil
+ }
+
+ public func registerDeviceToken(_ deviceToken: Data) {
+ let token = deviceToken.map { String(format: "%02x", $0) }.joined()
+ self.cachedDeviceToken = token
+ Task { @MainActor in
+ if let userId = self.currentUserId {
+ try? await PushSDK.shared.registerToken(token, userId: userId)
+ }
+ }
+ }
+
+ private func startUserSigExpirationTimer(userSig: String) {
+ userSigTimer?.invalidate()
+ guard let expDate = extractExpirationDate(from: userSig) else { return }
+ let interval = expDate.timeIntervalSinceNow - 300 // 5 minutes before expiry
+ guard interval > 0 else {
+ onUserSigExpired?()
+ return
+ }
+ userSigTimer = Timer.scheduledTimer(
+ timeInterval: interval,
+ target: self,
+ selector: #selector(userSigDidExpire),
+ userInfo: nil,
+ repeats: false
+ )
+ }
+
+ @objc private func userSigDidExpire() {
+ onUserSigExpired?()
+ }
+
+ private func extractExpirationDate(from userSig: String) -> Date? {
+ let parts = userSig.split(separator: ".")
+ guard parts.count >= 2 else { return nil }
+ var base64 = String(parts[1])
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+ let padding = 4 - base64.count % 4
+ if padding != 4 {
+ base64 += String(repeating: "=", count: padding)
+ }
+ guard let data = Data(base64Encoded: base64),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let exp = json["exp"] as? TimeInterval else {
+ return nil
+ }
+ return Date(timeIntervalSince1970: exp)
+ }
}
diff --git a/Sources/XuqmSDK/IM/ImSDK.swift b/Sources/XuqmSDK/IM/ImSDK.swift
index d2f5063..dacafa7 100644
--- a/Sources/XuqmSDK/IM/ImSDK.swift
+++ b/Sources/XuqmSDK/IM/ImSDK.swift
@@ -8,8 +8,34 @@ public final class ImSDK {
private weak var delegate: ImEventDelegate?
private var currentUserId: String?
+ public private(set) var connectionState: ImConnectionState = .disconnected
+ private var connectionStateListeners: [(ImConnectionState) -> Void] = []
+
private init() {}
+ public func addConnectionStateListener(_ listener: @escaping (ImConnectionState) -> Void) {
+ connectionStateListeners.append(listener)
+ }
+
+ private func updateConnectionState(_ state: ImConnectionState) {
+ connectionState = state
+ for listener in connectionStateListeners {
+ listener(state)
+ }
+ }
+
+ public func loginWithUserSig(_ userId: String, _ userSig: String) async throws {
+ let config = XuqmSDK.shared.requireConfig()
+ currentUserId = userId
+ XuqmSDK.shared.tokenStore?.save(userSig)
+ client?.disconnect()
+ client = ImClient(token: userSig, appId: config.appId)
+ client?.setCurrentUserId(userId)
+ client?.delegate = self
+ updateConnectionState(.connecting)
+ client?.connect()
+ }
+
public func login(userId: String, nickname: String? = nil, avatar: String? = nil) async throws {
let config = XuqmSDK.shared.requireConfig()
@@ -30,7 +56,8 @@ public final class ImSDK {
client?.disconnect()
client = ImClient(token: res.token, appId: config.appId)
client?.setCurrentUserId(userId)
- client?.delegate = delegate
+ client?.delegate = self
+ updateConnectionState(.connecting)
client?.connect()
}
@@ -57,13 +84,14 @@ public final class ImSDK {
client?.disconnect()
client = ImClient(token: res.imToken, appId: config.appId)
client?.setCurrentUserId(res.profile.userId)
- client?.delegate = delegate
+ client?.delegate = self
+ updateConnectionState(.connecting)
client?.connect()
}
public func setDelegate(_ delegate: ImEventDelegate) {
self.delegate = delegate
- client?.delegate = delegate
+ client?.delegate = self
}
public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage {
@@ -818,6 +846,7 @@ public final class ImSDK {
public func disconnect() {
client?.disconnect()
client = nil
+ updateConnectionState(.disconnected)
}
private func fetchHistoryInternal(
@@ -870,3 +899,37 @@ public final class ImSDK {
return formatter.string(from: date)
}
}
+
+@MainActor
+extension ImSDK: ImEventDelegate {
+ public func imClientDidConnect() {
+ updateConnectionState(.connected)
+ delegate?.imClientDidConnect()
+ }
+
+ public func imClientDidDisconnect(reason: String?) {
+ updateConnectionState(.disconnected)
+ delegate?.imClientDidDisconnect(reason: reason)
+ }
+
+ public func imClientDidReceiveMessage(_ message: ImMessage) {
+ delegate?.imClientDidReceiveMessage(message)
+ }
+
+ public func imClientDidReceiveGroupMessage(_ message: ImMessage) {
+ delegate?.imClientDidReceiveGroupMessage(message)
+ }
+
+ public func imClientDidReadMessage(_ message: ImMessage) {
+ delegate?.imClientDidReadMessage(message)
+ }
+
+ public func imClientDidReceiveRevokedMessage(_ message: ImMessage) {
+ delegate?.imClientDidReceiveRevokedMessage(message)
+ }
+
+ public func imClientDidError(_ error: String) {
+ updateConnectionState(.disconnected)
+ delegate?.imClientDidError(error)
+ }
+}
diff --git a/Sources/XuqmSDK/IM/ImTypes.swift b/Sources/XuqmSDK/IM/ImTypes.swift
index 5a7e847..ec791eb 100644
--- a/Sources/XuqmSDK/IM/ImTypes.swift
+++ b/Sources/XuqmSDK/IM/ImTypes.swift
@@ -1,5 +1,11 @@
import Foundation
+public enum ImConnectionState: String, Sendable {
+ case connected
+ case disconnected
+ case connecting
+}
+
public enum ChatType: String, Codable, Sendable {
case single = "SINGLE"
case group = "GROUP"
diff --git a/Sources/XuqmSDK/Push/PushSDK.swift b/Sources/XuqmSDK/Push/PushSDK.swift
index 24e1b3a..43f6235 100644
--- a/Sources/XuqmSDK/Push/PushSDK.swift
+++ b/Sources/XuqmSDK/Push/PushSDK.swift
@@ -49,6 +49,31 @@ public final class PushSDK: NSObject, UNUserNotificationCenterDelegate {
)
}
+ public func registerFcmToken(_ token: String, userId: String) async throws {
+ try await registerToken(token, userId: userId, vendor: .fcm)
+ }
+
+ public var isFcmAvailable: Bool {
+ #if canImport(FirebaseMessaging)
+ return true
+ #else
+ return false
+ #endif
+ }
+
+ public func unregisterToken(userId: String, vendor: PushVendor = .apns) async throws {
+ let config = XuqmSDK.shared.requireConfig()
+ let _: EmptyResponse = try await ApiClient.shared.request(
+ path: "/api/push/unregister",
+ method: "POST",
+ queryItems: [
+ URLQueryItem(name: "appId", value: config.appId),
+ URLQueryItem(name: "userId", value: userId),
+ URLQueryItem(name: "vendor", value: vendor.rawValue),
+ ]
+ )
+ }
+
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
diff --git a/TEST_REPORT.md b/TEST_REPORT.md
new file mode 100644
index 0000000..d836230
--- /dev/null
+++ b/TEST_REPORT.md
@@ -0,0 +1,130 @@
+# iOS SDK 测试报告
+
+> **生成时间**: 2026-05-01
+> **版本**: 0.1.0
+> **测试状态**: 部分功能待测试
+
+---
+
+## 测试环境
+
+| 项目 | 版本/配置 |
+|------|-----------|
+| Xcode | 16.0 |
+| iOS 模拟器 | iPhone 16 Pro(iOS 18.0) |
+| Swift | 5.9+ |
+| Swift Tools Version | 5.9 |
+| 最低 iOS 版本 | iOS 14 |
+
+---
+
+## 测试用例清单
+
+### TC-01 SDK 初始化测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 SDK 初始化及模块配置 |
+| **测试步骤** | 1. 在 `AppDelegate` 中调用 `XuqmSDK.shared.initialize(config: SDKConfig(appId:appSecret:))`
2. 确认 `XuqmSDK.shared.requireConfig()` 返回有效配置
3. 确认 `ApiClient.shared` 已配置 baseURL 与拦截器 |
+| **预期结果** | 1. 初始化成功,无 fatalError
2. `config.appId` 与传入值一致
3. `TokenStore` 已实例化 |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-02 IM 登录/登出测试(UserSig 模式)
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 UserSig 鉴权登录与登出流程 |
+| **测试步骤** | 1. 调用 `XuqmSDK.shared.login(userId: "user_001", userSig: "xxx")`
2. 观察 `ImSDK.shared.loginWithUserSig` 内部触发 WebSocket 连接
3. 监听 `ImEventDelegate.imClientDidConnect()`
4. 调用 `XuqmSDK.shared.logout()`
5. 确认 `ImSDK.shared.disconnect()` 执行,Push Token 解注册 |
+| **预期结果** | 1. `currentUserId` 被赋值
2. WebSocket 连接成功,状态变为 `.connecting` → `.connected`
3. delegate `imClientDidConnect()` 触发
4. 登出后 `currentUserId` 置 nil
5. `PushSDK.shared.unregisterToken` 被调用 |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-03 单聊消息收发测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证单聊消息发送、接收、历史与已读 |
+| **测试步骤** | 1. 调用 `ImSDK.shared.sendTextMessage(toId:chatType:content:)`
2. 接收方通过 `ImEventDelegate.imClientDidReceiveMessage(_:)` 接收
3. 调用 `fetchHistory(toId:page:size:)`
4. 调用 `markRead(targetId:chatType:)`
5. 发送方重新拉取历史,确认 `status == .read` |
+| **预期结果** | 1. 返回 `ImMessage`,`status` 为 `.sending` 或 `.sent`
2. 接收方实时收到消息,未读角标 +1
3. 历史消息返回 `[ImMessage]`
4. `markRead` HTTP 200,未读清零
5. 发送方消息状态更新为 `.read` |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-04 群聊消息收发测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证群创建、订阅、群消息收发与历史 |
+| **测试步骤** | 1. 调用 `createGroup(name:memberIds:groupType:)`
2. 双端调用 `subscribeGroup(_:)`
3. 发送方调用 `sendTextMessage(toId:chatType:content:)`(chatType=.group)
4. 接收方通过 `imClientDidReceiveGroupMessage(_:)` 接收
5. 双端调用 `fetchGroupHistory(groupId:page:size:)` |
+| **预期结果** | 1. 返回 `ImGroup`,`memberIds` 包含指定用户
2. WebSocket 订阅 `/topic/group/{groupId}` 成功
3. 群消息发送成功
4. 群成员实时收到消息
5. 群历史正确分页 |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-05 连接状态监听测试(新增)
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 `connectionState` 属性与 `addConnectionStateListener` 回调 |
+| **测试步骤** | 1. 添加 `addConnectionStateListener { state in print(state) }`
2. 触发登录,观察状态流转
3. 手动断开网络,观察重连状态
4. 恢复网络,观察恢复为 `.connected`
5. 调用 `disconnect()`,观察 `.disconnected` |
+| **预期结果** | 1. 监听器被加入数组
2. 登录时状态变化:`.disconnected` → `.connecting` → `.connected`
3. 断网后状态变为 `.disconnected` 并触发重连
4. 恢复网络后回到 `.connected`
5. `disconnect()` 后状态为 `.disconnected`,监听器仍保留(不移除) |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-06 Push 设备注册测试(APNs + FCM)
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 APNs 设备 Token 获取、注册与 FCM 备选方案 |
+| **测试步骤** | 1. 在 `AppDelegate` 中调用 `PushSDK.shared.requestAuthorization()`
2. 系统授权后 `UIApplication.shared.registerForRemoteNotifications()`
3. 在 `didRegisterForRemoteNotificationsWithDeviceToken` 中调用 `XuqmSDK.shared.registerDeviceToken(_:)`
4. 确认 `PushSDK.shared.registerToken(token:userId:vendor:)` 调用(vendor=.apns)
5. 若集成 Firebase,验证 `registerFcmToken` 路径 |
+| **预期结果** | 1. `requestAuthorization` 返回 `true`
2. 系统弹窗申请通知权限
3. `cachedDeviceToken` 被保存
4. `/api/push/register` 返回 200
5. FCM 路径返回 vendor=FCM,注册成功 |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-07 版本更新检查测试
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 UpdateSDK 检查 App Store 更新 |
+| **测试步骤** | 1. 调用 `UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)`
2. 若 `needsUpdate=true` 且 `forceUpdate=true`,调用 `openAppStore(url:)`
3. 观察是否能正确跳转到 App Store 或下载页 |
+| **预期结果** | 1. 返回 `AppUpdateInfo`,`platform=IOS`
2. `forceUpdate` 为布尔值,下载链接有效
3. `UIApplication.shared.open` 成功跳转 |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+### TC-08 UserSig 过期检测测试(新增)
+
+| 字段 | 内容 |
+|------|------|
+| **测试目的** | 验证 `XuqmSDK` 解析 UserSig JWT exp 并在过期前触发回调 |
+| **测试步骤** | 1. 生成一个 exp 为当前时间 + 6 分钟的 UserSig JWT
2. 调用 `XuqmSDK.shared.login(userId:userSig:)`
3. 设置 `XuqmSDK.shared.onUserSigExpired = { ... }`
4. 等待 1 分钟,观察 Timer 是否在正确时间点触发
5. 触发后调用 `logout()`,确认 Timer 被 `invalidate` |
+| **预期结果** | 1. `extractExpirationDate` 正确解析 JWT payload
2. `startUserSigExpirationTimer` 创建 `Timer`
3. 到期前 5 分钟触发 `onUserSigExpired?()`
4. 回调在主线程执行
5. `logout()` 后 `userSigTimer` 为 nil,无内存泄漏 |
+| **实际结果** | 待测试 |
+| **通过状态** | ⬜ |
+
+---
+
+## 测试汇总
+
+| 用例编号 | 用例名称 | 状态 |
+|---------|---------|------|
+| TC-01 | SDK 初始化测试 | ⬜ 待测试 |
+| TC-02 | IM 登录/登出测试(UserSig 模式) | ⬜ 待测试 |
+| TC-03 | 单聊消息收发测试 | ⬜ 待测试 |
+| TC-04 | 群聊消息收发测试 | ⬜ 待测试 |
+| TC-05 | 连接状态监听测试 | ⬜ 待测试 |
+| TC-06 | Push 设备注册测试(APNs + FCM) | ⬜ 待测试 |
+| TC-07 | 版本更新检查测试 | ⬜ 待测试 |
+| TC-08 | UserSig 过期检测测试 | ⬜ 待测试 |
diff --git a/XuqmDemo/Package.swift b/XuqmDemo/Package.swift
new file mode 100644
index 0000000..4cf63ed
--- /dev/null
+++ b/XuqmDemo/Package.swift
@@ -0,0 +1,22 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "XuqmDemo",
+ platforms: [.iOS(.v16), .macOS(.v13)],
+ products: [
+ .library(name: "XuqmDemo", targets: ["XuqmDemo"]),
+ ],
+ dependencies: [
+ .package(path: ".."),
+ ],
+ targets: [
+ .target(
+ name: "XuqmDemo",
+ dependencies: [
+ .product(name: "XuqmSDK", package: "XuqmGroup-iOSSDK"),
+ ],
+ path: "Sources"
+ ),
+ ]
+)
diff --git a/XuqmDemo/README.md b/XuqmDemo/README.md
new file mode 100644
index 0000000..8fc6dc9
--- /dev/null
+++ b/XuqmDemo/README.md
@@ -0,0 +1,65 @@
+# XuqmDemo
+
+XuqmGroup iOS SDK 的 SwiftUI 演示应用。
+
+## 功能
+
+- **登录**:使用 demo-service 登录(`user_a` / `123456`)
+- **会话列表**:展示最近会话,支持搜索、置顶、静音、删除
+- **单聊**:与某个用户收发文本消息
+- **更新检查**:检查 App 更新
+- **个人资料**:展示并修改当前用户信息
+
+## 环境
+
+- 演示服务器:`https://dev.xuqinmin.com`
+- App ID:`ak_demo_chat`
+
+## 运行方式
+
+### 方式一:嵌入 Xcode 项目
+
+1. 将 `XuqmDemo/Sources` 下的所有 Swift 文件拖入你的 Xcode iOS App 项目
+2. 确保项目已依赖 `XuqmSDK`(本地 SPM 或源码引用)
+3. 构建并运行
+
+### 方式二:Swift Package Manager(验证编译)
+
+```bash
+cd XuqmDemo
+swift build
+```
+
+> 注:由于 Demo 是 iOS SwiftUI 应用,建议使用 Xcode 打开 `XuqmGroup-iOSSDK` 仓库,然后新建一个 iOS App Target 并将 `XuqmDemo/Sources` 中的文件加入该 Target 进行真机/模拟器运行。
+
+## 项目结构
+
+```
+XuqmDemo/
+ Package.swift
+ Sources/
+ XuqmDemoApp.swift # @main 入口,初始化 SDK 与路由
+ Views/
+ LoginView.swift # 登录页
+ ConversationListView.swift # 会话列表页
+ ChatView.swift # 单聊页
+ UpdateCheckView.swift # 更新检查页
+ ProfileView.swift # 个人资料页
+ ViewModels/
+ AuthViewModel.swift # 登录/登出逻辑
+ ConversationViewModel.swift# 会话列表逻辑
+ ChatViewModel.swift # 聊天逻辑(含 ImEventDelegate 桥接)
+ Models/
+ DemoModels.swift # 路由、状态、辅助函数
+ README.md
+```
+
+## 关键 SDK API 使用
+
+- `XuqmSDK.shared.initialize(config:)` — 初始化 SDK
+- `ImSDK.shared.loginWithDemo(userId:password:)` — Demo 登录
+- `ImSDK.shared.listConversations()` — 获取会话列表
+- `ImSDK.shared.fetchHistory(toId:page:size:)` — 获取历史消息
+- `ImSDK.shared.sendTextMessage(toId:chatType:content:)` — 发送文本消息
+- `ImSDK.shared.getProfile(userId:)` / `updateProfile(...)` — 用户资料
+- `UpdateSDK.shared.checkAppUpdate(currentVersionCode:)` — 检查更新
diff --git a/XuqmDemo/Sources/Models/DemoModels.swift b/XuqmDemo/Sources/Models/DemoModels.swift
new file mode 100644
index 0000000..906be25
--- /dev/null
+++ b/XuqmDemo/Sources/Models/DemoModels.swift
@@ -0,0 +1,111 @@
+import Foundation
+import XuqmSDK
+
+enum AppRoute: Hashable {
+ case chat(targetId: String, targetName: String)
+}
+
+struct DemoUser: Identifiable {
+ let id: String
+ let userId: String
+ let nickname: String
+ let avatar: String?
+}
+
+enum LoginState: Equatable {
+ case idle
+ case loading
+ case success
+ case error(String)
+}
+
+enum ConversationLoadState: Equatable {
+ case idle
+ case loading
+ case loaded
+ case error(String)
+}
+
+struct MessageItem: Identifiable {
+ let id: String
+ let message: ImMessage
+ let isOwn: Bool
+}
+
+func parseMessageText(_ message: ImMessage) -> String {
+ if message.msgType == .text {
+ if let data = message.content.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let text = json["text"] as? String {
+ return text
+ }
+ return message.content
+ }
+ return messagePreview(message)
+}
+
+func messagePreview(_ message: ImMessage) -> String {
+ switch message.msgType {
+ case .image: return "[图片]"
+ case .video: return "[视频]"
+ case .audio: return "[语音]"
+ case .file: return "[文件]"
+ case .location: return "[位置]"
+ case .custom: return "[自定义]"
+ case .richText: return "[富文本]"
+ case .forward: return "[转发]"
+ case .quote: return "[引用]"
+ case .merge: return "[合并转发]"
+ case .callAudio: return "[语音通话]"
+ case .callVideo: return "[视频通话]"
+ case .revoked: return "[消息已撤回]"
+ case .notify: return message.content
+ case .text: return message.content
+ }
+}
+
+func conversationPreview(_ conversation: ConversationData) -> String {
+ switch conversation.lastMsgType?.uppercased() {
+ case "TEXT": return conversation.lastMsgContent ?? ""
+ case "IMAGE": return "[图片]"
+ case "VIDEO": return "[视频]"
+ case "AUDIO": return "[语音]"
+ case "FILE": return "[文件]"
+ case "LOCATION": return "[位置]"
+ case "CUSTOM": return "[自定义]"
+ case "RICH_TEXT": return "[富文本]"
+ case "FORWARD": return "[转发]"
+ case "QUOTE": return "[引用]"
+ case "MERGE": return "[合并转发]"
+ case "CALL_AUDIO": return "[语音通话]"
+ case "CALL_VIDEO": return "[视频通话]"
+ case "REVOKED": return "[消息已撤回]"
+ case "NOTIFY": return conversation.lastMsgContent ?? "[通知]"
+ default: return conversation.lastMsgContent ?? ""
+ }
+}
+
+func statusLabel(_ status: MsgStatus) -> String {
+ switch status {
+ case .sending: return "发送中"
+ case .sent: return "已发送"
+ case .delivered: return "已送达"
+ case .read: return "已读"
+ case .failed: return "发送失败"
+ case .revoked: return "已撤回"
+ }
+}
+
+func formatConversationTime(_ timestamp: Int64) -> String {
+ let date = Date(timeIntervalSince1970: Double(timestamp) / 1000)
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "zh_CN")
+ if Calendar.current.isDateInToday(date) {
+ formatter.dateFormat = "HH:mm"
+ } else if Calendar.current.isDateInYesterday(date) {
+ return "昨天"
+ } else {
+ formatter.dateFormat = "MM-dd"
+ }
+ return formatter.string(from: date)
+}
diff --git a/XuqmDemo/Sources/ViewModels/AuthViewModel.swift b/XuqmDemo/Sources/ViewModels/AuthViewModel.swift
new file mode 100644
index 0000000..36f2736
--- /dev/null
+++ b/XuqmDemo/Sources/ViewModels/AuthViewModel.swift
@@ -0,0 +1,34 @@
+import Foundation
+import XuqmSDK
+
+@MainActor
+final class AuthViewModel: ObservableObject {
+ @Published var state: LoginState = .idle
+ @Published var currentUserId: String = ""
+ @Published var currentNickname: String = ""
+
+ func login(userId: String, password: String) {
+ guard !userId.isEmpty, !password.isEmpty else {
+ state = .error("请输入用户 ID 和密码")
+ return
+ }
+ state = .loading
+ Task {
+ do {
+ try await ImSDK.shared.loginWithDemo(userId: userId, password: password)
+ currentUserId = userId
+ currentNickname = userId
+ state = .success
+ } catch {
+ state = .error(error.localizedDescription)
+ }
+ }
+ }
+
+ func logout() {
+ ImSDK.shared.disconnect()
+ currentUserId = ""
+ currentNickname = ""
+ state = .idle
+ }
+}
diff --git a/XuqmDemo/Sources/ViewModels/ChatViewModel.swift b/XuqmDemo/Sources/ViewModels/ChatViewModel.swift
new file mode 100644
index 0000000..5da0eb0
--- /dev/null
+++ b/XuqmDemo/Sources/ViewModels/ChatViewModel.swift
@@ -0,0 +1,145 @@
+import Foundation
+import XuqmSDK
+
+@MainActor
+final class ChatViewModel: ObservableObject {
+ @Published var messages: [ImMessage] = []
+ @Published var inputText: String = ""
+ @Published var isLoading: Bool = false
+ @Published var errorMessage: String?
+ @Published var connectionStatus: String = "连接中"
+
+ private var targetId: String = ""
+ private var chatType: ChatType = .single
+ private var currentPage = 0
+ private let pageSize = 20
+ private var hasMore = true
+ private var isLoadingMore = false
+
+ private var delegateBridge: ChatEventDelegateBridge?
+
+ func setup(targetId: String, chatType: ChatType) {
+ self.targetId = targetId
+ self.chatType = chatType
+ self.messages = []
+ self.currentPage = 0
+ self.hasMore = true
+ self.isLoadingMore = false
+ self.errorMessage = nil
+
+ let bridge = ChatEventDelegateBridge(viewModel: self)
+ self.delegateBridge = bridge
+ ImSDK.shared.setDelegate(bridge)
+
+ loadHistory()
+ }
+
+ func loadHistory() {
+ guard !isLoading else { return }
+ isLoading = true
+ currentPage = 0
+ hasMore = true
+ Task {
+ do {
+ let history = try await ImSDK.shared.fetchHistory(toId: targetId, page: currentPage, size: pageSize)
+ messages = history
+ hasMore = history.count >= pageSize
+ isLoading = false
+ } catch {
+ errorMessage = error.localizedDescription
+ isLoading = false
+ }
+ }
+ }
+
+ func loadMoreHistory() {
+ guard hasMore, !isLoadingMore, !isLoading else { return }
+ isLoadingMore = true
+ currentPage += 1
+ Task {
+ do {
+ let history = try await ImSDK.shared.fetchHistory(toId: targetId, page: currentPage, size: pageSize)
+ messages.append(contentsOf: history)
+ hasMore = history.count >= pageSize
+ isLoadingMore = false
+ } catch {
+ isLoadingMore = false
+ }
+ }
+ }
+
+ func sendText() {
+ let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return }
+ let message = ImSDK.shared.sendTextMessage(toId: targetId, chatType: chatType, content: text)
+ messages.insert(message, at: 0)
+ inputText = ""
+ }
+
+ func markRead() {
+ Task {
+ try? await ImSDK.shared.markRead(targetId: targetId, chatType: chatType)
+ }
+ }
+
+ // MARK: - Delegate callbacks (called on MainActor via bridge)
+
+ func didReceiveMessage(_ message: ImMessage) {
+ guard message.fromId == targetId || message.toId == targetId else { return }
+ if !messages.contains(where: { $0.id == message.id }) {
+ messages.insert(message, at: 0)
+ }
+ }
+
+ func didConnect() {
+ connectionStatus = "已连接"
+ }
+
+ func didDisconnect(reason: String?) {
+ connectionStatus = reason ?? "已断开"
+ }
+
+ func didError(_ error: String) {
+ connectionStatus = "错误: \(error)"
+ }
+}
+
+// MARK: - ImEventDelegate Bridge
+
+private final class ChatEventDelegateBridge: NSObject, ImEventDelegate {
+ weak var viewModel: ChatViewModel?
+
+ init(viewModel: ChatViewModel) {
+ self.viewModel = viewModel
+ }
+
+ func imClientDidConnect() {
+ Task { @MainActor [weak viewModel] in
+ viewModel?.didConnect()
+ }
+ }
+
+ func imClientDidDisconnect(reason: String?) {
+ Task { @MainActor [weak viewModel] in
+ viewModel?.didDisconnect(reason: reason)
+ }
+ }
+
+ func imClientDidReceiveMessage(_ message: ImMessage) {
+ Task { @MainActor [weak viewModel] in
+ viewModel?.didReceiveMessage(message)
+ }
+ }
+
+ func imClientDidReceiveGroupMessage(_ message: ImMessage) {
+ Task { @MainActor [weak viewModel] in
+ viewModel?.didReceiveMessage(message)
+ }
+ }
+
+ func imClientDidError(_ error: String) {
+ Task { @MainActor [weak viewModel] in
+ viewModel?.didError(error)
+ }
+ }
+}
diff --git a/XuqmDemo/Sources/ViewModels/ConversationViewModel.swift b/XuqmDemo/Sources/ViewModels/ConversationViewModel.swift
new file mode 100644
index 0000000..22a3e7e
--- /dev/null
+++ b/XuqmDemo/Sources/ViewModels/ConversationViewModel.swift
@@ -0,0 +1,76 @@
+import Foundation
+import XuqmSDK
+
+@MainActor
+final class ConversationViewModel: ObservableObject {
+ @Published var conversations: [ConversationData] = []
+ @Published var state: ConversationLoadState = .idle
+ @Published var query: String = ""
+ @Published var totalUnreadCount: Int = 0
+
+ var filteredConversations: [ConversationData] {
+ if query.isEmpty { return conversations }
+ let keyword = query.trimmingCharacters(in: .whitespaces)
+ return conversations.filter {
+ $0.targetId.localizedCaseInsensitiveContains(keyword)
+ || conversationPreview($0).localizedCaseInsensitiveContains(keyword)
+ }
+ }
+
+ func load() {
+ state = .loading
+ Task {
+ do {
+ conversations = try await ImSDK.shared.listConversations()
+ totalUnreadCount = conversations.reduce(0) { $0 + $1.unreadCount }
+ state = .loaded
+ } catch {
+ state = .error(error.localizedDescription)
+ }
+ }
+ }
+
+ func refresh() {
+ Task {
+ do {
+ conversations = try await ImSDK.shared.listConversations()
+ totalUnreadCount = conversations.reduce(0) { $0 + $1.unreadCount }
+ } catch {
+ state = .error(error.localizedDescription)
+ }
+ }
+ }
+
+ func deleteConversation(_ conversation: ConversationData) {
+ Task {
+ do {
+ try await ImSDK.shared.deleteConversation(targetId: conversation.targetId, chatType: conversation.chatType)
+ await refresh()
+ } catch {
+ state = .error(error.localizedDescription)
+ }
+ }
+ }
+
+ func togglePinned(_ conversation: ConversationData) {
+ Task {
+ do {
+ try await ImSDK.shared.setConversationPinned(targetId: conversation.targetId, chatType: conversation.chatType, pinned: !conversation.isPinned)
+ await refresh()
+ } catch {
+ state = .error(error.localizedDescription)
+ }
+ }
+ }
+
+ func toggleMuted(_ conversation: ConversationData) {
+ Task {
+ do {
+ try await ImSDK.shared.setConversationMuted(targetId: conversation.targetId, chatType: conversation.chatType, muted: !conversation.isMuted)
+ await refresh()
+ } catch {
+ state = .error(error.localizedDescription)
+ }
+ }
+ }
+}
diff --git a/XuqmDemo/Sources/Views/ChatView.swift b/XuqmDemo/Sources/Views/ChatView.swift
new file mode 100644
index 0000000..5704a15
--- /dev/null
+++ b/XuqmDemo/Sources/Views/ChatView.swift
@@ -0,0 +1,113 @@
+import SwiftUI
+import XuqmSDK
+
+struct ChatView: View {
+ let targetId: String
+ let targetName: String
+ let currentUserId: String
+
+ @StateObject private var viewModel = ChatViewModel()
+ @State private var scrollToBottom = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if viewModel.connectionStatus != "已连接" {
+ Text(viewModel.connectionStatus)
+ .font(.caption)
+ .foregroundStyle(.white)
+ .padding(.vertical, 4)
+ .frame(maxWidth: .infinity)
+ .background(viewModel.connectionStatus.contains("错误") ? Color.red : Color.orange)
+ }
+
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(spacing: 8) {
+ ForEach(viewModel.messages.reversed(), id: \.id) { message in
+ MessageBubble(message: message, currentUserId: currentUserId)
+ .id(message.id)
+ .rotationEffect(.degrees(180))
+ }
+
+ if viewModel.isLoading {
+ ProgressView()
+ .padding()
+ .rotationEffect(.degrees(180))
+ }
+ }
+ .padding()
+ .rotationEffect(.degrees(180))
+ }
+ .onChange(of: viewModel.messages.count) { _ in
+ if let first = viewModel.messages.first {
+ withAnimation {
+ proxy.scrollTo(first.id, anchor: .bottom)
+ }
+ }
+ }
+ }
+
+ Divider()
+
+ HStack(spacing: 12) {
+ TextField("输入消息…", text: $viewModel.inputText, axis: .vertical)
+ .textFieldStyle(.roundedBorder)
+ .lineLimit(1...4)
+
+ Button {
+ viewModel.sendText()
+ } label: {
+ Image(systemName: "paperplane.fill")
+ .foregroundStyle(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : Color.accentColor)
+ }
+ .disabled(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ .padding()
+ }
+ .navigationTitle(targetName)
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
+ .onAppear {
+ viewModel.setup(targetId: targetId, chatType: .single)
+ viewModel.markRead()
+ }
+ }
+}
+
+struct MessageBubble: View {
+ let message: ImMessage
+ let currentUserId: String
+
+ private var isOwn: Bool {
+ message.fromUserId == currentUserId
+ }
+
+ var body: some View {
+ HStack {
+ if isOwn { Spacer(minLength: 40) }
+
+ VStack(alignment: isOwn ? .trailing : .leading, spacing: 2) {
+ Text(parseMessageText(message))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(isOwn ? Color.accentColor.opacity(0.15) : Color.gray.opacity(0.2))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .foregroundStyle(.primary)
+
+ HStack(spacing: 4) {
+ if isOwn {
+ Text(statusLabel(message.status))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ Text(formatConversationTime(message.createdAt))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ if !isOwn { Spacer(minLength: 40) }
+ }
+ }
+}
diff --git a/XuqmDemo/Sources/Views/ConversationListView.swift b/XuqmDemo/Sources/Views/ConversationListView.swift
new file mode 100644
index 0000000..d5e6eed
--- /dev/null
+++ b/XuqmDemo/Sources/Views/ConversationListView.swift
@@ -0,0 +1,128 @@
+import SwiftUI
+import XuqmSDK
+
+struct ConversationListView: View {
+ @StateObject private var viewModel = ConversationViewModel()
+ @Binding var path: NavigationPath
+
+ var body: some View {
+ List {
+ if viewModel.totalUnreadCount > 0 {
+ Text("总未读 \(viewModel.totalUnreadCount)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ ForEach(viewModel.filteredConversations, id: \.targetId) { conversation in
+ ConversationRow(conversation: conversation)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ path.append(AppRoute.chat(targetId: conversation.targetId, targetName: conversation.targetId))
+ }
+ .swipeActions(edge: .trailing) {
+ Button(role: .destructive) {
+ viewModel.deleteConversation(conversation)
+ } label: {
+ Text("删除")
+ }
+ }
+ .swipeActions(edge: .leading) {
+ Button {
+ viewModel.togglePinned(conversation)
+ } label: {
+ Text(conversation.isPinned ? "取消置顶" : "置顶")
+ }
+ .tint(.orange)
+
+ Button {
+ viewModel.toggleMuted(conversation)
+ } label: {
+ Text(conversation.isMuted ? "取消静音" : "静音")
+ }
+ .tint(.indigo)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .searchable(text: $viewModel.query, prompt: "搜索会话")
+ .navigationTitle("会话")
+ .toolbar {
+ #if os(iOS)
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ viewModel.refresh()
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ }
+ #endif
+ }
+ .refreshable {
+ viewModel.refresh()
+ }
+ .onAppear {
+ if viewModel.conversations.isEmpty {
+ viewModel.load()
+ }
+ }
+ }
+}
+
+struct ConversationRow: View {
+ let conversation: ConversationData
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ZStack {
+ Circle()
+ .fill(Color.accentColor.opacity(0.15))
+ .frame(width: 48, height: 48)
+ Text(String(conversation.targetId.prefix(1).uppercased()))
+ .font(.headline)
+ .foregroundStyle(Color.accentColor)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(conversation.targetId)
+ .font(.subheadline)
+ .fontWeight(.semibold)
+ .lineLimit(1)
+
+ Spacer()
+
+ if conversation.isPinned {
+ Text("置顶")
+ .font(.caption2)
+ .foregroundStyle(.orange)
+ }
+
+ Text(formatConversationTime(conversation.lastMsgTime))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ HStack {
+ Text(conversationPreview(conversation))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ Spacer()
+
+ if conversation.unreadCount > 0 && !conversation.isMuted {
+ Text("\(conversation.unreadCount)")
+ .font(.caption2)
+ .fontWeight(.bold)
+ .foregroundStyle(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.red)
+ .clipShape(Capsule())
+ }
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
diff --git a/XuqmDemo/Sources/Views/LoginView.swift b/XuqmDemo/Sources/Views/LoginView.swift
new file mode 100644
index 0000000..219b93c
--- /dev/null
+++ b/XuqmDemo/Sources/Views/LoginView.swift
@@ -0,0 +1,74 @@
+import SwiftUI
+import XuqmSDK
+
+struct LoginView: View {
+ @ObservedObject var viewModel: AuthViewModel
+ @State private var userId: String = "user_a"
+ @State private var password: String = "123456"
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Spacer()
+
+ Text("XuqmGroup IM")
+ .font(.largeTitle)
+ .fontWeight(.bold)
+
+ Text("iOS Demo")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ Spacer().frame(height: 40)
+
+ VStack(spacing: 16) {
+ TextField("用户 ID", text: $userId)
+ .textContentType(.username)
+ #if os(iOS)
+ .autocapitalization(.none)
+ #endif
+ .padding()
+ .background(Color.gray.opacity(0.15))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+
+ SecureField("密码", text: $password)
+ .textContentType(.password)
+ .padding()
+ .background(Color.gray.opacity(0.15))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+
+ if case .error(let msg) = viewModel.state {
+ Text(msg)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ }
+
+ Button {
+ viewModel.login(userId: userId.trimmingCharacters(in: .whitespaces), password: password)
+ } label: {
+ HStack {
+ if case .loading = viewModel.state {
+ ProgressView()
+ .tint(.white)
+ }
+ Text("登录")
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.accentColor)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .disabled(viewModel.state == .loading || userId.isEmpty || password.isEmpty)
+
+ Spacer()
+
+ Text("演示环境: https://dev.xuqinmin.com")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, 32)
+ }
+}
diff --git a/XuqmDemo/Sources/Views/ProfileView.swift b/XuqmDemo/Sources/Views/ProfileView.swift
new file mode 100644
index 0000000..6e0365f
--- /dev/null
+++ b/XuqmDemo/Sources/Views/ProfileView.swift
@@ -0,0 +1,119 @@
+import SwiftUI
+import XuqmSDK
+
+@MainActor
+final class ProfileViewModel: ObservableObject {
+ @Published var profile: UserProfile?
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+ @Published var nickname: String = ""
+
+ func loadProfile(userId: String) {
+ isLoading = true
+ Task {
+ do {
+ let p = try await ImSDK.shared.getProfile(userId: userId)
+ profile = p
+ nickname = p.nickname ?? userId
+ isLoading = false
+ } catch {
+ errorMessage = error.localizedDescription
+ isLoading = false
+ }
+ }
+ }
+
+ func saveProfile(userId: String) {
+ isLoading = true
+ Task {
+ do {
+ let p = try await ImSDK.shared.updateProfile(userId: userId, nickname: nickname)
+ profile = p
+ isLoading = false
+ } catch {
+ errorMessage = error.localizedDescription
+ isLoading = false
+ }
+ }
+ }
+}
+
+struct ProfileView: View {
+ @ObservedObject var authViewModel: AuthViewModel
+ @StateObject private var viewModel = ProfileViewModel()
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Spacer().frame(height: 40)
+
+ ZStack {
+ Circle()
+ .fill(Color.accentColor.opacity(0.15))
+ .frame(width: 80, height: 80)
+ Text(String(viewModel.nickname.prefix(1).uppercased()))
+ .font(.largeTitle)
+ .foregroundStyle(Color.accentColor)
+ }
+
+ Text(authViewModel.currentUserId)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Divider()
+ .padding(.horizontal)
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("昵称")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("输入昵称", text: $viewModel.nickname)
+ .textFieldStyle(.roundedBorder)
+ }
+ .padding(.horizontal)
+
+ if let error = viewModel.errorMessage {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+
+ Button {
+ viewModel.saveProfile(userId: authViewModel.currentUserId)
+ } label: {
+ HStack {
+ if viewModel.isLoading {
+ ProgressView()
+ }
+ Text("保存")
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.accentColor)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .disabled(viewModel.isLoading || viewModel.nickname.isEmpty)
+ .padding(.horizontal)
+
+ Spacer()
+
+ Button {
+ authViewModel.logout()
+ } label: {
+ Text("退出登录")
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.red.opacity(0.1))
+ .foregroundStyle(.red)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .padding(.horizontal)
+ }
+ .padding()
+ .onAppear {
+ if !authViewModel.currentUserId.isEmpty {
+ viewModel.loadProfile(userId: authViewModel.currentUserId)
+ }
+ }
+ }
+}
diff --git a/XuqmDemo/Sources/Views/UpdateCheckView.swift b/XuqmDemo/Sources/Views/UpdateCheckView.swift
new file mode 100644
index 0000000..49b9d3d
--- /dev/null
+++ b/XuqmDemo/Sources/Views/UpdateCheckView.swift
@@ -0,0 +1,112 @@
+import SwiftUI
+import XuqmSDK
+
+@MainActor
+final class UpdateViewModel: ObservableObject {
+ @Published var isChecking = false
+ @Published var updateInfo: AppUpdateInfo?
+ @Published var message: String?
+
+ func checkUpdate() {
+ isChecking = true
+ message = nil
+ Task {
+ do {
+ let info = try await UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)
+ updateInfo = info
+ if !info.needsUpdate {
+ message = "已是最新版本"
+ }
+ isChecking = false
+ } catch {
+ message = "检查失败: \(error.localizedDescription)"
+ isChecking = false
+ }
+ }
+ }
+
+ func openAppStore() {
+ if let url = updateInfo?.appStoreUrl, !url.isEmpty {
+ UpdateSDK.shared.openAppStore(url: url)
+ }
+ }
+}
+
+struct UpdateCheckView: View {
+ @StateObject private var viewModel = UpdateViewModel()
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Spacer().frame(height: 40)
+
+ Text("版本更新")
+ .font(.title2)
+ .fontWeight(.bold)
+
+ if let info = viewModel.updateInfo, info.needsUpdate {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("发现新版本: \(info.versionName ?? "")")
+ .font(.headline)
+
+ if let changeLog = info.changeLog, !changeLog.isEmpty {
+ Text("更新说明: \(changeLog)")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ }
+
+ if info.forceUpdate == true {
+ Text("强制更新")
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+
+ Button {
+ viewModel.openAppStore()
+ } label: {
+ Text("前往 App Store")
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.accentColor)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ .padding()
+ .background(Color.gray.opacity(0.15))
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .padding(.horizontal)
+ } else {
+ if let msg = viewModel.message {
+ Text(msg)
+ .foregroundStyle(.secondary)
+ }
+
+ Button {
+ viewModel.checkUpdate()
+ } label: {
+ HStack {
+ if viewModel.isChecking {
+ ProgressView()
+ }
+ Text(viewModel.isChecking ? "检查中…" : "检查更新")
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.accentColor)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ .disabled(viewModel.isChecking)
+ .padding(.horizontal)
+ }
+
+ Spacer()
+ }
+ .padding()
+ .onAppear {
+ if viewModel.updateInfo == nil && !viewModel.isChecking {
+ viewModel.checkUpdate()
+ }
+ }
+ }
+}
diff --git a/XuqmDemo/Sources/XuqmDemoApp.swift b/XuqmDemo/Sources/XuqmDemoApp.swift
new file mode 100644
index 0000000..7e52e57
--- /dev/null
+++ b/XuqmDemo/Sources/XuqmDemoApp.swift
@@ -0,0 +1,100 @@
+import SwiftUI
+import XuqmSDK
+
+@main
+struct XuqmDemoApp: App {
+ #if canImport(UIKit)
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+ #endif
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
+
+#if canImport(UIKit)
+class AppDelegate: NSObject, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ initializeSDK()
+ return true
+ }
+}
+#endif
+
+@MainActor
+private func initializeSDK() {
+ let config = SDKConfig(
+ appKey: "ak_demo_chat",
+ appSecret: "demo_secret",
+ debug: true
+ )
+ XuqmSDK.shared.initialize(config: config)
+}
+
+struct ContentView: View {
+ @StateObject private var authViewModel = AuthViewModel()
+ @State private var path = NavigationPath()
+ @State private var selectedTab = 0
+
+ init() {
+ #if !canImport(UIKit)
+ initializeSDK()
+ #endif
+ }
+
+ var body: some View {
+ Group {
+ if case .success = authViewModel.state {
+ MainTabView(authViewModel: authViewModel, path: $path, selectedTab: $selectedTab)
+ } else {
+ NavigationStack {
+ LoginView(viewModel: authViewModel)
+ }
+ }
+ }
+ }
+}
+
+struct MainTabView: View {
+ @ObservedObject var authViewModel: AuthViewModel
+ @Binding var path: NavigationPath
+ @Binding var selectedTab: Int
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ TabView(selection: $selectedTab) {
+ ConversationListView(path: $path)
+ .tabItem {
+ Image(systemName: "message.fill")
+ Text("会话")
+ }
+ .tag(0)
+
+ UpdateCheckView()
+ .tabItem {
+ Image(systemName: "arrow.up.circle.fill")
+ Text("更新")
+ }
+ .tag(1)
+
+ ProfileView(authViewModel: authViewModel)
+ .tabItem {
+ Image(systemName: "person.fill")
+ Text("我的")
+ }
+ .tag(2)
+ }
+ .navigationDestination(for: AppRoute.self) { route in
+ switch route {
+ case .chat(let targetId, let targetName):
+ ChatView(targetId: targetId, targetName: targetName, currentUserId: authViewModel.currentUserId)
+ }
+ }
+ }
+ }
+}