Files
hapi/ios/Hapi/Features/Pairing/ManualEntryView.swift
T
weishu bc9df82dc6 fix(ios): refine pairing and session list UX
- 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
2026-08-27 11:16:53 +08:00

109 lines
4.1 KiB
Swift

import HapiClient
import HapiProtocol
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 hubUrl = ""
@State private var accessToken = ""
@State private var attempt = PairingAttempt()
private var canPair: Bool {
!hubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
NavigationStack {
Form {
Section {
TextField("http://192.168.1.20:3006", text: $hubUrl)
.keyboardType(.URL)
.textContentType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.disabled(attempt.isPairing)
} header: {
Text("Hub URL")
} footer: {
Text("The address the hub prints at startup — a LAN address like http://192.168.1.20:3006, or the public tunnel URL when running with --relay.")
}
Section {
TextField("Access token", text: $accessToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.monospaced()
.disabled(attempt.isPairing)
} header: {
Text("Access token")
} footer: {
Text("Printed by the hub at startup, and shown in the web app under Settings → Companion Pairing. Pasting a full pairing link into either field also works.")
}
if let failure = attempt.failure {
Section {
PairingErrorView(failure: failure)
}
}
Section {
Button {
pairNow()
} 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((!canPair && parsePastedLink() == nil) || attempt.isPairing)
}
}
.navigationTitle("Enter Hub Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.interactiveDismissDisabled(attempt.isPairing)
}
}
/// Convenience: a whole pairing link pasted into either field carries
/// both values at once.
private func parsePastedLink() -> BindLink? {
BindLink.parse(hubUrl) ?? BindLink.parse(accessToken)
}
private func pairNow() {
if let link = parsePastedLink() {
attempt.pair(model, hubUrl: link.hubUrl, accessToken: link.accessToken)
return
}
var address = hubUrl.trimmingCharacters(in: .whitespacesAndNewlines)
// Typing the scheme on a phone is annoying; local hubs serve plain
// HTTP.
if !address.isEmpty, !address.contains("://") {
address = "http://\(address)"
}
attempt.pair(
model,
hubUrl: address,
accessToken: accessToken.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
}