Files
hapi/ios/Hapi/Features/Home/HomePlaceholderView.swift
T
weishu 3363d1c75b feat(ios): pairing flow, deep link, app session wiring (A-M1d)
- HapiProtocol/Pairing/BindLink: parses the companion deeplink
  (hapicompanion://bind?hub=&code=) and the web direct-access QR
  (?hub=&token=) with URLSearchParams form-decoding semantics, in
  lockstep with the Android port (tests mirror BindLinkTest.kt).
- HapiClient/Auth/HubPairingService: normalize -> GET /health
  (reachability + protocolVersion) -> POST /api/auth -> persist
  Keychain + registry + active hub; unpair with fallback. Covered by
  PairingLogicTests through the HTTPPerforming seam.
- App layer: AppModel (@Observable @MainActor pairing state machine:
  restore, pair, switch, sign out, deep-link routing, scenePhase,
  terminal-auth-failure banner) + HubSession (per-active-hub APIClient/
  AuthManager/global SSEClient with suspend-resume and a connection
  state for the UI; store routing is TODO(M2)).
- Pairing UI: welcome flow, VisionKit QR scanner (with Simulator/
  permission fallbacks), manual entry (paste-friendly), shared confirm
  sheet with per-PairingFailure error states.
- HapiApp routes hapicompanion:// through AppModel (paired hubs switch
  with a notice, never log the token); RootView switches unpaired/
  paired and hosts the deep-link confirm sheet; HomePlaceholderView
  shows hub, connection dot, hub switcher (M2a replaces it with the
  session list).
- Info.plist: NSCameraUsageDescription; README: pairing guide + manual
  test pass.
2026-08-17 15:58:17 +08:00

143 lines
4.7 KiB
Swift

import HapiClient
import SwiftUI
/// Post-pairing placeholder: shows the active hub with its live global-SSE
/// connection state and the hub switcher (switch / add / sign out). The
/// session list replaces the body content in M2.
struct HomePlaceholderView: View {
let session: HubSession
@Environment(AppModel.self) private var model
@State private var confirmSignOut = false
var body: some View {
@Bindable var model = model
NavigationStack {
VStack(spacing: 0) {
if let failedHub = model.authFailureNotice {
authFailureBanner(failedHub: failedHub)
}
// TODO(M2a): replace HomePlaceholderView with SessionListView.
ContentUnavailableView {
Label("Paired with \(HubDisplay.host(session.hubUrl))", systemImage: "checkmark.seal")
} description: {
Text("Sessions arrive with M2.")
}
}
.navigationTitle(HubDisplay.host(session.hubUrl))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
connectionIndicator
}
ToolbarItem(placement: .topBarTrailing) {
hubMenu
}
}
.confirmationDialog(
"Sign out of \(HubDisplay.host(session.hubUrl))?",
isPresented: $confirmSignOut,
titleVisibility: .visible
) {
Button("Sign Out", role: .destructive) {
model.signOut(hub: session.hubUrl)
}
} message: {
Text("Removes the stored access token for this hub. "
+ "Pair again to reconnect.")
}
}
.sheet(isPresented: $model.showAddHub) {
PairingFlowView(context: .addHub)
}
}
// MARK: - Connection state
private var connectionIndicator: some View {
HStack(spacing: 6) {
Circle()
.fill(connectionColor)
.frame(width: 8, height: 8)
Text(connectionLabel)
.font(.footnote)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Connection: \(connectionLabel)")
}
private var connectionColor: Color {
switch session.connectionState {
case .connected: .green
case .connecting, .backoff: .orange
case .idle, .suspended: .gray
}
}
private var connectionLabel: String {
switch session.connectionState {
case .connected: "Live"
case .connecting: "Connecting…"
case .backoff: "Reconnecting…"
case .suspended: "Paused"
case .idle: "Offline"
}
}
// MARK: - Hub switcher
private var hubMenu: some View {
Menu {
Section("Hubs") {
ForEach(model.hubs, id: \.self) { hub in
Button {
model.switchHub(to: hub)
} label: {
if hub == session.hubUrl {
Label(HubDisplay.host(hub), systemImage: "checkmark")
} else {
Text(HubDisplay.host(hub))
}
}
}
}
Button {
model.showAddHub = true
} label: {
Label("Add Hub…", systemImage: "plus")
}
Button(role: .destructive) {
confirmSignOut = true
} label: {
Label("Sign Out…", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Label("Hubs", systemImage: "server.rack")
}
}
private func authFailureBanner(failedHub: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("\(HubDisplay.host(failedHub)) rejected its stored "
+ "credentials and was signed out. Pair it again from the hub "
+ "menu.")
.font(.footnote)
Spacer(minLength: 0)
Button {
model.authFailureNotice = nil
} label: {
Image(systemName: "xmark")
.font(.footnote.bold())
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 8)
}
}