| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- 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()
- }
|