Files
hapi/ios/Hapi/Features/Pairing/ManualEntryView.swift
T

197 lines
7.8 KiB
Swift

import HapiClient
import SwiftUI
/// Manual pairing path for hubs without `--relay` (nothing to scan): type or
/// paste the hub URL and the access token the hub prints at startup, then
/// pair directly with inline progress and error states.
struct ManualEntryView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AppModel.self) private var model
@State private var attempt = PairingAttempt()
var body: some View {
NavigationStack {
ManualPairingFormView(isPairing: attempt.isPairing, failure: attempt.failure) {
attempt.failure = nil
} onPair: { submission in
attempt.pair(model, hubUrl: submission.hubURL, accessToken: submission.accessToken)
}
.navigationTitle("Enter Hub Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
.disabled(attempt.isPairing)
}
}
.interactiveDismissDisabled(attempt.isPairing)
}
}
}
/// The production form also runs without AppModel in app-hosted interaction
/// tests. Only the explicit submit action can invoke onPair.
struct ManualPairingFormView: View {
let isPairing: Bool
let failure: PairingFailure?
var onEdit: () -> Void
var onPair: (ManualPairingForm.Submission) -> Void
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.locale) private var locale
@State private var form = ManualPairingForm()
@State private var focusedField: ManualPairingForm.Field? = .address
var body: some View {
Form {
Section {
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 0))
: AnyLayout(HStackLayout(spacing: 12))
layout {
Menu {
Picker("Connection protocol", selection: Binding(
get: { form.scheme },
set: { value in
guard !isPairing else { return }
form.selectScheme(value)
onEdit()
}
)) {
ForEach(ManualPairingForm.Scheme.allCases, id: \.self) { scheme in
Text(verbatim: scheme.prefix).tag(scheme)
}
}
} label: {
HStack(spacing: 4) {
Text(verbatim: form.scheme.prefix)
Image(systemName: "chevron.down").font(.caption)
}
.fixedSize()
.frame(minHeight: 44)
}
.accessibilityLabel("Connection protocol")
.accessibilityValue(form.scheme.prefix)
.accessibilityIdentifier("pairing.scheme")
.disabled(isPairing)
input(.address)
}
inputError(for: .address)
} header: {
Text("Hub URL")
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Enter your hub address without the protocol, or paste a full URL or pairing link.")
if form.scheme == .http {
Label("HTTP is unencrypted and may be restricted by iOS. HTTPS is recommended.", systemImage: "exclamationmark.triangle")
}
}
}
Section {
input(.accessToken)
inputError(for: .accessToken)
} header: {
Text("Access token")
} footer: {
Text("Printed by the hub at startup, or available in web Settings → Companion Pairing. You can also paste a pairing link here.")
}
if form.didImportLink {
Section {
Label("Pairing details filled in. Review them, then tap Pair.", systemImage: "checkmark.circle")
.foregroundStyle(.secondary)
.accessibilityIdentifier("pairing.imported")
}
}
if let failure {
Section { PairingErrorView(failure: failure) }
}
Section {
Button(action: pairNow) {
if isPairing {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Pairing…")
}
.frame(maxWidth: .infinity)
} else {
Text(failure == nil ? LocalizedStringKey("Pair") : LocalizedStringKey("Try Again"))
.frame(maxWidth: .infinity)
}
}
.accessibilityIdentifier("pairing.submit")
.disabled(form.submission == nil || isPairing)
}
}
}
private func input(_ field: ManualPairingForm.Field) -> some View {
PairingTextField(
text: Binding(get: { form.text(for: field) }, set: { value in
guard !isPairing else { return }
form.edit(value, in: field)
onEdit()
}),
focusedField: $focusedField,
field: field,
placeholder: field == .address
? String(localized: LocalizedStringResource("Domain or IP address", locale: locale))
: String(localized: LocalizedStringResource("Access token", locale: locale)),
accessibilityLabel: field == .address
? String(localized: LocalizedStringResource("Hub URL", locale: locale))
: String(localized: LocalizedStringResource("Access token", locale: locale)),
isEnabled: !isPairing,
onPaste: { raw in
guard !isPairing else { return false }
let handled = form.paste(raw, into: field)
if handled { onEdit() }
return handled
},
onEndEditing: {
guard !isPairing else { return }
form.finishEditing(field)
},
onReturn: {
guard !isPairing else { return }
if field == .address {
form.finishEditing(field)
focusedField = .accessToken
} else {
pairNow()
}
}
)
}
@ViewBuilder
private func inputError(for field: ManualPairingForm.Field) -> some View {
if let error = form.error(for: field) {
let message: LocalizedStringKey = switch error {
case .invalidAddress: "Enter a valid hub domain or IP address, with an optional port."
case .invalidLink: "This pairing link is invalid or incomplete. Copy the full link from your hub."
case .missingToken: "Enter the access token printed by your hub."
}
Label(message, systemImage: "exclamationmark.circle")
.font(.footnote)
.foregroundStyle(.red)
.accessibilityIdentifier(field == .address ? "pairing.addressError" : "pairing.tokenError")
}
}
private func pairNow() {
guard !isPairing else { return }
form.finishEditing(.address)
form.finishEditing(.accessToken)
guard let submission = form.submission else { return }
focusedField = nil
onPair(submission)
}
}