Files
hapi/ios/Hapi/Features/Home/HomeView.swift
T
weishu 6066f20c20 feat(ios): zh-CN localization + catalog dedupe (A-M5)
Part 1 — Simplified Chinese localization of the iOS app layer:
- ios/Hapi/Resources/Localizable.xcstrings: hand-authored String Catalog
  (395 keys, zh-Hans; en implicit as source). Terminology mined from
  web/src/lib/locales/zh-CN.ts (会话/新建会话/权限模式/允许/拒绝/工作树/
  机器/语音输入/用量/草稿夹 …).
- Mechanical edits only: merged multi-part string concatenations into
  single LocalizedStringKey literals, converted ternary/plain-String user
  copy to String(localized:), switched helper params (detailRow,
  DashboardCard, optionPicker, notice) to LocalizedStringKey.
- LocalizedNoticeMapper (app layer): display-point translation of the 27
  known HapiKit-emitted strings (ChatInteractor notices, dictation errors,
  window-sync warnings, files fallbacks, worktree-name validation) with
  verbatim passthrough for server-originated text; package stays
  language-free.
- Deliberately untranslated (web parity / non-copy): event rows
  (EventPresentation — web renders presentation.ts verbatim), catalog
  option labels (Default/Auto/Sonnet/permission modes — web shows them
  verbatim), tool names, code-like titles (grep(pattern:), MCP:, Skill,
  Task), unit suffixes (B/KB/MB, m/h/d/w), decorative separators.
- Language row wired: AppLanguage gains .system (follow system, new
  default); explicit picks write the AppleLanguages override, Follow
  system removes it; footer notes a relaunch applies it (no supported
  in-place SwiftUI locale swap).
- project.pbxproj: zh-Hans added to knownRegions.

Part 2 — #39 catalog dedupe (HapiProtocol):
- NewSessionCatalogs.claudeModels/claudeEfforts now derive from
  ClaudeModels/ClaudeEfforts (single source shared with ModelCatalog);
  codexReasoningEfforts stays own data (web CODEX_REASONING_EFFORT_OPTIONS
  minus max); effortLabel delegates to ModelCatalog.capitalizedFirst.
- New CatalogTests lock test for the derived option lists.

Gate: bash ios/scripts/linux-test.sh — 459 tests green.
2026-08-18 16:04:03 +08:00

178 lines
6.3 KiB
Swift

import HapiClient
import SwiftUI
/// Post-pairing home: the session list for the active hub, with the hub
/// switcher (switch / add / settings / sign out), the "+" new-session sheet
/// (A-M3c), the Settings sheet (A-M4e), and the live global-SSE connection
/// dot in the toolbar. Tapping a row pushes the chat (M2f); a successful
/// spawn dismisses the sheet and pushes the new chat the same way.
struct HomeView: View {
let session: HubSession
@Environment(AppModel.self) private var model
@State private var confirmSignOut = false
@State private var showNewSession = false
@State private var showSettings = false
@State private var path: [String] = []
var body: some View {
@Bindable var model = model
NavigationStack(path: $path) {
VStack(spacing: 0) {
if let failedHub = model.authFailureNotice {
authFailureBanner(failedHub: failedHub)
}
SessionListView(session: session) { sessionId in
path.append(sessionId)
}
}
.navigationDestination(for: String.self) { sessionId in
ChatView(session: session, sessionId: sessionId) { superseding in
// Resume returned a different session id (A-M3a): replace
// the current chat entry so back still pops to the list.
if let last = path.indices.last, path[last] == sessionId {
path[last] = superseding
} else {
path.append(superseding)
}
}
// A replaced path element must rebuild the screen's @State.
.id(sessionId)
}
.navigationTitle(HubDisplay.host(session.hubUrl))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
connectionIndicator
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showNewSession = true
} label: {
Label("New Session", systemImage: "plus")
}
}
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)
}
.sheet(isPresented: $showNewSession) {
NewSessionView(session: session) { sessionId in
// Navigate-replace: drop the sheet, push the fresh chat.
showNewSession = false
path.append(sessionId)
}
}
.sheet(isPresented: $showSettings) {
SettingsView(session: session)
}
}
// 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: String(localized: "Live")
case .connecting: String(localized: "Connecting…")
case .backoff: String(localized: "Reconnecting…")
case .suspended: String(localized: "Paused")
case .idle: String(localized: "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")
}
Divider()
Button {
showSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
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)
}
}