import SwiftUI import DuooomiBleSDK // MARK: - CDN Helper private enum CDNHelper { static var cdnHost = "https://cdn.bowong.cc/" static func ensureFullUrl(_ keyOrUrl: String) -> String { if keyOrUrl.hasPrefix("http://") || keyOrUrl.hasPrefix("https://") { return keyOrUrl } let key = keyOrUrl.hasPrefix("/") ? String(keyOrUrl.dropFirst()) : keyOrUrl return cdnHost + key } static func isImage(_ url: String) -> Bool { let ext = URL(string: url)?.pathExtension.lowercased() ?? "" return ["jpg", "jpeg", "png", "webp", "gif"].contains(ext) } } // MARK: - View struct WrapperTestView: View { @ObservedObject var viewModel: DemoViewModel @State private var userId = "Cjgex9kTTTJq9u9RMOnc5hiIZTvrOOq3" @State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4" @State private var otaRecordsTick = 0 // fetchLatestFirmware 入参:可手动填,bind 完成 / version 拿到时自动回填 @State private var otaFetchSn = "" @State private var otaFetchVersion = "" @State private var otaFetchUserId = "" // State @State private var isBusy = false @State private var connectingDeviceId: String? @State private var deviceFiles: [String] = [] @State private var logs: [LogLine] = [] struct LogLine: Identifiable { let id = UUID() let level: Level let message: String enum Level { case info, success, error } } var body: some View { List { configSection statusSection scanSection deviceSection transferSection firmwareSection deviceFilesSection logSection } .navigationTitle("SDK Demo") .scrollDismissesKeyboard(.interactively) .onAppear { CDNHelper.cdnHost = viewModel.sdk.config.cdnHost } } // MARK: - Sections private var configSection: some View { Section("SDK Config(修改后点 Reload 生效)") { HStack { Text("x-owner").frame(width: 90, alignment: .leading) TextField("owner", text: $viewModel.configOwner) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) } HStack { Text("apiKey").frame(width: 90, alignment: .leading) TextField("x-api-key", text: $viewModel.configApiKey) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) } HStack { Text("scanPrefix").frame(width: 90, alignment: .leading) TextField("Duooomi-", text: $viewModel.configScanPrefix) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) } Button("Reload SDK") { viewModel.reloadSDK() log("SDK reloaded with owner=\(viewModel.configOwner), prefix=\(viewModel.configScanPrefix)", level: .success) } .buttonStyle(.borderedProminent) } } private var statusSection: some View { Section("Status") { LabeledContent("BLE", value: viewModel.btState.rawValue) LabeledContent("Device", value: viewModel.connectedDevice?.name ?? "—") LabeledContent("Version", value: viewModel.version.isEmpty ? "—" : viewModel.version) if viewModel.transferProgress > 0 { ProgressView(value: Double(viewModel.transferProgress), total: 100) { Text("\(viewModel.transferProgress)%").font(.caption) } } if let err = viewModel.error { Text(err).foregroundStyle(.red).font(.caption) } } } private var scanSection: some View { Section("Scan & Connect") { HStack { Button("Scan") { viewModel.sdk.scan() log("→ scan()") } .buttonStyle(.borderedProminent) Button("Stop") { viewModel.sdk.stopScan() log("→ stopScan()") } .buttonStyle(.bordered) } ForEach(viewModel.discoveredDevices) { device in HStack { VStack(alignment: .leading) { Text(device.name ?? "Unknown") Text(device.id).font(.caption2).foregroundStyle(.secondary) } Spacer() Text("\(device.rssi) dBm").font(.caption).foregroundStyle(.secondary) if viewModel.connectedDevice?.id == device.id { Button("Disconnect") { runDisconnect() } .buttonStyle(.bordered) .controlSize(.small) .tint(.red) } else if connectingDeviceId == device.id { ProgressView().controlSize(.small) } else { Button("Connect") { runConnect(deviceId: device.id) } .buttonStyle(.bordered) .controlSize(.small) .disabled(connectingDeviceId != nil) } } } } } private var deviceSection: some View { Section("Device") { LabeledContent("Brand", value: viewModel.deviceInfo?.brand ?? "—") LabeledContent("Size", value: viewModel.deviceInfo?.size ?? "—") LabeledContent("Power", value: viewModel.deviceInfo.map { "\($0.powerlevel)%" } ?? "—") LabeledContent("Storage", value: viewModel.deviceInfo.map { "\($0.freespace)/\($0.allspace)" } ?? "—") HStack { Button("getDeviceInfo") { runGetDeviceInfo() } Button("getVersion") { runGetVersion() } } .buttonStyle(.bordered) HStack { TextField("userId", text: $userId) .textFieldStyle(.roundedBorder) Button("bind") { runBind() } .buttonStyle(.bordered) Button("unbind") { runUnbind() } .buttonStyle(.bordered) } HStack { Text("PlayMode") .font(.caption) Spacer() if let mode = viewModel.deviceInfo?.loop { Text(mode == .loop ? "循环" : "单播") .font(.caption) .foregroundColor(.secondary) } Button("Single") { runSetPlayMode(.single) } .buttonStyle(.bordered) Button("Loop") { runSetPlayMode(.loop) } .buttonStyle(.bordered) } .disabled(!viewModel.isActivated || isBusy) } } private var transferSection: some View { Section("Transfer") { TextField("File URL (mp4/jpg/png/ani)", text: $fileUrl) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) Button("Transfer") { runTransfer() } .buttonStyle(.borderedProminent) .disabled(fileUrl.isEmpty || viewModel.connectedDevice == nil || isBusy) } } private var firmwareSection: some View { Section("Firmware OTA") { LabeledContent("Current Version", value: viewModel.version.isEmpty ? "—" : viewModel.version) // fetchLatestFirmware 三入参:可手动填,bind/getVersion 完成时自动回填 HStack { Text("sn").frame(width: 110, alignment: .leading).font(.caption) TextField("绑定后自动填", text: $otaFetchSn) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) } HStack { Text("currentVersion").frame(width: 110, alignment: .leading).font(.caption) TextField("getVersion 后自动填", text: $otaFetchVersion) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) } HStack { Text("userId").frame(width: 110, alignment: .leading).font(.caption) TextField("用户 ID", text: $otaFetchUserId) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) } HStack { Button("回填当前状态") { otaFetchSn = viewModel.sdk.sn otaFetchVersion = viewModel.version otaFetchUserId = userId } .buttonStyle(.bordered) .controlSize(.small) Spacer() Button("拉取安装包") { runCheckFirmwareUpgrade() } .buttonStyle(.bordered) .disabled(otaFetchSn.isEmpty || otaFetchUserId.isEmpty || isBusy) Button("安装") { runInstallFirmwareUpgrade() } .buttonStyle(.borderedProminent) .disabled(viewModel.sdk.firmwareUpgrade?.targetFirmware?.fileUrl == nil || isBusy) } if let upgrade = viewModel.sdk.firmwareUpgrade { if let target = upgrade.targetFirmware { LabeledContent("target version", value: target.version).font(.caption) LabeledContent("firmwareId", value: target.id).font(.caption2).foregroundStyle(.secondary) if let force = target.forceUpgrade { LabeledContent("forceUpgrade", value: force ? "是" : "否") .font(.caption2) .foregroundStyle(force ? Color.red : Color.secondary) } if let size = target.fileSize { LabeledContent("fileSize", value: size).font(.caption2).foregroundStyle(.secondary) } if let md5 = target.fileMd5 { LabeledContent("md5", value: md5).font(.caption2).foregroundStyle(.secondary) } if let url = target.fileUrl { Text(url).font(.caption2).foregroundStyle(.secondary).lineLimit(2) } } else { Text("无可用固件(targetFirmware 为空)") .font(.caption).foregroundStyle(.secondary) } } // UpgradeRecords 列表(来自 ViewModel @Published 镜像,对账上报变化会响应式刷新) let records = viewModel.upgradeRecords if !records.isEmpty { Text("Upgrade Records (\(records.count))").font(.caption).foregroundStyle(.secondary) ForEach(records, id: \.firmwareId) { r in VStack(alignment: .leading, spacing: 2) { Text("\(r.sn) · \(r.firmwareId)").font(.caption) Text("\(r.fromVersion) → \(r.targetVersion)").font(.caption2).foregroundStyle(.secondary) HStack { // 用 (reported, outcome) 组合判定 4 种状态 switch (r.reported, r.outcome) { case (false, _): Text("待上报").font(.caption2).foregroundStyle(Color.orange) case (true, .success): Text("✅ 正确上报").font(.caption2).foregroundStyle(Color.green) case (true, .versionMismatch): Text("❌ 错误上报(版本不匹配)").font(.caption2).foregroundStyle(Color.red) case (true, nil): Text("过期放弃").font(.caption2).foregroundStyle(Color.gray) } } } .padding(.vertical, 2) } } } .id(otaRecordsTick) .onAppear { // 进入页面时初始回填一次 if otaFetchSn.isEmpty { otaFetchSn = viewModel.sdk.sn } if otaFetchVersion.isEmpty { otaFetchVersion = viewModel.version } if otaFetchUserId.isEmpty { otaFetchUserId = userId } } .onChange(of: viewModel.isActivated) { activated in // bind 成功 → 回填 sn / currentVersion(可被用户手动覆盖;下次 bind 完成时再次回填) if activated { otaFetchSn = viewModel.sdk.sn otaFetchVersion = viewModel.version } } .onChange(of: viewModel.version) { v in // getVersion 单独触发时也同步过来 if !v.isEmpty { otaFetchVersion = v } } } private var deviceFilesSection: some View { Section("Device Files (\(deviceFiles.count))") { if deviceFiles.isEmpty { Text("Bind 后显示设备文件") .font(.caption) .foregroundStyle(.secondary) } ForEach(deviceFiles, id: \.self) { rawKey in let fileUrl = CDNHelper.ensureFullUrl(rawKey) HStack { if CDNHelper.isImage(fileUrl), let url = URL(string: fileUrl) { AsyncImage(url: url) { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fill) case .failure: Image(systemName: "photo").foregroundStyle(.secondary) default: ProgressView() } } .frame(width: 50, height: 50) .clipShape(RoundedRectangle(cornerRadius: 6)) } else { Image(systemName: "film") .frame(width: 50, height: 50) .foregroundStyle(.secondary) } Text(rawKey) .font(.caption2) .lineLimit(2) Spacer() Button(role: .destructive) { runDeleteFile(rawKey) } label: { Image(systemName: "trash") } .buttonStyle(.borderless) .disabled(isBusy) } } } } private var logSection: some View { Section("Log (\(logs.count))") { ForEach(logs.prefix(80)) { line in Text(line.message) .font(.caption) .foregroundStyle(line.level == .error ? .red : line.level == .success ? .green : .primary) } } } // MARK: - Logging private func log(_ message: String, level: LogLine.Level = .info) { logs.insert(LogLine(level: level, message: message), at: 0) if logs.count > 200 { logs.removeLast() } } // MARK: - Actions (callback-based) private func runConnect(deviceId: String) { connectingDeviceId = deviceId log("→ connect(\(deviceId))") viewModel.sdk.connect(deviceId: deviceId) { [self] result in DispatchQueue.main.async { self.connectingDeviceId = nil switch result { case .success(let device): self.log("connect ✓ \(device.name ?? device.id)", level: .success) case .failure(let error): self.log("connect ✗ \(error.localizedDescription)", level: .error) } } } } private func runDisconnect() { log("→ disconnect") viewModel.sdk.disconnect { [self] result in DispatchQueue.main.async { switch result { case .success: self.deviceFiles = [] self.log("disconnect ✓", level: .success) case .failure(let error): self.log("disconnect ✗ \(error.localizedDescription)", level: .error) } } } } private func runGetDeviceInfo() { isBusy = true log("→ getDeviceInfo") viewModel.sdk.getDeviceInfo { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success(let info): self.log("getDeviceInfo ✓ brand=\(info.brand) power=\(info.powerlevel)%", level: .success) case .failure(let error): self.log("getDeviceInfo ✗ \(error.localizedDescription)", level: .error) } } } } private func runGetVersion() { isBusy = true log("→ getVersion") viewModel.sdk.getVersion { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success(let info): self.log("getVersion ✓ \(info.version)", level: .success) case .failure(let error): self.log("getVersion ✗ \(error.localizedDescription)", level: .error) } } } } private func runBind() { isBusy = true log("→ bind(\(userId))") viewModel.sdk.bind(userId: userId) { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success(let resp): self.log("bind ✓ sn=\(resp.sn) contents=\(resp.contents.count)", level: .success) self.deviceFiles = resp.contents case .failure(let error): self.log("bind ✗ \(error.localizedDescription)", level: .error) } } } } private func runUnbind() { isBusy = true log("→ unbind(\(userId))") viewModel.sdk.unbind(userId: userId) { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success: self.deviceFiles = [] self.log("unbind ✓", level: .success) case .failure(let error): self.log("unbind ✗ \(error.localizedDescription)", level: .error) } } } } private func runSetPlayMode(_ mode: PlayMode) { isBusy = true log("→ setPlayMode(\(mode == .loop ? "loop" : "single"), userId=\(userId))") viewModel.sdk.setPlayMode(mode, userId: userId) { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success(let resp): self.log("setPlayMode ✓ sn=\(resp.sn)", level: .success) case .failure(let error): self.log("setPlayMode ✗ \(error.localizedDescription)", level: .error) } } } } private func runTransfer() { isBusy = true log("→ transferMedia(\(fileUrl))") viewModel.sdk.transferMedia(fileUrl: fileUrl) { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success: self.log("transfer ✓", level: .success) case .failure(let error): self.log("transfer ✗ \(error.localizedDescription)", level: .error) } } } } private func runDeleteFile(_ rawKey: String) { isBusy = true log("→ deleteFile(\(rawKey))") viewModel.sdk.deleteFile(key: rawKey) { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success: self.log("deleteFile ✓", level: .success) self.deviceFiles.removeAll { $0 == rawKey } case .failure(let error): self.log("deleteFile ✗ \(error.localizedDescription)", level: .error) } } } } private func runCheckFirmwareUpgrade() { isBusy = true log("→ fetchLatestFirmware(sn=\(otaFetchSn), version=\(otaFetchVersion), userId=\(otaFetchUserId)) [report→softBind→check]") viewModel.sdk.fetchLatestFirmware( sn: otaFetchSn, currentVersion: otaFetchVersion, userId: otaFetchUserId ) { [self] result in DispatchQueue.main.async { self.isBusy = false self.otaRecordsTick &+= 1 switch result { case .success(let r): let target = r.targetFirmware?.version ?? "—" self.log("fetchLatestFirmware ✓ target=\(target)", level: .success) case .failure(let error): self.log("fetchLatestFirmware ✗ \(error.localizedDescription)", level: .error) } } } } private func runInstallFirmwareUpgrade() { guard let upgrade = viewModel.sdk.firmwareUpgrade, let target = upgrade.targetFirmware, let fileUrl = target.fileUrl, !fileUrl.isEmpty else { log("install ✗ 无可用固件包(请先点拉取安装包)", level: .error) return } let sn = viewModel.sdk.sn let fromVersion = viewModel.version isBusy = true log("→ upgradeFirmware sn=\(sn) \(fromVersion)→\(target.version) (fw=\(target.id))") viewModel.sdk.upgradeFirmware( sn: sn, fileUrl: fileUrl, firmwareId: target.id, fromVersion: fromVersion, targetVersion: target.version ) { [self] result in DispatchQueue.main.async { self.isBusy = false self.otaRecordsTick &+= 1 switch result { case .success: self.log("install ✓(已落 UpgradeRecord,下次 fetchLatestFirmware 自动对账上报)", level: .success) case .failure(let error): self.log("install ✗ \(error.localizedDescription)", level: .error) } } } } }