mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-26 22:30:50 +00:00
- Pairing now allows direct entry/scan with inline progress and error feedback - PairingConfirmView is now deep-link-only for security confirmations - Session list rows: move thinking spinner and unread indicator to trailing edge - Extracted shared PairingAttempt state for reuse - Updated strings: "Continue" → "Pair" button label - Removed unused PendingPairing.source Claude-Session: https://claude.ai/code/session_01ViH4oaLSpTcksxDFEMQgeD
135 lines
5.1 KiB
Swift
135 lines
5.1 KiB
Swift
import HapiClient
|
|
import SwiftUI
|
|
|
|
/// Confirmation step for the deep-link path (`hapicompanion://bind`) — the
|
|
/// one entry where an external link initiates pairing, so an explicit user
|
|
/// confirmation stays in between. Manual entry and QR scan pair inline.
|
|
///
|
|
/// On success `AppModel` clears all pairing presentation state and flips to
|
|
/// `.paired`, which dismisses this view's container — nothing to do here.
|
|
struct PairingConfirmView: View {
|
|
let pending: PendingPairing
|
|
|
|
@Environment(AppModel.self) private var model
|
|
@State private var attempt = PairingAttempt()
|
|
|
|
private var isAddingAnotherHub: Bool {
|
|
if case .paired = model.state { return true }
|
|
return false
|
|
}
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
LabeledContent("Hub") {
|
|
Text(pending.hubUrl)
|
|
.multilineTextAlignment(.trailing)
|
|
.textSelection(.enabled)
|
|
}
|
|
LabeledContent("Access token") {
|
|
Text(HubDisplay.maskedToken(pending.accessToken))
|
|
.monospaced()
|
|
}
|
|
} footer: {
|
|
Text(isAddingAnotherHub
|
|
? String(localized: "This hub will be added to your paired hubs and become active.")
|
|
: String(localized: "The app checks the hub is reachable, then exchanges the token for a session."))
|
|
}
|
|
|
|
if let failure = attempt.failure {
|
|
Section {
|
|
PairingErrorView(failure: failure)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
attempt.pair(model, hubUrl: pending.hubUrl, accessToken: pending.accessToken)
|
|
} label: {
|
|
if attempt.isPairing {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
Text("Pairing…")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
Text(attempt.failure == nil ? String(localized: "Pair") : String(localized: "Try Again"))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.disabled(attempt.isPairing)
|
|
}
|
|
}
|
|
.navigationTitle(isAddingAnotherHub ? String(localized: "Add Hub") : String(localized: "Pair"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.interactiveDismissDisabled(attempt.isPairing)
|
|
}
|
|
}
|
|
|
|
/// The pairing screen's error states, one presentation per
|
|
/// ``PairingFailure`` case.
|
|
struct PairingErrorView: View {
|
|
let failure: PairingFailure
|
|
|
|
var body: some View {
|
|
Label {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.headline)
|
|
Text(message)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} icon: {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
private var icon: String {
|
|
switch failure {
|
|
case .invalidHubURL: "questionmark.circle"
|
|
case .unreachable: "wifi.exclamationmark"
|
|
case .protocolMismatch: "arrow.triangle.2.circlepath"
|
|
case .invalidAccessToken: "key"
|
|
case .hubError: "exclamationmark.triangle"
|
|
case .storageFailure: "externaldrive.badge.xmark"
|
|
}
|
|
}
|
|
|
|
private var title: String {
|
|
switch failure {
|
|
case .invalidHubURL: String(localized: "Invalid hub URL")
|
|
case .unreachable: String(localized: "Hub unreachable")
|
|
case .protocolMismatch: String(localized: "Version mismatch")
|
|
case .invalidAccessToken: String(localized: "Token rejected")
|
|
case .hubError: String(localized: "Hub error")
|
|
case .storageFailure: String(localized: "Could not save")
|
|
}
|
|
}
|
|
|
|
private var message: String {
|
|
switch failure {
|
|
case .invalidHubURL:
|
|
String(localized: "Enter the hub's full address, e.g. http://192.168.1.20:3006.")
|
|
case .unreachable:
|
|
String(localized: "No HAPI hub answered at this address. Check that the hub is running and that this device can reach it (same network, or the relay tunnel is up).")
|
|
case .protocolMismatch(let hubVersion, let supportedVersion):
|
|
String(
|
|
format: String(localized: "The hub speaks protocol v%lld, this app supports v%lld. Update the older side, then try again."),
|
|
Int64(hubVersion),
|
|
Int64(supportedVersion)
|
|
)
|
|
case .invalidAccessToken:
|
|
String(localized: "The hub rejected this access token — it may have been rotated. Get a fresh pairing code from the hub and try again.")
|
|
case .hubError(let status):
|
|
String(
|
|
format: String(localized: "The hub answered unexpectedly (HTTP %lld). Try again in a moment."),
|
|
Int64(status)
|
|
)
|
|
case .storageFailure:
|
|
String(localized: "Pairing succeeded but the credentials could not be stored on this device. Try again.")
|
|
}
|
|
}
|
|
}
|