262 lines
7.8 KiB
Swift
262 lines
7.8 KiB
Swift
// 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()
|
|
}
|