diff --git a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift index 5ae082f..77aafa4 100644 --- a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift +++ b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift @@ -2,34 +2,39 @@ import SwiftUI import AVFoundation import NucleicProtocol +/// Why the live camera feed isn't showing, so the scanner never sits on a silent black screen. +enum ScannerCameraState: Equatable { + case scanning // feed is live + case denied // permission off — needs Settings + case unavailable // no camera on this device (e.g. the Simulator) +} + /// Camera QR scanner (UX_IOS §7). Decodes the Mac's `nucleic://pair?d=…` QR into a /// `PairingPayload` and hands it back exactly once. struct PairingScannerView: View { let onScanned: (PairingPayload) -> Void @Environment(\.dismiss) private var dismiss @State private var error: String? + @State private var cameraState: ScannerCameraState = .scanning var body: some View { NavigationStack { ZStack { - QRScannerRepresentable { value in handle(value) } + Color.black.ignoresSafeArea() + QRScannerRepresentable(onFound: handle, onState: { cameraState = $0 }) .ignoresSafeArea() VStack { Spacer() - RoundedRectangle(cornerRadius: 16) - .stroke(.white.opacity(0.8), lineWidth: 3) - .frame(width: 240, height: 240) - Spacer() - if let error { - Text(error).foregroundStyle(.white) - .padding().background(.red.opacity(0.8), in: Capsule()) - } else { - Text("Point at the QR code on your Mac") - .foregroundStyle(.white) - .padding().background(.black.opacity(0.5), in: Capsule()) + if cameraState == .scanning { + RoundedRectangle(cornerRadius: 16) + .stroke(.white.opacity(0.8), lineWidth: 3) + .frame(width: 240, height: 240) } + Spacer() + statusBanner Spacer().frame(height: 40) } + .padding(.horizontal, 24) } .navigationTitle("Scan to pair") .navigationBarTitleDisplayMode(.inline) @@ -37,6 +42,41 @@ struct PairingScannerView: View { } } + @ViewBuilder + private var statusBanner: some View { + if let error { + banner(error, tint: .red.opacity(0.85)) + } else { + switch cameraState { + case .scanning: + banner("Point at the QR code on your Mac", tint: .black.opacity(0.5)) + case .unavailable: + VStack(spacing: 12) { + banner("The camera isn't available here — pairing needs a real iPhone, not the Simulator.", + tint: .black.opacity(0.6)) + } + case .denied: + VStack(spacing: 12) { + banner("Camera access is off. Enable it to scan the pairing code.", tint: .black.opacity(0.6)) + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + } + } + } + + private func banner(_ text: String, tint: Color) -> some View { + Text(text) + .multilineTextAlignment(.center) + .foregroundStyle(.white) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(tint, in: RoundedRectangle(cornerRadius: 16)) + } + private func handle(_ value: String) { guard let payload = try? PairingPayload(qrString: value) else { error = "That isn't a Nucleic pairing code"; return @@ -48,10 +88,12 @@ struct PairingScannerView: View { /// UIKit bridge for the AVCaptureSession metadata scan. struct QRScannerRepresentable: UIViewControllerRepresentable { let onFound: (String) -> Void + let onState: (ScannerCameraState) -> Void func makeUIViewController(context: Context) -> QRScannerController { let controller = QRScannerController() controller.onFound = onFound + controller.onState = onState return controller } func updateUIViewController(_ controller: QRScannerController, context: Context) {} @@ -59,18 +101,45 @@ struct QRScannerRepresentable: UIViewControllerRepresentable { final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onFound: ((String) -> Void)? + var onState: ((ScannerCameraState) -> Void)? private let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session") + private var preview: AVCaptureVideoPreviewLayer? private var didFind = false override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .black + // Gate on camera authorization first — without this the prompt may never appear and the + // feed silently fails (and on a denied device it would just stay black). + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + granted ? self?.configureSession() : self?.report(.denied) + } + } + case .denied, .restricted: + report(.denied) + @unknown default: + report(.denied) + } + } + + /// Build the capture graph. Reports `.unavailable` when there's no camera (the Simulator) so + /// the UI can explain the black screen instead of leaving the user guessing. + private func configureSession() { guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } + session.canAddInput(input) else { + report(.unavailable); return + } session.addInput(input) let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { return } + guard session.canAddOutput(output) else { report(.unavailable); return } session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: .main) output.metadataObjectTypes = [.qr] @@ -79,13 +148,26 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject preview.videoGravity = .resizeAspectFill preview.frame = view.layer.bounds view.layer.addSublayer(preview) + self.preview = preview - Task.detached { [session] in session.startRunning() } + report(.scanning) + sessionQueue.async { [session] in session.startRunning() } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // Keep the preview filling the view across layout/rotation (setting it once in viewDidLoad + // left it mis-sized when bounds weren't final yet). + preview?.frame = view.layer.bounds } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - session.stopRunning() + sessionQueue.async { [session] in session.stopRunning() } + } + + private func report(_ state: ScannerCameraState) { + onState?(state) } func metadataOutput(