DeviceScanView.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. import SwiftUI
  2. import UIKit
  3. /// Modal sheet for scanning nearby BLE audio recording devices and binding them to the logged-in user.
  4. struct DeviceScanView: View {
  5. @Environment(\.dismiss) private var dismiss
  6. @ObservedObject var bleManager: BLEManager = .shared
  7. @ObservedObject var authManager: AuthManager = .shared
  8. @State private var boundSuccessMessage: String?
  9. @State private var bindingDeviceID: String?
  10. @State private var bindingErrorMessage: String?
  11. @State private var debugDeviceID: String?
  12. @State private var copiedCompatibilityLog = false
  13. var body: some View {
  14. ZStack {
  15. Color.spaceBlack.ignoresSafeArea()
  16. VStack(spacing: 20) {
  17. // Top Header
  18. HStack {
  19. VStack(alignment: .leading, spacing: 4) {
  20. Text("搜索与绑定录音设备")
  21. .font(.system(size: 18, weight: .bold))
  22. .foregroundStyle(Color.primary)
  23. Text("请确保设备已开启蓝牙广播模式并靠拢 iPhone")
  24. .font(.system(size: 12))
  25. .foregroundStyle(Color.secondary)
  26. }
  27. Spacer()
  28. Button {
  29. bleManager.stopScanning()
  30. dismiss()
  31. } label: {
  32. Image(systemName: "xmark.circle.fill")
  33. .font(.system(size: 22))
  34. .foregroundStyle(Color.secondary.opacity(0.6))
  35. }
  36. }
  37. .padding(.horizontal, 20)
  38. .padding(.top, 20)
  39. // Scanning Status Indicator
  40. HStack(spacing: 12) {
  41. if bleManager.isScanning {
  42. ProgressView()
  43. .tint(Color.primary)
  44. } else {
  45. Image(systemName: "radiowaves.left.and.right")
  46. .font(.system(size: 16))
  47. .foregroundStyle(Color.primary)
  48. }
  49. Text(scanStatusText)
  50. .font(.system(size: 13, weight: .medium))
  51. .foregroundStyle(Color.primary)
  52. Spacer()
  53. Button {
  54. if bleManager.isScanning {
  55. bleManager.stopScanning()
  56. } else {
  57. bleManager.startScanning()
  58. }
  59. } label: {
  60. Text(bleManager.isScanning ? "停止扫描" : "重新扫描")
  61. .font(.system(size: 12, weight: .regular))
  62. .foregroundStyle(Color.primary)
  63. .padding(.horizontal, 10)
  64. .padding(.vertical, 6)
  65. .overlay(
  66. RoundedRectangle(cornerRadius: 6)
  67. .stroke(Color.primary.opacity(0.3), lineWidth: 1)
  68. )
  69. }
  70. }
  71. .padding(14)
  72. .background(Color.cardBackground.opacity(0.4))
  73. .businessBorder(cornerRadius: 10)
  74. .padding(.horizontal, 20)
  75. if let issue = bleManager.scanUnavailableMessage {
  76. VStack(alignment: .leading, spacing: 10) {
  77. Label(issue, systemImage: "exclamationmark.triangle.fill")
  78. .font(.system(size: 12))
  79. .foregroundStyle(Color.orange)
  80. if bleManager.state == .unauthorized {
  81. Button("打开系统设置") {
  82. guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
  83. UIApplication.shared.open(url)
  84. }
  85. .font(.system(size: 12, weight: .medium))
  86. }
  87. }
  88. .frame(maxWidth: .infinity, alignment: .leading)
  89. .padding(14)
  90. .background(Color.orange.opacity(0.08))
  91. .businessBorder(cornerRadius: 10)
  92. .padding(.horizontal, 20)
  93. }
  94. formalBindingSection
  95. if let debugDeviceID {
  96. communicationDebugPanel(deviceID: debugDeviceID)
  97. } else if !bleManager.observedAdvertisements.isEmpty {
  98. VStack(alignment: .leading, spacing: 10) {
  99. HStack {
  100. Text("BLE 广播调试")
  101. .font(.system(size: 13, weight: .semibold))
  102. .foregroundStyle(Color.primary)
  103. Spacer()
  104. Text("\(bleManager.observedAdvertisements.count) 个设备 · 最多显示 50 个")
  105. .font(.system(size: 11, design: .monospaced))
  106. .foregroundStyle(Color.secondary)
  107. }
  108. ScrollView {
  109. LazyVStack(spacing: 8) {
  110. ForEach(bleManager.observedAdvertisements) { advertisement in
  111. observedAdvertisementRow(advertisement)
  112. }
  113. }
  114. }
  115. .frame(height: 300)
  116. }
  117. .padding(14)
  118. .background(Color.cardBackground.opacity(0.4))
  119. .businessBorder(cornerRadius: 10)
  120. .padding(.horizontal, 20)
  121. }
  122. // Success Toast Banner
  123. if let successMsg = boundSuccessMessage {
  124. HStack(spacing: 8) {
  125. Image(systemName: "checkmark.circle.fill")
  126. .foregroundStyle(Color.primary)
  127. Text(successMsg)
  128. .font(.system(size: 13, weight: .medium))
  129. .foregroundStyle(Color.primary)
  130. }
  131. .padding(.vertical, 10)
  132. .padding(.horizontal, 16)
  133. .background(Color.primary.opacity(0.1))
  134. .cornerRadius(8)
  135. .transition(.move(edge: .top).combined(with: .opacity))
  136. }
  137. }
  138. }
  139. .onAppear {
  140. bleManager.startScanning()
  141. }
  142. .onDisappear {
  143. bleManager.stopScanning()
  144. }
  145. .alert("无法绑定微光", isPresented: Binding(
  146. get: { bindingErrorMessage != nil },
  147. set: { if !$0 { bindingErrorMessage = nil } }
  148. )) {
  149. Button("知道了", role: .cancel) { bindingErrorMessage = nil }
  150. } message: {
  151. Text(bindingErrorMessage ?? "请稍后重试。")
  152. }
  153. }
  154. @ViewBuilder
  155. private var formalBindingSection: some View {
  156. VStack(alignment: .leading, spacing: 10) {
  157. Text("正式绑定设备(仅显示 YLF20)")
  158. .font(.system(size: 13, weight: .semibold))
  159. .foregroundStyle(Color.primary)
  160. if bleManager.discoveredDevices.isEmpty {
  161. Text(
  162. bleManager.observedPeripheralCount > 0
  163. ? "暂未发现名称以 YLF20 开头的设备。"
  164. : "正在等待 YLF20 设备广播…"
  165. )
  166. .font(.system(size: 11))
  167. .foregroundStyle(Color.secondary)
  168. .frame(maxWidth: .infinity, minHeight: 54, alignment: .center)
  169. } else {
  170. ScrollView {
  171. LazyVStack(spacing: 8) {
  172. ForEach(bleManager.discoveredDevices) { device in
  173. discoveredDeviceRow(device)
  174. }
  175. }
  176. }
  177. .frame(maxHeight: 150)
  178. }
  179. }
  180. .padding(14)
  181. .background(Color.cardBackground.opacity(0.4))
  182. .businessBorder(cornerRadius: 10)
  183. .padding(.horizontal, 20)
  184. }
  185. private func communicationDebugPanel(deviceID: String) -> some View {
  186. let logs = bleManager.communicationLogs(for: deviceID)
  187. return VStack(alignment: .leading, spacing: 10) {
  188. HStack {
  189. Text("绑定与协议通信调试")
  190. .font(.system(size: 13, weight: .semibold))
  191. .foregroundStyle(Color.primary)
  192. Spacer()
  193. Button(copiedCompatibilityLog ? "已复制" : "复制兼容日志") {
  194. copyCompatibilityLogs(deviceID: deviceID)
  195. }
  196. .font(.system(size: 11, weight: .medium))
  197. .disabled(compatibilityLogs(deviceID: deviceID).isEmpty)
  198. Button("重新测试") {
  199. bleManager.runProtocolDiagnostics(deviceID: deviceID)
  200. }
  201. .font(.system(size: 11, weight: .medium))
  202. .disabled(bindingDeviceID != nil)
  203. }
  204. ScrollViewReader { proxy in
  205. ScrollView {
  206. LazyVStack(alignment: .leading, spacing: 5) {
  207. ForEach(logs) { entry in
  208. HStack(alignment: .top, spacing: 7) {
  209. Text(entry.timestamp.formatted(date: .omitted, time: .standard))
  210. .foregroundStyle(Color.secondary)
  211. Text(entry.kind)
  212. .foregroundStyle(logColor(entry.kind))
  213. .frame(width: 38, alignment: .leading)
  214. Text(entry.message)
  215. .foregroundStyle(Color.primary)
  216. .textSelection(.enabled)
  217. }
  218. .font(.system(size: 9, design: .monospaced))
  219. .id(entry.id)
  220. }
  221. }
  222. }
  223. .onChange(of: logs.count) {
  224. if let last = logs.last {
  225. withAnimation {
  226. proxy.scrollTo(last.id, anchor: .bottom)
  227. }
  228. }
  229. }
  230. }
  231. .frame(height: 300)
  232. }
  233. .padding(14)
  234. .background(Color.cardBackground.opacity(0.4))
  235. .businessBorder(cornerRadius: 10)
  236. .padding(.horizontal, 20)
  237. }
  238. private func compatibilityLogs(deviceID: String) -> [BLECommunicationLogEntry] {
  239. let logs = bleManager.communicationLogs(for: deviceID)
  240. guard let startIndex = logs.lastIndex(where: {
  241. $0.message.contains("开始只读兼容探测")
  242. }) else {
  243. return []
  244. }
  245. return Array(logs[startIndex...])
  246. }
  247. private func copyCompatibilityLogs(deviceID: String) {
  248. let logs = compatibilityLogs(deviceID: deviceID)
  249. guard !logs.isEmpty else { return }
  250. let formatter = DateFormatter()
  251. formatter.locale = Locale(identifier: "en_US_POSIX")
  252. formatter.dateFormat = "HH:mm:ss.SSS"
  253. let lines = logs.map {
  254. "\(formatter.string(from: $0.timestamp)) [\($0.kind)] \($0.message)"
  255. }
  256. let header = [
  257. "YLF20 BLE 兼容探测日志",
  258. "设备标识:\(deviceID)",
  259. "导出时间:\(Date().formatted(date: .numeric, time: .standard))",
  260. String(repeating: "-", count: 36)
  261. ]
  262. UIPasteboard.general.string = (header + lines).joined(separator: "\n")
  263. copiedCompatibilityLog = true
  264. DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
  265. copiedCompatibilityLog = false
  266. }
  267. }
  268. private func logColor(_ kind: String) -> Color {
  269. switch kind {
  270. case "OK": return .green
  271. case "ERROR": return .red
  272. case "TX": return .blue
  273. case "RX": return .orange
  274. default: return .secondary
  275. }
  276. }
  277. private var scanStatusText: String {
  278. if bleManager.isScanning {
  279. return "正在扫描附近的蓝牙录音设备..."
  280. }
  281. switch bleManager.state {
  282. case .unknown, .resetting:
  283. return "正在初始化系统蓝牙..."
  284. case .poweredOn:
  285. return "扫描已停止"
  286. case .poweredOff:
  287. return "系统蓝牙未开启"
  288. case .unauthorized:
  289. return "没有蓝牙访问权限"
  290. case .unsupported:
  291. return "当前设备不支持 BLE 扫描"
  292. @unknown default:
  293. return "蓝牙状态不可用"
  294. }
  295. }
  296. private func observedAdvertisementRow(_ advertisement: ObservedBLEAdvertisement) -> some View {
  297. VStack(alignment: .leading, spacing: 3) {
  298. HStack(spacing: 8) {
  299. Text(advertisement.name)
  300. .font(.system(size: 12, weight: .medium))
  301. .foregroundStyle(Color.primary)
  302. .lineLimit(1)
  303. if advertisement.matchesSpark {
  304. Text("匹配 Spark")
  305. .font(.system(size: 9, weight: .semibold))
  306. .foregroundStyle(Color.green)
  307. .padding(.horizontal, 6)
  308. .padding(.vertical, 2)
  309. .background(Color.green.opacity(0.12))
  310. .clipShape(Capsule())
  311. }
  312. if !advertisement.isPresent {
  313. debugBadge("刚消失", color: .red)
  314. } else if advertisement.reappearanceCount > 0 {
  315. debugBadge("重新出现 ×\(advertisement.reappearanceCount)", color: .orange)
  316. } else if Date().timeIntervalSince(advertisement.firstSeenAt) < 8 {
  317. debugBadge("新出现", color: .blue)
  318. }
  319. if advertisement.isStrongSignal {
  320. debugBadge("强信号", color: .green)
  321. }
  322. Spacer()
  323. Text("\(advertisement.rssi) dBm")
  324. .font(.system(size: 10, design: .monospaced))
  325. .foregroundStyle(Color.secondary)
  326. }
  327. Text("设备:\(advertisement.id) · \(advertisement.lastSeenAt.formatted(date: .omitted, time: .standard))")
  328. .font(.system(size: 9, design: .monospaced))
  329. .foregroundStyle(Color.secondary.opacity(0.8))
  330. .textSelection(.enabled)
  331. Text(
  332. advertisement.serviceUUIDs.isEmpty
  333. ? "Service UUID:广播中未提供"
  334. : "Service UUID:\(advertisement.serviceUUIDs.joined(separator: ", "))"
  335. )
  336. .font(.system(size: 9, design: .monospaced))
  337. .foregroundStyle(Color.secondary.opacity(0.8))
  338. .textSelection(.enabled)
  339. }
  340. .padding(7)
  341. .background(Color.spaceBlack.opacity(0.35))
  342. .clipShape(RoundedRectangle(cornerRadius: 7))
  343. .opacity(advertisement.isPresent ? 1 : 0.65)
  344. }
  345. private func debugBadge(_ text: String, color: Color) -> some View {
  346. Text(text)
  347. .font(.system(size: 9, weight: .semibold))
  348. .foregroundStyle(color)
  349. .padding(.horizontal, 6)
  350. .padding(.vertical, 2)
  351. .background(color.opacity(0.12))
  352. .clipShape(Capsule())
  353. }
  354. private func discoveredDeviceRow(_ device: DiscoveredBLEDevice) -> some View {
  355. let isAlreadyBound = bleManager.boundDevices.contains(where: { $0.id == device.id })
  356. return HStack(spacing: 14) {
  357. ZStack {
  358. Circle()
  359. .fill(Color.primary.opacity(0.06))
  360. .frame(width: 42, height: 42)
  361. Image(systemName: "mic.fill")
  362. .font(.system(size: 16))
  363. .foregroundStyle(Color.primary)
  364. }
  365. VStack(alignment: .leading, spacing: 4) {
  366. Text("微光(Spark)")
  367. .font(.system(size: 14, weight: .medium))
  368. .foregroundStyle(Color.primary)
  369. HStack(spacing: 8) {
  370. Text("信号强度: \(device.rssi) dBm")
  371. .font(.system(size: 11, design: .monospaced))
  372. .foregroundStyle(Color.secondary)
  373. Text("•")
  374. .font(.system(size: 10))
  375. .foregroundStyle(Color.secondary)
  376. Text("设备标识: " + String(device.peripheralUUID.prefix(8)))
  377. .font(.system(size: 11, design: .monospaced))
  378. .foregroundStyle(Color.secondary)
  379. }
  380. Text("广播名称: \(device.name)")
  381. .font(.system(size: 10))
  382. .foregroundStyle(Color.secondary.opacity(0.8))
  383. }
  384. Spacer()
  385. if isAlreadyBound {
  386. Button("测试通信") {
  387. debugDeviceID = device.id
  388. bleManager.runProtocolDiagnostics(deviceID: device.id)
  389. }
  390. .font(.system(size: 12, weight: .medium))
  391. .disabled(bindingDeviceID != nil)
  392. } else {
  393. Button {
  394. guard let userId = authManager.currentUser?.id else { return }
  395. debugDeviceID = device.id
  396. boundSuccessMessage = nil
  397. bindingErrorMessage = nil
  398. bindingDeviceID = device.id
  399. bleManager.connectAndBind(device, userId: userId) { result in
  400. bindingDeviceID = nil
  401. switch result {
  402. case .success(let bound):
  403. Task {
  404. do {
  405. let remote = try await DeviceCloudService.shared.register(bound)
  406. bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: remote.id, error: nil)
  407. boundSuccessMessage = "设备已绑定并登记到云端"
  408. } catch {
  409. bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: nil, error: error.localizedDescription)
  410. boundSuccessMessage = "设备已在本机绑定,云端登记将在稍后重试"
  411. }
  412. }
  413. case .failure(let error):
  414. bindingErrorMessage = error.localizedDescription
  415. }
  416. }
  417. } label: {
  418. Group {
  419. if bindingDeviceID == device.id {
  420. ProgressView()
  421. .tint(Color.spaceBlack)
  422. } else {
  423. Text("绑定设备")
  424. .font(.system(size: 13, weight: .semibold))
  425. }
  426. }
  427. .foregroundStyle(Color.spaceBlack)
  428. .frame(minWidth: 72)
  429. .padding(.horizontal, 14)
  430. .padding(.vertical, 7)
  431. .background(Color.primary)
  432. .cornerRadius(6)
  433. }
  434. .disabled(bindingDeviceID != nil)
  435. }
  436. }
  437. .padding(14)
  438. .background(Color.cardBackground.opacity(0.3))
  439. .businessBorder(cornerRadius: 10)
  440. }
  441. }
  442. #Preview {
  443. DeviceScanView()
  444. }