mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
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
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
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
|
||||
/// continue into the shared confirm step.
|
||||
/// 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 pending: PendingPairing?
|
||||
@State private var attempt = PairingAttempt()
|
||||
|
||||
private var canContinue: Bool {
|
||||
private var canPair: Bool {
|
||||
!hubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
@@ -24,6 +26,7 @@ struct ManualEntryView: View {
|
||||
.textContentType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.disabled(attempt.isPairing)
|
||||
} header: {
|
||||
Text("Hub URL")
|
||||
} footer: {
|
||||
@@ -35,18 +38,35 @@ struct ManualEntryView: View {
|
||||
.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.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Continue") {
|
||||
continueToConfirm()
|
||||
if let failure = attempt.failure {
|
||||
Section {
|
||||
PairingErrorView(failure: failure)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!canContinue && parsePastedLink() == nil)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -58,9 +78,7 @@ struct ManualEntryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $pending) { pending in
|
||||
PairingConfirmView(pending: pending)
|
||||
}
|
||||
.interactiveDismissDisabled(attempt.isPairing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,25 +88,21 @@ struct ManualEntryView: View {
|
||||
BindLink.parse(hubUrl) ?? BindLink.parse(accessToken)
|
||||
}
|
||||
|
||||
private func continueToConfirm() {
|
||||
private func pairNow() {
|
||||
if let link = parsePastedLink() {
|
||||
pending = PendingPairing(
|
||||
hubUrl: link.hubUrl,
|
||||
accessToken: link.accessToken,
|
||||
source: .manual
|
||||
)
|
||||
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, and the confirm step shows the resulting URL before pairing.
|
||||
// HTTP.
|
||||
if !address.isEmpty, !address.contains("://") {
|
||||
address = "http://\(address)"
|
||||
}
|
||||
pending = PendingPairing(
|
||||
attempt.pair(
|
||||
model,
|
||||
hubUrl: address,
|
||||
accessToken: accessToken.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
source: .manual
|
||||
accessToken: accessToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import HapiClient
|
||||
import Observation
|
||||
|
||||
/// Inline pairing progress/error state shared by manual entry, QR scan,
|
||||
/// and the deep-link confirm step.
|
||||
@Observable @MainActor
|
||||
final class PairingAttempt {
|
||||
private(set) var isPairing = false
|
||||
var failure: PairingFailure?
|
||||
|
||||
func pair(_ model: AppModel, hubUrl: String, accessToken: String) {
|
||||
guard !isPairing else { return }
|
||||
isPairing = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.pair(hubUrl: hubUrl, accessToken: accessToken)
|
||||
// Success: AppModel flips to .paired and tears down the
|
||||
// presenting hierarchy; nothing to do here.
|
||||
} catch let pairingFailure as PairingFailure {
|
||||
failure = pairingFailure
|
||||
} catch {
|
||||
failure = .unreachable
|
||||
}
|
||||
isPairing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import HapiClient
|
||||
import SwiftUI
|
||||
|
||||
/// Final confirmation step shared by every pairing path (deep link, QR scan,
|
||||
/// manual entry): shows what is about to be paired, runs `AppModel.pair`
|
||||
/// with progress, and renders ``PairingErrorView`` states on failure.
|
||||
/// 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.
|
||||
@@ -11,8 +11,7 @@ struct PairingConfirmView: View {
|
||||
let pending: PendingPairing
|
||||
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var isPairing = false
|
||||
@State private var failure: PairingFailure?
|
||||
@State private var attempt = PairingAttempt()
|
||||
|
||||
private var isAddingAnotherHub: Bool {
|
||||
if case .paired = model.state { return true }
|
||||
@@ -37,7 +36,7 @@ struct PairingConfirmView: View {
|
||||
: String(localized: "The app checks the hub is reachable, then exchanges the token for a session."))
|
||||
}
|
||||
|
||||
if let failure {
|
||||
if let failure = attempt.failure {
|
||||
Section {
|
||||
PairingErrorView(failure: failure)
|
||||
}
|
||||
@@ -45,43 +44,25 @@ struct PairingConfirmView: View {
|
||||
|
||||
Section {
|
||||
Button {
|
||||
pairNow()
|
||||
attempt.pair(model, hubUrl: pending.hubUrl, accessToken: pending.accessToken)
|
||||
} label: {
|
||||
if isPairing {
|
||||
if attempt.isPairing {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Pairing…")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(failure == nil ? String(localized: "Pair") : String(localized: "Try Again"))
|
||||
Text(attempt.failure == nil ? String(localized: "Pair") : String(localized: "Try Again"))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.disabled(isPairing)
|
||||
.disabled(attempt.isPairing)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isAddingAnotherHub ? String(localized: "Add Hub") : String(localized: "Pair"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.interactiveDismissDisabled(isPairing)
|
||||
}
|
||||
|
||||
private func pairNow() {
|
||||
guard !isPairing else { return }
|
||||
isPairing = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.pair(hubUrl: pending.hubUrl, accessToken: pending.accessToken)
|
||||
// Success: AppModel flipped to .paired and closed the pairing
|
||||
// surfaces; this view is on its way out.
|
||||
} catch let pairingFailure as PairingFailure {
|
||||
failure = pairingFailure
|
||||
} catch {
|
||||
failure = .unreachable
|
||||
}
|
||||
isPairing = false
|
||||
}
|
||||
.interactiveDismissDisabled(attempt.isPairing)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import HapiClient
|
||||
import HapiProtocol
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@@ -6,13 +7,14 @@ import VisionKit
|
||||
|
||||
/// QR scanning step: a VisionKit live scanner that feeds every recognized
|
||||
/// string through ``BindLink/parse(_:)`` (so both the companion deeplink QR
|
||||
/// and the web direct-access QR pair) and pushes the shared confirm step on
|
||||
/// success. Falls back to guidance when scanning is unsupported (Simulator,
|
||||
/// no camera) or camera access is denied.
|
||||
/// and the web direct-access QR pair) and pairs immediately on success, with
|
||||
/// inline progress/error below the scanner. Falls back to guidance when
|
||||
/// scanning is unsupported (Simulator, no camera) or camera access is denied.
|
||||
struct QRScannerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var scanned: PendingPairing?
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var attempt = PairingAttempt()
|
||||
@State private var cameraDenied = false
|
||||
@State private var sawForeignCode = false
|
||||
|
||||
@@ -28,19 +30,20 @@ struct QRScannerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $scanned) { pending in
|
||||
PairingConfirmView(pending: pending)
|
||||
}
|
||||
.interactiveDismissDisabled(attempt.isPairing)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var scannerContent: some View {
|
||||
if DataScannerViewController.isSupported {
|
||||
DataScannerRepresentable(isActive: scanned == nil, onScan: handleScannedString)
|
||||
DataScannerRepresentable(
|
||||
isActive: !attempt.isPairing && attempt.failure == nil,
|
||||
onScan: handleScannedString
|
||||
)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.overlay(alignment: .bottom) {
|
||||
hintBar
|
||||
statusBar
|
||||
}
|
||||
.overlay {
|
||||
if cameraDenied {
|
||||
@@ -65,6 +68,33 @@ struct QRScannerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBar: some View {
|
||||
if attempt.isPairing {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Pairing…")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.thinMaterial)
|
||||
} else if let failure = attempt.failure {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
PairingErrorView(failure: failure)
|
||||
Button("Scan Again") {
|
||||
attempt.failure = nil
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.thinMaterial)
|
||||
} else {
|
||||
hintBar
|
||||
}
|
||||
}
|
||||
|
||||
private var hintBar: some View {
|
||||
Text(sawForeignCode
|
||||
? String(localized: "That code is not a HAPI pairing QR — scan one printed by the hub.")
|
||||
@@ -95,24 +125,21 @@ struct QRScannerView: View {
|
||||
|
||||
/// Returns whether the payload was accepted (stops further scanning).
|
||||
private func handleScannedString(_ raw: String) -> Bool {
|
||||
guard scanned == nil else { return true }
|
||||
guard !attempt.isPairing, attempt.failure == nil else { return true }
|
||||
guard let link = BindLink.parse(raw) else {
|
||||
sawForeignCode = true
|
||||
return false
|
||||
}
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
scanned = PendingPairing(
|
||||
hubUrl: link.hubUrl,
|
||||
accessToken: link.accessToken,
|
||||
source: .qrScan
|
||||
)
|
||||
sawForeignCode = false
|
||||
attempt.pair(model, hubUrl: link.hubUrl, accessToken: link.accessToken)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// `DataScannerViewController` wrapped for SwiftUI, restricted to QR codes.
|
||||
/// `isActive` gates scanning so a successful scan freezes the camera while
|
||||
/// the confirm step is up and re-arms when the user navigates back.
|
||||
/// pairing runs (or its error is showing) and re-arms on "Scan Again".
|
||||
private struct DataScannerRepresentable: UIViewControllerRepresentable {
|
||||
var isActive: Bool
|
||||
var onScan: (String) -> Bool
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import HapiClient
|
||||
import HapiProtocol
|
||||
import HapiUI
|
||||
import SwiftUI
|
||||
|
||||
/// The session list (A-M2a) — standalone screen: navigation and hub chrome
|
||||
@@ -18,6 +19,7 @@ import SwiftUI
|
||||
/// - long-press context menu → pin (none/project/global) + archive with
|
||||
/// optimistic store updates; failures land in an alert.
|
||||
struct SessionListView: View {
|
||||
@Environment(\.hapiTheme) private var theme
|
||||
@State private var model: SessionListModel
|
||||
private let onOpenSession: (String) -> Void
|
||||
|
||||
@@ -127,6 +129,9 @@ struct SessionListView: View {
|
||||
SessionRowView(row: row, now: now)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Default separator color reads heavy against these rows; the theme
|
||||
// divider is the WeChat-style faint hairline.
|
||||
.listRowSeparatorTint(theme.divider)
|
||||
.contextMenu {
|
||||
contextMenuActions(row)
|
||||
}
|
||||
@@ -227,12 +232,15 @@ struct SessionRowView: View {
|
||||
}
|
||||
|
||||
private var titleLine: some View {
|
||||
// Spinner/dot pinned to the trailing edge next to the timestamp
|
||||
// (Android row order), so they don't drift with the title length.
|
||||
HStack(spacing: 6) {
|
||||
AgentFlavorIconView(flavor: row.flavor)
|
||||
Text(row.title)
|
||||
.font(.body)
|
||||
.fontWeight(row.unread ? .semibold : .regular)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if row.summary.active && row.summary.thinking {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
@@ -246,7 +254,6 @@ struct SessionRowView: View {
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityLabel("Unread")
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
Text(formatRelativeAge(now: now, thenEpochMs: row.summary.updatedAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -4,20 +4,13 @@ import HapiProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// A pairing that awaits user confirmation — from a deep link, a scanned QR,
|
||||
/// or manual entry. Drives the confirm sheet.
|
||||
struct PendingPairing: Identifiable, Hashable {
|
||||
enum Source: Hashable {
|
||||
case deepLink
|
||||
case qrScan
|
||||
case manual
|
||||
}
|
||||
|
||||
/// A pairing that awaits user confirmation — from a deep link. Drives the
|
||||
/// confirm sheet.
|
||||
struct PendingPairing: Identifiable {
|
||||
let id = UUID()
|
||||
/// Hub URL as carried by the link/entry (normalized during `pair`).
|
||||
/// Hub URL as carried by the link (normalized during `pair`).
|
||||
let hubUrl: String
|
||||
let accessToken: String
|
||||
let source: Source
|
||||
}
|
||||
|
||||
/// Root application state: which hubs are paired, which one is active, and
|
||||
@@ -184,8 +177,7 @@ final class AppModel {
|
||||
showAddHub = false
|
||||
pendingPairing = PendingPairing(
|
||||
hubUrl: link.hubUrl,
|
||||
accessToken: link.accessToken,
|
||||
source: .deepLink
|
||||
accessToken: link.accessToken
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -931,16 +931,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Continue": {
|
||||
"localizations": {
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "继续"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Copy Contents": {
|
||||
"localizations": {
|
||||
"zh-Hans": {
|
||||
@@ -2981,6 +2971,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scan Again": {
|
||||
"localizations": {
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "重新扫描"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scan QR Code": {
|
||||
"localizations": {
|
||||
"zh-Hans": {
|
||||
|
||||
Reference in New Issue
Block a user