diff --git a/ios/Hapi/Features/Pairing/ManualEntryView.swift b/ios/Hapi/Features/Pairing/ManualEntryView.swift index db451c4a..7a81c431 100644 --- a/ios/Hapi/Features/Pairing/ManualEntryView.swift +++ b/ios/Hapi/Features/Pairing/ManualEntryView.swift @@ -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) ) } } diff --git a/ios/Hapi/Features/Pairing/PairingAttempt.swift b/ios/Hapi/Features/Pairing/PairingAttempt.swift new file mode 100644 index 00000000..55670aff --- /dev/null +++ b/ios/Hapi/Features/Pairing/PairingAttempt.swift @@ -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 + } + } +} diff --git a/ios/Hapi/Features/Pairing/PairingConfirmView.swift b/ios/Hapi/Features/Pairing/PairingConfirmView.swift index d6f2c7b0..9179242e 100644 --- a/ios/Hapi/Features/Pairing/PairingConfirmView.swift +++ b/ios/Hapi/Features/Pairing/PairingConfirmView.swift @@ -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) } } diff --git a/ios/Hapi/Features/Pairing/QRScannerView.swift b/ios/Hapi/Features/Pairing/QRScannerView.swift index 2048d272..f1bd77aa 100644 --- a/ios/Hapi/Features/Pairing/QRScannerView.swift +++ b/ios/Hapi/Features/Pairing/QRScannerView.swift @@ -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 diff --git a/ios/Hapi/Features/Sessions/SessionListView.swift b/ios/Hapi/Features/Sessions/SessionListView.swift index a56a0e42..7f4d577c 100644 --- a/ios/Hapi/Features/Sessions/SessionListView.swift +++ b/ios/Hapi/Features/Sessions/SessionListView.swift @@ -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) diff --git a/ios/Hapi/Models/AppModel.swift b/ios/Hapi/Models/AppModel.swift index d8955309..198de178 100644 --- a/ios/Hapi/Models/AppModel.swift +++ b/ios/Hapi/Models/AppModel.swift @@ -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 } diff --git a/ios/Hapi/Resources/Localizable.xcstrings b/ios/Hapi/Resources/Localizable.xcstrings index ce254285..df52c2b1 100644 --- a/ios/Hapi/Resources/Localizable.xcstrings +++ b/ios/Hapi/Resources/Localizable.xcstrings @@ -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": {