// Sources/UI/Pairing/PairingFlowView.swift // T-2.2: SwiftUI pairing flow (idle → scanning → pairing → success/error) import SwiftUI // MARK: - State machine private enum PairingState: Sendable { case idle case scanning case pairing(qrPayload: QRPayload) case success(credential: SidecarCredential) case error(message: String, qrPayload: QRPayload?) } /// Intermediate value capturing a parsed QR result. private struct QRPayload: Sendable { let host: String let port: Int let pairingToken: String let fingerprint: String let name: String } // MARK: - View struct PairingFlowView: View { /// Called after the credential is saved to Keychain. var onDismiss: (() -> Void)? @State private var state: PairingState = .idle private let service = PairingService() var body: some View { Group { switch state { case .idle: idleView case .scanning: scanningView case .pairing: pairingView case .success(let credential): successView(credential: credential) case .error(let message, let payload): errorView(message: message, retryPayload: payload) } } .animation(.default, value: stateTag) } // MARK: - Sub-views private var idleView: some View { VStack(spacing: 24) { Image(systemName: "qrcode.viewfinder") .resizable() .scaledToFit() .frame(width: 100, height: 100) .foregroundStyle(.secondary) Text("Pair with Pi Remote") .font(.title2) .bold() Text("Scan the QR code shown by `pi-remote pair` on your server.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) Button(action: { state = .scanning }) { Label("Tap to Scan", systemImage: "camera") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) } .padding(32) } private var scanningView: some View { ZStack(alignment: .top) { QRScannerView { rawString in handleQRScan(rawString) } .ignoresSafeArea() VStack { HStack { Button(action: { state = .idle }) { Label("Cancel", systemImage: "xmark.circle.fill") .labelStyle(.iconOnly) .font(.title) .foregroundStyle(.white) } Spacer() } .padding() Text("Point at the pi-remote QR code") .font(.subheadline) .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(.ultraThinMaterial, in: Capsule()) .padding(.top, 4) } } } private var pairingView: some View { VStack(spacing: 20) { ProgressView() .controlSize(.large) Text("Pairing…") .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private func successView(credential: SidecarCredential) -> some View { VStack(spacing: 24) { Image(systemName: "checkmark.circle.fill") .resizable() .scaledToFit() .frame(width: 80, height: 80) .foregroundStyle(.green) Text("Connected to \(credential.sidecarName)") .font(.title2) .bold() .multilineTextAlignment(.center) Text("Paired on \(credential.pairedAt.formatted(date: .abbreviated, time: .shortened))") .foregroundStyle(.secondary) .font(.subheadline) Button("Done") { onDismiss?() } .buttonStyle(.borderedProminent) .controlSize(.large) } .padding(32) } private func errorView(message: String, retryPayload: QRPayload?) -> some View { VStack(spacing: 24) { Image(systemName: "exclamationmark.triangle.fill") .resizable() .scaledToFit() .frame(width: 80, height: 80) .foregroundStyle(.orange) Text("Pairing Failed") .font(.title2) .bold() Text(message) .multilineTextAlignment(.center) .foregroundStyle(.secondary) Button(action: retryAction(for: retryPayload)) { Label("Retry", systemImage: "arrow.counterclockwise") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) .controlSize(.large) } .padding(32) } // MARK: - Logic private func retryAction(for payload: QRPayload?) -> () -> Void { if let payload { // Retry the exchange with the already-parsed QR data. return { startExchange(payload: payload) } } else { // QR parse failed — let the user scan again. return { state = .scanning } } } private func handleQRScan(_ rawString: String) { let payload: QRPayload do { let parsed = try PairingService.parseQR(rawString) payload = QRPayload( host: parsed.host, port: parsed.port, pairingToken: parsed.pairingToken, fingerprint: parsed.fingerprint, name: parsed.name ) } catch { state = .error(message: "Invalid QR code — please try again.", qrPayload: nil) return } startExchange(payload: payload) } private func startExchange(payload: QRPayload) { state = .pairing(qrPayload: payload) let deviceName = UIDevice.current.name Task { @MainActor in do { let credential = try await service.exchange( host: payload.host, port: payload.port, pairingToken: payload.pairingToken, fingerprint: payload.fingerprint, name: payload.name, deviceName: deviceName ) try Keychain.shared.save(credential, key: Keychain.credentialKey) state = .success(credential: credential) } catch let err as PairingError { state = .error(message: friendlyMessage(for: err), qrPayload: payload) } catch { state = .error(message: error.localizedDescription, qrPayload: payload) } } } private func friendlyMessage(for error: PairingError) -> String { switch error { case .invalidQR: return "The QR code doesn't look like a pi-remote URL." case .networkError(let underlying): return "Network error: \(underlying.localizedDescription)" case .serverError(let code, let body): return "Server returned \(code): \(body)" case .decodingFailed: return "Couldn't understand the server's response." } } // MARK: - Helpers /// Computed tag for `animation(value:)` — just needs to change when state changes. private var stateTag: Int { switch state { case .idle: return 0 case .scanning: return 1 case .pairing: return 2 case .success: return 3 case .error: return 4 } } } // MARK: - Preview #Preview { PairingFlowView() }