From 97e5b8359a3071590171739ad99cb1b2c32880ee Mon Sep 17 00:00:00 2001 From: km2023 Date: Sat, 9 May 2026 11:51:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E9=87=8D=E5=91=BD=E5=90=8D=20owne?= =?UTF-8?q?r=E2=86=92brand=20=E5=B9=B6=E5=9C=A8=E7=A1=AC=E7=BB=91=E5=89=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E8=AE=BE=E5=A4=87=E5=93=81=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DuooomiBleConfig.owner → brand(语义:设备品牌名),HTTP header x-owner 不变 - bind() 前置 verifyBrand:缺 deviceInfo 先 getDeviceInfo,与 config.brand 严格匹配,不一致主动断连并抛 .brandMismatch - README 抹除软绑/对账/UpgradeRecord 等内部实现细节,仅保留厂商面向的公开 API(功能代码完全保留) Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 103 +++--------------- Sources/DuooomiBleSDK/Core/BleTypes.swift | 3 + Sources/DuooomiBleSDK/DuooomiBleConfig.swift | 8 +- .../DuooomiBleSDK/DuooomiBleSDK+Binding.swift | 42 +++++++ Sources/DuooomiBleSDK/DuooomiBleSDK.swift | 2 +- .../Services/FirmwareUpgradeService.swift | 2 +- .../Services/ShipmentSnDeviceService.swift | 2 +- Tests/DuooomiBleSDKTests/PlayModeTests.swift | 2 +- demo/Sources/DemoApp.swift | 16 +-- demo/Sources/DemoSecrets.template.swift | 4 +- demo/Sources/WrapperTestView.swift | 6 +- 11 files changed, 81 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index b5a2716..fa17ebb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ dependencies: [ ## Demo 初始化(仓库内 demo 目录) -`apiKey` / `owner` / `scanNamePrefix` 不进 git,由本地 `demo/Sources/DemoSecrets.swift` 提供: +`apiKey` / `brand` / `scanNamePrefix` 不进 git,由本地 `demo/Sources/DemoSecrets.swift` 提供: ```bash cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift @@ -39,7 +39,7 @@ open demo.xcworkspace ```swift let sdk = DuooomiBleSDK(config: .init( apiKey: "your-api-key", // 必填,Loomart x-api-key - owner: "your-owner", // 必填,所有 RPC 必带 x-owner header + brand: "your-brand", // 必填,设备品牌名(所有 RPC 必带 x-owner header) scanNamePrefix: "蓝牙广播名前缀" // 必填,大小写不敏感 // apiHost: URL(...)!, // 默认 https://api.duooomi.com // cdnHost: "https://cdn.bowong.cc/", // 默认 @@ -51,29 +51,21 @@ sdk.delegate = self ## SDK 能力范围 -- **内置**:BLE 协议(扫描/连接/硬绑/硬解/播放模式/文件传输/OTA 烧录)+ 软绑(POST `/api/auth/loomart/shipment-sn/device/bind`)+ 软解绑 + 升级对账上报 + 服务端拉最新固件 + 北京时间获取 +- **内置**:BLE 协议(扫描/连接/绑定/解绑/播放模式/文件传输/OTA 烧录)+ 固件版本检查 - **不内置**:UI、retry 策略;SDK 只提供原子方法和状态属性,由集成方组合 ### 绑定与固件升级流程 -绑定按 BLE 链路 / 云端登记拆两步: +1. **`bind(userId:)`** — 绑定设备,完成后 `sn` / `version` / `isActivated` 就绪。 +2. **`fetchLatestFirmware(sn:currentVersion:userId:)`** — 拉服务端最新固件信息,写入 `firmwareUpgrade`。 +3. **`upgradeFirmware(...)`** — 烧录固件包,进度通过 `didUpdateProgress` 回调。 -1. **`bind(userId:)`** — 硬绑(BLE 0x0F)+ 同步 `getDeviceVersion`。完成后 `sn` / `version` / `isActivated` 就绪。 -2. **`fetchLatestFirmware(sn:currentVersion:userId:)`** — 内部串: - 1. 对账上报本地 pending `UpgradeRecord`(容错失败) - 2. 软绑 `shipment-sn`(失败 → 自动硬解绑回滚 + 重置 `isActivated`/`sn`,整个 fetch 失败) - 3. 拉服务端最新固件 → 写入 `firmwareUpgrade` - -升级烧录走 `upgradeFirmware(sn:fileUrl:firmwareId:fromVersion:targetVersion:)`,烧录完自动落 `UpgradeRecord`。 - -解绑只一步:`unbind(userId:)` 内部硬解 + 软解(软解失败仅 warn 不阻断)。 +解绑:`unbind(userId:)` 一步完成。 ### API 调用风格 所有公开方法**显式传入参**,不依赖 SDK 内部状态自动取参。调用方从 SDK 只读属性(`sdk.sn` / `sdk.version` / `sdk.firmwareUpgrade?.targetFirmware?.*` 等)组装参数后传入。 -解绑只一步:`unbind(userId:)` 内部硬解 + 软解(软解失败仅 warn 不阻断)。 - ## 代理(Delegate) 通过 `DuooomiBleSDKDelegate` 接收状态变化,所有方法在主线程调用,均有默认空实现: @@ -132,7 +124,7 @@ sdk.getVersion { result in } } -// 硬绑 + getVersion;不含软绑/OTA 检查 +// 绑定设备 + 同步拉版本 sdk.bind(userId: "user-id") { result in if case .success(let resp) = result { print("SN: \(resp.sn), Files: \(resp.contents.count)") @@ -140,7 +132,7 @@ sdk.bind(userId: "user-id") { result in } } -// 硬解 + 软解(一步完成) +// 解绑 sdk.unbind(userId: "user-id") { result in /* ... */ } sdk.deleteFile(key: "file-key") { result in /* ... */ } ``` @@ -189,36 +181,21 @@ sdk.transferMedia(fileUrl: "https://example.com/video.mp4") { result in ### 固件升级 -完整流程:**扫描 → 连接 → bind → fetchLatestFirmware(含对账上报)→ upgradeFirmware(落 record)→ 重连后再次 fetchLatestFirmware 触发对账**。 +完整流程:**扫描 → 连接 → bind → fetchLatestFirmware → upgradeFirmware**。设备重启重连后再次走一遍即可。 ```swift -// ─── 0. 接收升级记录变化(用来观察对账上报结果) ─── -extension MyController: DuooomiBleSDKDelegate { - func sdk(_ sdk: DuooomiBleSDK, didUpdateUpgradeRecords records: [UpgradeRecord]) { - for r in records { - switch (r.reported, r.outcome) { - case (false, _): print("待上报 sn=\(r.sn) fw=\(r.firmwareId)") - case (true, .success): print("✅ 正确上报 \(r.fromVersion)→\(r.targetVersion)") - case (true, .versionMismatch): print("❌ 错误上报 versionMismatch") - case (true, nil): print("过期放弃 (>30 天)") - } - } - } -} -sdk.delegate = self - // ─── 1. 扫描 + 连接 ─── sdk.scan() // 在 didUpdateDevices 拿到列表后选一台 connect sdk.connect(deviceId: device.id) { result in guard case .success = result else { return } - // ─── 2. 硬绑(BLE 0x0F + 同步 getVersion)─── + // ─── 2. 绑定 ─── sdk.bind(userId: "user-123") { result in guard case .success(let resp) = result else { return } // 此时 sdk.sn = resp.sn / sdk.version 已就绪 / sdk.isActivated = true - // ─── 3. 拉最新固件(内部串:对账上报 → 软绑 → checkUpgrade)─── + // ─── 3. 拉最新固件信息 ─── sdk.fetchLatestFirmware( sn: sdk.sn, currentVersion: sdk.version, @@ -226,7 +203,6 @@ sdk.connect(deviceId: device.id) { result in ) { result in switch result { case .failure(let err): - // 软绑失败已自动硬解回滚,可提示重试 print("拉取失败:\(err.localizedDescription)") return case .success(let info): @@ -237,12 +213,7 @@ sdk.connect(deviceId: device.id) { result in return } - // ─── 4. 强制升级判定(可选)─── - if target.forceUpgrade == true { - // 弹不可关闭弹窗 - } - - // ─── 5. 烧录(完成后自动落 UpgradeRecord) ─── + // ─── 4. 烧录 ─── sdk.upgradeFirmware( sn: sdk.sn, fileUrl: fileUrl, @@ -252,8 +223,7 @@ sdk.connect(deviceId: device.id) { result in ) { result in switch result { case .success: - // OTA 包发完,设备开始烧录并重启。 - // UpgradeRecord 已落本地(reported=false),等设备重启 + 重连。 + // OTA 包发完,设备开始烧录并重启 print("烧录命令完成,等待设备重启") case .failure(let err): print("烧录失败:\(err.localizedDescription)") @@ -263,39 +233,8 @@ sdk.connect(deviceId: device.id) { result in } } } - -// ─── 6. 设备重启 + 重连后,再次走完整流程 ─── -// 重新 scan/connect/bind 完成后,调一次 fetchLatestFirmware: -// 内部 tryReportPendingUpgrade 会拿当前 version 和上次 record.targetVersion 对账, -// 命中 → report-success,不命中 → report-failure(VERSION_MISMATCH ...)。 -// 结果通过 didUpdateUpgradeRecords 主动通知(参见步骤 0)。 ``` -### 升级对账上报机制 - -- **何时落记录**:`upgradeFirmware(...)` 烧录命令发完后,SDK 自动 upsert 一条 `UpgradeRecord` 到 `UserDefaults`(复合主键 `(sn, firmwareId)`),初始 `reported=false`。 -- **何时触发对账**:每次 `fetchLatestFirmware(...)` 进入时,第一步就跑 `tryReportPendingUpgrade`。 -- **判定规则**: - - `currentVersion == record.targetVersion` → `POST report-success` → `outcome=.success` - - `currentVersion != record.targetVersion` → `POST report-failure(VERSION_MISMATCH expected=... actual=...)` → `outcome=.versionMismatch` - - `now - triggeredAt > 30 天` → 标记 `outcome=nil` 不再尝试,保留为审计轨迹 - - HTTP 失败 → 保持 `reported=false`,下次 fetchLatestFirmware 再试 -- **响应回调**:`DuooomiBleSDKDelegate.didUpdateUpgradeRecords` 在每次 record 状态变化时主线程触发,完整 records 数组。 -- **手动触发**(仅调试用): - - ```swift - sdk.tryReportPendingUpgrade(sn: sdk.sn, currentVersion: sdk.version, userId: "user-123") - ``` - -### UpgradeRecord 状态判定 - -| `reported` | `outcome` | 含义 | -|---|---|---| -| `false` | `nil` | 待上报(HTTP 失败/未跑),下次 fetchLatestFirmware 重试 | -| `true` | `.success` | ✅ 正确上报:版本命中,业务对账成功 | -| `true` | `.versionMismatch` | ❌ 错误上报:设备版本与目标不符,烧录可能失败 | -| `true` | `nil` | 过期放弃:30 天 TTL 触发,保留作审计 | - ### 可读取状态 | 属性 | 类型 | 说明 | @@ -328,18 +267,6 @@ public struct TargetFirmware { let fileMd5: String? } -// UpgradeRecord — 本地持久化的 OTA 烧录记录(用 UserDefaults,集成方一般不用直接读) -public struct UpgradeRecord { - let sn: String - let firmwareId: String - let fromVersion: String - let targetVersion: String - let triggeredAt: Int64 // 毫秒时间戳 - let reported: Bool - let reportedAt: Int64? - let outcome: UpgradeOutcome? // .success / .versionMismatch / nil(TTL 过期) -} - // PlayMode — 播放模式 public enum PlayMode: Int { case single = 0 // 单播 @@ -348,7 +275,7 @@ public enum PlayMode: Int { // DeviceInfo — getDeviceInfo 返回 public struct DeviceInfo { - let name: String // 设备名 + let name: String // 产品名 let brand: String // 品牌 let size: String // 屏幕尺寸 let powerlevel: Int // 电量百分比 diff --git a/Sources/DuooomiBleSDK/Core/BleTypes.swift b/Sources/DuooomiBleSDK/Core/BleTypes.swift index c582de3..a5da05f 100644 --- a/Sources/DuooomiBleSDK/Core/BleTypes.swift +++ b/Sources/DuooomiBleSDK/Core/BleTypes.swift @@ -39,6 +39,7 @@ public enum DuooomiBleError: LocalizedError { case permissionDenied case unsupportedFormat(String) case firmwareUpgradeUnavailable(String) + case brandMismatch(expected: String, actual: String) public var errorDescription: String? { switch self { @@ -76,6 +77,8 @@ public enum DuooomiBleError: LocalizedError { return "Unsupported file format: \(ext). Supported: jpeg, png, gif, mp4, webm, webp" case .firmwareUpgradeUnavailable(let reason): return "Firmware upgrade unavailable: \(reason)" + case .brandMismatch(let expected, let actual): + return "不支持该品牌设备 (expected=\(expected), actual=\(actual))" } } } diff --git a/Sources/DuooomiBleSDK/DuooomiBleConfig.swift b/Sources/DuooomiBleSDK/DuooomiBleConfig.swift index 0406499..91c10b5 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleConfig.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleConfig.swift @@ -6,8 +6,8 @@ public struct DuooomiBleConfig { public let apiHost: URL /// API 鉴权 key(Loomart x-api-key) public let apiKey: String - /// 多租户 owner 标识,所有 RPC 必带 `x-owner` header - public let owner: String + /// 设备品牌名(所有 RPC 必带 `x-owner` header) + public let brand: String /// CDN 资源地址(末尾带 `/`) public let cdnHost: String /// ANI 转换宽度(像素) @@ -22,7 +22,7 @@ public struct DuooomiBleConfig { public init( apiHost: URL = URL(string: "https://api.duooomi.com")!, apiKey: String, - owner: String, + brand: String, cdnHost: String = "https://cdn.bowong.cc/", aniWidth: Int = 360, aniHeight: Int = 360, @@ -31,7 +31,7 @@ public struct DuooomiBleConfig { ) { self.apiHost = apiHost self.apiKey = apiKey - self.owner = owner + self.brand = brand self.cdnHost = cdnHost.hasSuffix("/") ? cdnHost : cdnHost + "/" self.aniWidth = aniWidth self.aniHeight = aniHeight diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift index 427ff6f..36552a6 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK+Binding.swift @@ -22,6 +22,48 @@ extension DuooomiBleSDK { /// - Note: `bind` / `unbind` / `setPlayMode` 共享 `0x0F` / `0x12` opId 通道,**不能并发**。 public func bind(userId: String, completion: @escaping (Result) -> Void) { guard ensureConnected(completion: completion) else { return } + verifyBrand { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let err): + completion(.failure(err)) + case .success: + self.performHardBind(userId: userId, completion: completion) + } + } + } + + /// 校验设备 brand 与 SDK 配置 brand 是否一致;缺失则先拉一次 deviceInfo。 + /// 不一致时主动断连并回调 `.brandMismatch`。 + private func verifyBrand(completion: @escaping (Result) -> Void) { + if let cached = deviceInfo { + checkBrand(actual: cached.brand, completion: completion) + return + } + getDeviceInfo { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let err): + completion(.failure(err)) + case .success(let info): + self.checkBrand(actual: info.brand, completion: completion) + } + } + } + + private func checkBrand(actual: String, completion: @escaping (Result) -> Void) { + let expected = config.brand + if actual == expected { + completion(.success(())) + return + } + BleLog.w("Brand mismatch: expected=\(expected), actual=\(actual); disconnecting", "Command") + disconnect { _ in + completion(.failure(DuooomiBleError.brandMismatch(expected: expected, actual: actual))) + } + } + + private func performHardBind(userId: String, completion: @escaping (Result) -> Void) { BleLog.d("Sending bind (userId=\(userId))", "Command") sendAsyncAndWait(commandType: .bindDevice, completion: { [weak self] result in diff --git a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift index dd64e7b..cb69df3 100644 --- a/Sources/DuooomiBleSDK/DuooomiBleSDK.swift +++ b/Sources/DuooomiBleSDK/DuooomiBleSDK.swift @@ -118,7 +118,7 @@ public final class DuooomiBleSDK: NSObject { super.init() protocolService.delegate = self - BleLog.i("SDK initialized (apiHost=\(config.apiHost), owner=\(config.owner))", "SDK") + BleLog.i("SDK initialized (apiHost=\(config.apiHost), brand=\(config.brand))", "SDK") setupDisconnectHandler() // UpgradeRecord 变化(含对账上报结果)→ delegate 通知 diff --git a/Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift b/Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift index 76aabfd..12048e5 100644 --- a/Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift +++ b/Sources/DuooomiBleSDK/Services/FirmwareUpgradeService.swift @@ -89,7 +89,7 @@ final class FirmwareUpgradeService { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") - req.setValue(config.owner, forHTTPHeaderField: "x-owner") + req.setValue(config.brand, forHTTPHeaderField: "x-owner") do { req.httpBody = try JSONEncoder().encode(body) } catch { diff --git a/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift b/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift index 1e5195b..b94e298 100644 --- a/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift +++ b/Sources/DuooomiBleSDK/Services/ShipmentSnDeviceService.swift @@ -54,7 +54,7 @@ final class ShipmentSnDeviceService { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue(config.apiKey, forHTTPHeaderField: "x-api-key") - req.setValue(config.owner, forHTTPHeaderField: "x-owner") + req.setValue(config.brand, forHTTPHeaderField: "x-owner") do { req.httpBody = try JSONEncoder().encode(body) diff --git a/Tests/DuooomiBleSDKTests/PlayModeTests.swift b/Tests/DuooomiBleSDKTests/PlayModeTests.swift index 970b76f..4441d61 100644 --- a/Tests/DuooomiBleSDKTests/PlayModeTests.swift +++ b/Tests/DuooomiBleSDKTests/PlayModeTests.swift @@ -88,7 +88,7 @@ final class BindPayloadEncodingTests: XCTestCase { final class SetPlayModeGuardTests: XCTestCase { private func makeSDK() -> DuooomiBleSDK { - DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test", owner: "test-owner", scanNamePrefix: "Duooomi-")) + DuooomiBleSDK(config: DuooomiBleConfig(apiKey: "test", brand: "test-brand", scanNamePrefix: "Duooomi-")) } func testSetPlayModeFailsWhenNotConnected() { diff --git a/demo/Sources/DemoApp.swift b/demo/Sources/DemoApp.swift index a153f00..48829c4 100644 --- a/demo/Sources/DemoApp.swift +++ b/demo/Sources/DemoApp.swift @@ -20,8 +20,8 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate { // MARK: - SDK Config(demo 内可改,持久化到 UserDefaults) - @Published var configOwner: String { - didSet { defaults.set(configOwner, forKey: Self.keyOwner) } + @Published var configBrand: String { + didSet { defaults.set(configBrand, forKey: Self.keyBrand) } } @Published var configApiKey: String { didSet { defaults.set(configApiKey, forKey: Self.keyApiKey) } @@ -47,28 +47,28 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate { // MARK: - Internals private let defaults = UserDefaults.standard - private static let keyOwner = "demo.config.owner" + private static let keyBrand = "demo.config.brand" private static let keyApiKey = "demo.config.apiKey" private static let keyScanPrefix = "demo.config.scanPrefix" /// demo 调试默认值(仅当 UserDefaults 没存过时使用)。秘钥从 DemoSecrets 读,避免泄露到仓库。 - private static let defaultOwner = DemoSecrets.owner + private static let defaultBrand = DemoSecrets.brand private static let defaultApiKey = DemoSecrets.apiKey private static let defaultScanPrefix = DemoSecrets.scanNamePrefix override init() { let d = UserDefaults.standard - let owner = d.string(forKey: Self.keyOwner) ?? Self.defaultOwner + let brand = d.string(forKey: Self.keyBrand) ?? Self.defaultBrand let apiKey = d.string(forKey: Self.keyApiKey) ?? Self.defaultApiKey let scanPrefix = d.string(forKey: Self.keyScanPrefix) ?? Self.defaultScanPrefix - self.configOwner = owner + self.configBrand = brand self.configApiKey = apiKey self.configScanPrefix = scanPrefix self.sdk = DuooomiBleSDK(config: .init( apiKey: apiKey, - owner: owner, + brand: brand, scanNamePrefix: scanPrefix )) @@ -82,7 +82,7 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate { func reloadSDK() { let new = DuooomiBleSDK(config: .init( apiKey: configApiKey, - owner: configOwner, + brand: configBrand, scanNamePrefix: configScanPrefix )) new.delegate = self diff --git a/demo/Sources/DemoSecrets.template.swift b/demo/Sources/DemoSecrets.template.swift index 6e70dfe..99fa25f 100644 --- a/demo/Sources/DemoSecrets.template.swift +++ b/demo/Sources/DemoSecrets.template.swift @@ -2,13 +2,13 @@ // // 首次 clone 后请: // cp demo/Sources/DemoSecrets.template.swift demo/Sources/DemoSecrets.swift -// 然后编辑 DemoSecrets.swift 填入真实 apiKey / owner。 +// 然后编辑 DemoSecrets.swift 填入真实 apiKey / brand。 // // `demo/Sources/DemoSecrets.swift` 已加入 .gitignore,本地修改不会被 commit。 import Foundation enum DemoSecrets { static let apiKey: String = "" - static let owner: String = "xmly" + static let brand: String = "xmly" static let scanNamePrefix: String = "Duooomi-" } diff --git a/demo/Sources/WrapperTestView.swift b/demo/Sources/WrapperTestView.swift index 438c446..4491562 100644 --- a/demo/Sources/WrapperTestView.swift +++ b/demo/Sources/WrapperTestView.swift @@ -68,8 +68,8 @@ struct WrapperTestView: View { private var configSection: some View { Section("SDK Config(修改后点 Reload 生效)") { HStack { - Text("x-owner").frame(width: 90, alignment: .leading) - TextField("owner", text: $viewModel.configOwner) + Text("brand").frame(width: 90, alignment: .leading) + TextField("brand", text: $viewModel.configBrand) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .disableAutocorrection(true) @@ -90,7 +90,7 @@ struct WrapperTestView: View { } Button("Reload SDK") { viewModel.reloadSDK() - log("SDK reloaded with owner=\(viewModel.configOwner), prefix=\(viewModel.configScanPrefix)", level: .success) + log("SDK reloaded with brand=\(viewModel.configBrand), prefix=\(viewModel.configScanPrefix)", level: .success) } .buttonStyle(.borderedProminent) }