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 = "test-user-001" @State private var fileUrl = "https://cdn.bowong.cc/material/569f48a8e29f47859b3a9808be37f94c.mp4" // Firmware @State private var firmwareBrand = "duomi" @State private var firmwareStatus = "DRAFT" @State private var firmwareInfo: FirmwareInfo? = nil @State private var firmwareLoading = false @State private var firmwareError: String? = nil // 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 { statusSection scanSection deviceSection transferSection firmwareSection deviceFilesSection logSection } .navigationTitle("SDK Demo") .scrollDismissesKeyboard(.interactively) .onAppear { CDNHelper.cdnHost = viewModel.sdk.config.cdnHost firmwareBrand = viewModel.sdk.config.firmwareIdentifier firmwareStatus = viewModel.sdk.config.firmwareStatus } } // MARK: - Sections 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) } } } 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 static let statusOptions = ["DRAFT", "PUBLISHED"] private var firmwareSection: some View { Section("Firmware Update") { LabeledContent("Current Version", value: viewModel.version.isEmpty ? "—" : viewModel.version) HStack { Text("Identifier") TextField("duomi", text: $firmwareBrand) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) } Picker("Status", selection: $firmwareStatus) { ForEach(Self.statusOptions, id: \.self) { Text($0) } } .pickerStyle(.segmented) if let info = firmwareInfo { VStack(alignment: .leading, spacing: 6) { LabeledContent("Latest", value: info.version) LabeledContent("Has Update", value: viewModel.sdk.hasNewerFirmware(deviceVersion: viewModel.version, serverVersion: info.version) ? "Yes" : "No") if let size = info.fileSize { let bytes = Int(size) ?? 0 LabeledContent("Size", value: ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)) } if let md5 = info.fileMd5, !md5.isEmpty { LabeledContent("MD5", value: md5).font(.caption2) } if let desc = info.description, !desc.isEmpty { Text(desc).font(.caption).foregroundStyle(.secondary) } Text(info.fileUrl).font(.caption2).foregroundStyle(.secondary).lineLimit(2) } } if let err = firmwareError { Text(err).font(.caption).foregroundStyle(.red) } if firmwareLoading { HStack { ProgressView().controlSize(.small) Text("Fetching...").font(.caption).foregroundStyle(.secondary) } } HStack { Button("Get Update Info") { runFetchFirmwareInfo() } .buttonStyle(.bordered) .disabled(firmwareLoading || firmwareBrand.isEmpty) Button("Upgrade") { runUpgradeFirmware() } .buttonStyle(.borderedProminent) .disabled(firmwareInfo?.fileUrl.isEmpty != false || viewModel.connectedDevice == nil || isBusy || firmwareLoading) } } } 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 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 runFetchFirmwareInfo() { let brand = firmwareBrand.trimmingCharacters(in: .whitespacesAndNewlines) guard !brand.isEmpty else { firmwareError = "No identifier specified" return } firmwareLoading = true firmwareError = nil log("→ fetchLatestFirmware(\(brand), \(firmwareStatus))") viewModel.sdk.fetchLatestFirmware(identifier: brand, status: firmwareStatus) { [self] result in DispatchQueue.main.async { self.firmwareLoading = false switch result { case .success(let info): self.firmwareInfo = info if let info = info { let hasUpdate = self.viewModel.sdk.hasNewerFirmware(deviceVersion: self.viewModel.version, serverVersion: info.version) self.log("firmware latest: \(info.version), hasUpdate=\(hasUpdate)", level: .success) } else { self.log("no firmware available", level: .info) } case .failure(let error): self.firmwareError = error.localizedDescription self.log("firmware info ✗ \(error.localizedDescription)", level: .error) } } } } private func runUpgradeFirmware() { guard let info = firmwareInfo, !info.fileUrl.isEmpty else { return } isBusy = true log("→ upgradeFirmware \(info.version)") viewModel.sdk.upgradeFirmware(fileUrl: info.fileUrl) { [self] result in DispatchQueue.main.async { self.isBusy = false switch result { case .success: self.log("OTA ✓", level: .success) case .failure(let error): self.log("OTA ✗ \(error.localizedDescription)", level: .error) } } } } }