DuooomiBleConfig 新增 frameIntervalMs,BleProtocolService 构造期注入。 顺手清死代码 FrameConstants.frameIntervalNanos、修 sendFrames 0.035 字面量 (统一走 config)。demo 加可调输入框 + UserDefaults 持久化。 越界 warn 不抛错,使用点 min/max 兜底。 设计稿:docs/superpowers/specs/2026-05-20-frame-interval-config-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
605 lines
23 KiB
Swift
605 lines
23 KiB
Swift
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("brand").frame(width: 90, alignment: .leading)
|
||
TextField("brand", text: $viewModel.configBrand)
|
||
.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)
|
||
}
|
||
HStack {
|
||
Text("frameMs").frame(width: 90, alignment: .leading)
|
||
TextField("35", text: $viewModel.configFrameIntervalMs)
|
||
.textFieldStyle(.roundedBorder)
|
||
.keyboardType(.numberPad)
|
||
}
|
||
Button("Reload SDK") {
|
||
viewModel.reloadSDK()
|
||
log("SDK reloaded with brand=\(viewModel.configBrand), prefix=\(viewModel.configScanPrefix), frameMs=\(viewModel.configFrameIntervalMs)", 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|