feat(sdk): 更新 SDK 设计文档和 API 重构
- 添加 expiresAt 和 refreshUserSig 参数支持自动续签 - 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化 - 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发 - 重构 RN SDK 文档结构,简化安装和使用方式 - 更新统一登录流程,支持 profile 信息传递 - 添加 IM 数据库自动隔离功能 - 修复 Android 群消息聚合问题 - 补充自动化测试验证和错误处理机制
这个提交包含在:
父节点
3249e4b4e5
当前提交
e7067d03cb
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
130
TEST_REPORT.md
普通文件
130
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:))` <br> 2. 确认 `XuqmSDK.shared.requireConfig()` 返回有效配置 <br> 3. 确认 `ApiClient.shared` 已配置 baseURL 与拦截器 |
|
||||
| **预期结果** | 1. 初始化成功,无 fatalError <br> 2. `config.appId` 与传入值一致 <br> 3. `TokenStore` 已实例化 |
|
||||
| **实际结果** | 待测试 |
|
||||
| **通过状态** | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
### TC-02 IM 登录/登出测试(UserSig 模式)
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **测试目的** | 验证 UserSig 鉴权登录与登出流程 |
|
||||
| **测试步骤** | 1. 调用 `XuqmSDK.shared.login(userId: "user_001", userSig: "xxx")` <br> 2. 观察 `ImSDK.shared.loginWithUserSig` 内部触发 WebSocket 连接 <br> 3. 监听 `ImEventDelegate.imClientDidConnect()` <br> 4. 调用 `XuqmSDK.shared.logout()` <br> 5. 确认 `ImSDK.shared.disconnect()` 执行,Push Token 解注册 |
|
||||
| **预期结果** | 1. `currentUserId` 被赋值 <br> 2. WebSocket 连接成功,状态变为 `.connecting` → `.connected` <br> 3. delegate `imClientDidConnect()` 触发 <br> 4. 登出后 `currentUserId` 置 nil <br> 5. `PushSDK.shared.unregisterToken` 被调用 |
|
||||
| **实际结果** | 待测试 |
|
||||
| **通过状态** | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
### TC-03 单聊消息收发测试
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **测试目的** | 验证单聊消息发送、接收、历史与已读 |
|
||||
| **测试步骤** | 1. 调用 `ImSDK.shared.sendTextMessage(toId:chatType:content:)` <br> 2. 接收方通过 `ImEventDelegate.imClientDidReceiveMessage(_:)` 接收 <br> 3. 调用 `fetchHistory(toId:page:size:)` <br> 4. 调用 `markRead(targetId:chatType:)` <br> 5. 发送方重新拉取历史,确认 `status == .read` |
|
||||
| **预期结果** | 1. 返回 `ImMessage`,`status` 为 `.sending` 或 `.sent` <br> 2. 接收方实时收到消息,未读角标 +1 <br> 3. 历史消息返回 `[ImMessage]` <br> 4. `markRead` HTTP 200,未读清零 <br> 5. 发送方消息状态更新为 `.read` |
|
||||
| **实际结果** | 待测试 |
|
||||
| **通过状态** | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
### TC-04 群聊消息收发测试
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **测试目的** | 验证群创建、订阅、群消息收发与历史 |
|
||||
| **测试步骤** | 1. 调用 `createGroup(name:memberIds:groupType:)` <br> 2. 双端调用 `subscribeGroup(_:)` <br> 3. 发送方调用 `sendTextMessage(toId:chatType:content:)`(chatType=.group) <br> 4. 接收方通过 `imClientDidReceiveGroupMessage(_:)` 接收 <br> 5. 双端调用 `fetchGroupHistory(groupId:page:size:)` |
|
||||
| **预期结果** | 1. 返回 `ImGroup`,`memberIds` 包含指定用户 <br> 2. WebSocket 订阅 `/topic/group/{groupId}` 成功 <br> 3. 群消息发送成功 <br> 4. 群成员实时收到消息 <br> 5. 群历史正确分页 |
|
||||
| **实际结果** | 待测试 |
|
||||
| **通过状态** | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
### TC-05 连接状态监听测试(新增)
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **测试目的** | 验证 `connectionState` 属性与 `addConnectionStateListener` 回调 |
|
||||
| **测试步骤** | 1. 添加 `addConnectionStateListener { state in print(state) }` <br> 2. 触发登录,观察状态流转 <br> 3. 手动断开网络,观察重连状态 <br> 4. 恢复网络,观察恢复为 `.connected` <br> 5. 调用 `disconnect()`,观察 `.disconnected` |
|
||||
| **预期结果** | 1. 监听器被加入数组 <br> 2. 登录时状态变化:`.disconnected` → `.connecting` → `.connected` <br> 3. 断网后状态变为 `.disconnected` 并触发重连 <br> 4. 恢复网络后回到 `.connected` <br> 5. `disconnect()` 后状态为 `.disconnected`,监听器仍保留(不移除) |
|
||||
| **实际结果** | 待测试 |
|
||||
| **通过状态** | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
### TC-06 Push 设备注册测试(APNs + FCM)
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **测试目的** | 验证 APNs 设备 Token 获取、注册与 FCM 备选方案 |
|
||||
| **测试步骤** | 1. 在 `AppDelegate` 中调用 `PushSDK.shared.requestAuthorization()` <br> 2. 系统授权后 `UIApplication.shared.registerForRemoteNotifications()` <br> 3. 在 `didRegisterForRemoteNotificationsWithDeviceToken` 中调用 `XuqmSDK.shared.registerDeviceToken(_:)` <br> 4. 确认 `PushSDK.shared.registerToken(token:userId:vendor:)` 调用(vendor=.apns) <br> 5. 若集成 Firebase,验证 `registerFcmToken` 路径 |
|
||||
| **预期结果** | 1. `requestAuthorization` 返回 `true` <br> 2. 系统弹窗申请通知权限 <br> 3. `cachedDeviceToken` 被保存 <br> 4. `/api/push/register` 返回 200 <br> 5. FCM 路径返回 vendor=FCM,注册成功 |
|
||||
| **实际结果** | 待测试 |
|
||||
| **通过状态** | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
### TC-07 版本更新检查测试
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **测试目的** | 验证 UpdateSDK 检查 App Store 更新 |
|
||||
| **测试步骤** | 1. 调用 `UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)` <br> 2. 若 `needsUpdate=true` 且 `forceUpdate=true`,调用 `openAppStore(url:)` <br> 3. 观察是否能正确跳转到 App Store 或下载页 |
|
||||
| **预期结果** | 1. 返回 `AppUpdateInfo`,`platform=IOS` <br> 2. `forceUpdate` 为布尔值,下载链接有效 <br> 3. `UIApplication.shared.open` 成功跳转 |
|
||||
| **实际结果** | 待测试 |
|
||||
| **通过状态** | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
### TC-08 UserSig 过期检测测试(新增)
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **测试目的** | 验证 `XuqmSDK` 解析 UserSig JWT exp 并在过期前触发回调 |
|
||||
| **测试步骤** | 1. 生成一个 exp 为当前时间 + 6 分钟的 UserSig JWT <br> 2. 调用 `XuqmSDK.shared.login(userId:userSig:)` <br> 3. 设置 `XuqmSDK.shared.onUserSigExpired = { ... }` <br> 4. 等待 1 分钟,观察 Timer 是否在正确时间点触发 <br> 5. 触发后调用 `logout()`,确认 Timer 被 `invalidate` |
|
||||
| **预期结果** | 1. `extractExpirationDate` 正确解析 JWT payload <br> 2. `startUserSigExpirationTimer` 创建 `Timer` <br> 3. 到期前 5 分钟触发 `onUserSigExpired?()` <br> 4. 回调在主线程执行 <br> 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 过期检测测试 | ⬜ 待测试 |
|
||||
22
XuqmDemo/Package.swift
普通文件
22
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"
|
||||
),
|
||||
]
|
||||
)
|
||||
65
XuqmDemo/README.md
普通文件
65
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:)` — 检查更新
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
XuqmDemo/Sources/XuqmDemoApp.swift
普通文件
100
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户