pi-remote-ios/Sources/UI/Pairing/PairingFlowView.swift

262 lines
7.9 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 with the new credential after pairing succeeds.
var onSuccess: ((SidecarCredential) -> 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") {
onSuccess?(credential)
}
.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()
}