DeviceScanView.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import SwiftUI
  2. /// Modal sheet for scanning nearby BLE audio recording devices and binding them to the logged-in user.
  3. struct DeviceScanView: View {
  4. @Environment(\.dismiss) private var dismiss
  5. @ObservedObject var bleManager: BLEManager = .shared
  6. @ObservedObject var authManager: AuthManager = .shared
  7. @State private var boundSuccessMessage: String?
  8. @State private var bindingDeviceID: String?
  9. @State private var bindingErrorMessage: String?
  10. var body: some View {
  11. ZStack {
  12. Color.spaceBlack.ignoresSafeArea()
  13. VStack(spacing: 20) {
  14. // Top Header
  15. HStack {
  16. VStack(alignment: .leading, spacing: 4) {
  17. Text("搜索与绑定录音设备")
  18. .font(.system(size: 18, weight: .bold))
  19. .foregroundStyle(Color.primary)
  20. Text("请确保设备已开启蓝牙广播模式并靠拢 iPhone")
  21. .font(.system(size: 12))
  22. .foregroundStyle(Color.secondary)
  23. }
  24. Spacer()
  25. Button {
  26. bleManager.stopScanning()
  27. dismiss()
  28. } label: {
  29. Image(systemName: "xmark.circle.fill")
  30. .font(.system(size: 22))
  31. .foregroundStyle(Color.secondary.opacity(0.6))
  32. }
  33. }
  34. .padding(.horizontal, 20)
  35. .padding(.top, 20)
  36. // Scanning Status Indicator
  37. HStack(spacing: 12) {
  38. if bleManager.isScanning {
  39. ProgressView()
  40. .tint(Color.primary)
  41. } else {
  42. Image(systemName: "radiowaves.left.and.right")
  43. .font(.system(size: 16))
  44. .foregroundStyle(Color.primary)
  45. }
  46. Text(bleManager.isScanning ? "正在扫描附近的蓝牙录音设备..." : "扫描已停止")
  47. .font(.system(size: 13, weight: .medium))
  48. .foregroundStyle(Color.primary)
  49. Spacer()
  50. Button {
  51. if bleManager.isScanning {
  52. bleManager.stopScanning()
  53. } else {
  54. bleManager.startScanning()
  55. }
  56. } label: {
  57. Text(bleManager.isScanning ? "停止扫描" : "重新扫描")
  58. .font(.system(size: 12, weight: .regular))
  59. .foregroundStyle(Color.primary)
  60. .padding(.horizontal, 10)
  61. .padding(.vertical, 6)
  62. .overlay(
  63. RoundedRectangle(cornerRadius: 6)
  64. .stroke(Color.primary.opacity(0.3), lineWidth: 1)
  65. )
  66. }
  67. }
  68. .padding(14)
  69. .background(Color.cardBackground.opacity(0.4))
  70. .businessBorder(cornerRadius: 10)
  71. .padding(.horizontal, 20)
  72. // Success Toast Banner
  73. if let successMsg = boundSuccessMessage {
  74. HStack(spacing: 8) {
  75. Image(systemName: "checkmark.circle.fill")
  76. .foregroundStyle(Color.primary)
  77. Text(successMsg)
  78. .font(.system(size: 13, weight: .medium))
  79. .foregroundStyle(Color.primary)
  80. }
  81. .padding(.vertical, 10)
  82. .padding(.horizontal, 16)
  83. .background(Color.primary.opacity(0.1))
  84. .cornerRadius(8)
  85. .transition(.move(edge: .top).combined(with: .opacity))
  86. }
  87. // Discovered Devices List
  88. if bleManager.discoveredDevices.isEmpty {
  89. VStack(spacing: 12) {
  90. Spacer()
  91. Image(systemName: "antenna.radiowaves.left.and.right")
  92. .font(.system(size: 36, weight: .ultraLight))
  93. .foregroundStyle(Color.secondary.opacity(0.4))
  94. Text("未搜索到可用的蓝牙录音设备")
  95. .font(.system(size: 13))
  96. .foregroundStyle(Color.secondary)
  97. Spacer()
  98. }
  99. } else {
  100. ScrollView {
  101. VStack(spacing: 12) {
  102. ForEach(bleManager.discoveredDevices) { device in
  103. discoveredDeviceRow(device)
  104. }
  105. }
  106. .padding(.horizontal, 20)
  107. .padding(.bottom, 20)
  108. }
  109. }
  110. }
  111. }
  112. .onAppear {
  113. bleManager.startScanning()
  114. }
  115. .onDisappear {
  116. bleManager.stopScanning()
  117. }
  118. .alert("无法绑定微光", isPresented: Binding(
  119. get: { bindingErrorMessage != nil },
  120. set: { if !$0 { bindingErrorMessage = nil } }
  121. )) {
  122. Button("知道了", role: .cancel) { bindingErrorMessage = nil }
  123. } message: {
  124. Text(bindingErrorMessage ?? "请稍后重试。")
  125. }
  126. }
  127. private func discoveredDeviceRow(_ device: DiscoveredBLEDevice) -> some View {
  128. let isAlreadyBound = bleManager.boundDevices.contains(where: { $0.id == device.id })
  129. return HStack(spacing: 14) {
  130. ZStack {
  131. Circle()
  132. .fill(Color.primary.opacity(0.06))
  133. .frame(width: 42, height: 42)
  134. Image(systemName: "mic.fill")
  135. .font(.system(size: 16))
  136. .foregroundStyle(Color.primary)
  137. }
  138. VStack(alignment: .leading, spacing: 4) {
  139. Text("微光(Spark)")
  140. .font(.system(size: 14, weight: .medium))
  141. .foregroundStyle(Color.primary)
  142. HStack(spacing: 8) {
  143. Text("信号强度: \(device.rssi) dBm")
  144. .font(.system(size: 11, design: .monospaced))
  145. .foregroundStyle(Color.secondary)
  146. Text("•")
  147. .font(.system(size: 10))
  148. .foregroundStyle(Color.secondary)
  149. Text("设备标识: " + String(device.peripheralUUID.prefix(8)))
  150. .font(.system(size: 11, design: .monospaced))
  151. .foregroundStyle(Color.secondary)
  152. }
  153. Text("广播名称: \(device.name)")
  154. .font(.system(size: 10))
  155. .foregroundStyle(Color.secondary.opacity(0.8))
  156. }
  157. Spacer()
  158. if isAlreadyBound {
  159. Text("已绑定")
  160. .font(.system(size: 12, weight: .regular))
  161. .foregroundStyle(Color.secondary)
  162. .padding(.horizontal, 12)
  163. .padding(.vertical, 6)
  164. } else {
  165. Button {
  166. guard let userId = authManager.currentUser?.id else { return }
  167. bindingDeviceID = device.id
  168. bleManager.connectAndBind(device, userId: userId) { result in
  169. bindingDeviceID = nil
  170. switch result {
  171. case .success(let bound):
  172. Task {
  173. do {
  174. let remote = try await DeviceCloudService.shared.register(bound)
  175. bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: remote.id, error: nil)
  176. boundSuccessMessage = "设备已绑定并登记到云端"
  177. } catch {
  178. bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: nil, error: error.localizedDescription)
  179. boundSuccessMessage = "设备已在本机绑定,云端登记将在稍后重试"
  180. }
  181. DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
  182. dismiss()
  183. }
  184. }
  185. case .failure(let error):
  186. bindingErrorMessage = error.localizedDescription
  187. }
  188. }
  189. } label: {
  190. Group {
  191. if bindingDeviceID == device.id {
  192. ProgressView()
  193. .tint(Color.spaceBlack)
  194. } else {
  195. Text("绑定设备")
  196. .font(.system(size: 13, weight: .semibold))
  197. }
  198. }
  199. .foregroundStyle(Color.spaceBlack)
  200. .frame(minWidth: 72)
  201. .padding(.horizontal, 14)
  202. .padding(.vertical, 7)
  203. .background(Color.primary)
  204. .cornerRadius(6)
  205. }
  206. .disabled(bindingDeviceID != nil)
  207. }
  208. }
  209. .padding(14)
  210. .background(Color.cardBackground.opacity(0.3))
  211. .businessBorder(cornerRadius: 10)
  212. }
  213. }
  214. #Preview {
  215. DeviceScanView()
  216. }