import SwiftUI import UIKit /// Modal sheet for scanning nearby BLE audio recording devices and binding them to the logged-in user. struct DeviceScanView: View { @Environment(\.dismiss) private var dismiss @ObservedObject var bleManager: BLEManager = .shared @ObservedObject var authManager: AuthManager = .shared @State private var boundSuccessMessage: String? @State private var bindingDeviceID: String? @State private var bindingErrorMessage: String? @State private var debugDeviceID: String? @State private var copiedCompatibilityLog = false var body: some View { ZStack { Color.spaceBlack.ignoresSafeArea() VStack(spacing: 20) { // Top Header HStack { VStack(alignment: .leading, spacing: 4) { Text("搜索与绑定录音设备") .font(.system(size: 18, weight: .bold)) .foregroundStyle(Color.primary) Text("请确保设备已开启蓝牙广播模式并靠拢 iPhone") .font(.system(size: 12)) .foregroundStyle(Color.secondary) } Spacer() Button { bleManager.stopScanning() dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 22)) .foregroundStyle(Color.secondary.opacity(0.6)) } } .padding(.horizontal, 20) .padding(.top, 20) // Scanning Status Indicator HStack(spacing: 12) { if bleManager.isScanning { ProgressView() .tint(Color.primary) } else { Image(systemName: "radiowaves.left.and.right") .font(.system(size: 16)) .foregroundStyle(Color.primary) } Text(scanStatusText) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.primary) Spacer() Button { if bleManager.isScanning { bleManager.stopScanning() } else { bleManager.startScanning() } } label: { Text(bleManager.isScanning ? "停止扫描" : "重新扫描") .font(.system(size: 12, weight: .regular)) .foregroundStyle(Color.primary) .padding(.horizontal, 10) .padding(.vertical, 6) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.primary.opacity(0.3), lineWidth: 1) ) } } .padding(14) .background(Color.cardBackground.opacity(0.4)) .businessBorder(cornerRadius: 10) .padding(.horizontal, 20) if let issue = bleManager.scanUnavailableMessage { VStack(alignment: .leading, spacing: 10) { Label(issue, systemImage: "exclamationmark.triangle.fill") .font(.system(size: 12)) .foregroundStyle(Color.orange) if bleManager.state == .unauthorized { Button("打开系统设置") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } .font(.system(size: 12, weight: .medium)) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .background(Color.orange.opacity(0.08)) .businessBorder(cornerRadius: 10) .padding(.horizontal, 20) } formalBindingSection if let debugDeviceID { communicationDebugPanel(deviceID: debugDeviceID) } else if !bleManager.observedAdvertisements.isEmpty { VStack(alignment: .leading, spacing: 10) { HStack { Text("BLE 广播调试") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.primary) Spacer() Text("\(bleManager.observedAdvertisements.count) 个设备 · 最多显示 50 个") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Color.secondary) } ScrollView { LazyVStack(spacing: 8) { ForEach(bleManager.observedAdvertisements) { advertisement in observedAdvertisementRow(advertisement) } } } .frame(height: 300) } .padding(14) .background(Color.cardBackground.opacity(0.4)) .businessBorder(cornerRadius: 10) .padding(.horizontal, 20) } // Success Toast Banner if let successMsg = boundSuccessMessage { HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Color.primary) Text(successMsg) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.primary) } .padding(.vertical, 10) .padding(.horizontal, 16) .background(Color.primary.opacity(0.1)) .cornerRadius(8) .transition(.move(edge: .top).combined(with: .opacity)) } } } .onAppear { bleManager.startScanning() } .onDisappear { bleManager.stopScanning() } .alert("无法绑定微光", isPresented: Binding( get: { bindingErrorMessage != nil }, set: { if !$0 { bindingErrorMessage = nil } } )) { Button("知道了", role: .cancel) { bindingErrorMessage = nil } } message: { Text(bindingErrorMessage ?? "请稍后重试。") } } @ViewBuilder private var formalBindingSection: some View { VStack(alignment: .leading, spacing: 10) { Text("正式绑定设备(仅显示 YLF20)") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.primary) if bleManager.discoveredDevices.isEmpty { Text( bleManager.observedPeripheralCount > 0 ? "暂未发现名称以 YLF20 开头的设备。" : "正在等待 YLF20 设备广播…" ) .font(.system(size: 11)) .foregroundStyle(Color.secondary) .frame(maxWidth: .infinity, minHeight: 54, alignment: .center) } else { ScrollView { LazyVStack(spacing: 8) { ForEach(bleManager.discoveredDevices) { device in discoveredDeviceRow(device) } } } .frame(maxHeight: 150) } } .padding(14) .background(Color.cardBackground.opacity(0.4)) .businessBorder(cornerRadius: 10) .padding(.horizontal, 20) } private func communicationDebugPanel(deviceID: String) -> some View { let logs = bleManager.communicationLogs(for: deviceID) return VStack(alignment: .leading, spacing: 10) { HStack { Text("绑定与协议通信调试") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.primary) Spacer() Button(copiedCompatibilityLog ? "已复制" : "复制兼容日志") { copyCompatibilityLogs(deviceID: deviceID) } .font(.system(size: 11, weight: .medium)) .disabled(compatibilityLogs(deviceID: deviceID).isEmpty) Button("重新测试") { bleManager.runProtocolDiagnostics(deviceID: deviceID) } .font(.system(size: 11, weight: .medium)) .disabled(bindingDeviceID != nil) } ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 5) { ForEach(logs) { entry in HStack(alignment: .top, spacing: 7) { Text(entry.timestamp.formatted(date: .omitted, time: .standard)) .foregroundStyle(Color.secondary) Text(entry.kind) .foregroundStyle(logColor(entry.kind)) .frame(width: 38, alignment: .leading) Text(entry.message) .foregroundStyle(Color.primary) .textSelection(.enabled) } .font(.system(size: 9, design: .monospaced)) .id(entry.id) } } } .onChange(of: logs.count) { if let last = logs.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } .frame(height: 300) } .padding(14) .background(Color.cardBackground.opacity(0.4)) .businessBorder(cornerRadius: 10) .padding(.horizontal, 20) } private func compatibilityLogs(deviceID: String) -> [BLECommunicationLogEntry] { let logs = bleManager.communicationLogs(for: deviceID) guard let startIndex = logs.lastIndex(where: { $0.message.contains("开始只读兼容探测") }) else { return [] } return Array(logs[startIndex...]) } private func copyCompatibilityLogs(deviceID: String) { let logs = compatibilityLogs(deviceID: deviceID) guard !logs.isEmpty else { return } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "HH:mm:ss.SSS" let lines = logs.map { "\(formatter.string(from: $0.timestamp)) [\($0.kind)] \($0.message)" } let header = [ "YLF20 BLE 兼容探测日志", "设备标识:\(deviceID)", "导出时间:\(Date().formatted(date: .numeric, time: .standard))", String(repeating: "-", count: 36) ] UIPasteboard.general.string = (header + lines).joined(separator: "\n") copiedCompatibilityLog = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copiedCompatibilityLog = false } } private func logColor(_ kind: String) -> Color { switch kind { case "OK": return .green case "ERROR": return .red case "TX": return .blue case "RX": return .orange default: return .secondary } } private var scanStatusText: String { if bleManager.isScanning { return "正在扫描附近的蓝牙录音设备..." } switch bleManager.state { case .unknown, .resetting: return "正在初始化系统蓝牙..." case .poweredOn: return "扫描已停止" case .poweredOff: return "系统蓝牙未开启" case .unauthorized: return "没有蓝牙访问权限" case .unsupported: return "当前设备不支持 BLE 扫描" @unknown default: return "蓝牙状态不可用" } } private func observedAdvertisementRow(_ advertisement: ObservedBLEAdvertisement) -> some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 8) { Text(advertisement.name) .font(.system(size: 12, weight: .medium)) .foregroundStyle(Color.primary) .lineLimit(1) if advertisement.matchesSpark { Text("匹配 Spark") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(Color.green) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.green.opacity(0.12)) .clipShape(Capsule()) } if !advertisement.isPresent { debugBadge("刚消失", color: .red) } else if advertisement.reappearanceCount > 0 { debugBadge("重新出现 ×\(advertisement.reappearanceCount)", color: .orange) } else if Date().timeIntervalSince(advertisement.firstSeenAt) < 8 { debugBadge("新出现", color: .blue) } if advertisement.isStrongSignal { debugBadge("强信号", color: .green) } Spacer() Text("\(advertisement.rssi) dBm") .font(.system(size: 10, design: .monospaced)) .foregroundStyle(Color.secondary) } Text("设备:\(advertisement.id) · \(advertisement.lastSeenAt.formatted(date: .omitted, time: .standard))") .font(.system(size: 9, design: .monospaced)) .foregroundStyle(Color.secondary.opacity(0.8)) .textSelection(.enabled) Text( advertisement.serviceUUIDs.isEmpty ? "Service UUID:广播中未提供" : "Service UUID:\(advertisement.serviceUUIDs.joined(separator: ", "))" ) .font(.system(size: 9, design: .monospaced)) .foregroundStyle(Color.secondary.opacity(0.8)) .textSelection(.enabled) } .padding(7) .background(Color.spaceBlack.opacity(0.35)) .clipShape(RoundedRectangle(cornerRadius: 7)) .opacity(advertisement.isPresent ? 1 : 0.65) } private func debugBadge(_ text: String, color: Color) -> some View { Text(text) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(color) .padding(.horizontal, 6) .padding(.vertical, 2) .background(color.opacity(0.12)) .clipShape(Capsule()) } private func discoveredDeviceRow(_ device: DiscoveredBLEDevice) -> some View { let isAlreadyBound = bleManager.boundDevices.contains(where: { $0.id == device.id }) return HStack(spacing: 14) { ZStack { Circle() .fill(Color.primary.opacity(0.06)) .frame(width: 42, height: 42) Image(systemName: "mic.fill") .font(.system(size: 16)) .foregroundStyle(Color.primary) } VStack(alignment: .leading, spacing: 4) { Text("微光(Spark)") .font(.system(size: 14, weight: .medium)) .foregroundStyle(Color.primary) HStack(spacing: 8) { Text("信号强度: \(device.rssi) dBm") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Color.secondary) Text("•") .font(.system(size: 10)) .foregroundStyle(Color.secondary) Text("设备标识: " + String(device.peripheralUUID.prefix(8))) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Color.secondary) } Text("广播名称: \(device.name)") .font(.system(size: 10)) .foregroundStyle(Color.secondary.opacity(0.8)) } Spacer() if isAlreadyBound { Button("测试通信") { debugDeviceID = device.id bleManager.runProtocolDiagnostics(deviceID: device.id) } .font(.system(size: 12, weight: .medium)) .disabled(bindingDeviceID != nil) } else { Button { guard let userId = authManager.currentUser?.id else { return } debugDeviceID = device.id boundSuccessMessage = nil bindingErrorMessage = nil bindingDeviceID = device.id bleManager.connectAndBind(device, userId: userId) { result in bindingDeviceID = nil switch result { case .success(let bound): Task { do { let remote = try await DeviceCloudService.shared.register(bound) bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: remote.id, error: nil) boundSuccessMessage = "设备已绑定并登记到云端" } catch { bleManager.updateCloudRegistration(deviceID: bound.id, cloudID: nil, error: error.localizedDescription) boundSuccessMessage = "设备已在本机绑定,云端登记将在稍后重试" } } case .failure(let error): bindingErrorMessage = error.localizedDescription } } } label: { Group { if bindingDeviceID == device.id { ProgressView() .tint(Color.spaceBlack) } else { Text("绑定设备") .font(.system(size: 13, weight: .semibold)) } } .foregroundStyle(Color.spaceBlack) .frame(minWidth: 72) .padding(.horizontal, 14) .padding(.vertical, 7) .background(Color.primary) .cornerRadius(6) } .disabled(bindingDeviceID != nil) } } .padding(14) .background(Color.cardBackground.opacity(0.3)) .businessBorder(cornerRadius: 10) } } #Preview { DeviceScanView() }