Files
hapi/ios/Hapi/Features/Settings/SettingsView.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

292 lines
9.7 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
/// Settings-home state (A-M4e): the owner gate for the usage/storage entries
/// and the About hub probe. Appearance/language live in their prefs stores;
/// this model only carries what needs async loading. Android reference:
/// `feature/settings/SettingsViewModel.kt`.
@MainActor @Observable
final class SettingsModel {
enum HubInfoState {
case loading
case loaded(HubHealthResponse)
case failed(String?)
}
/// True when the active hub's JWT namespace is `OwnerGate.ownerNamespace`.
/// Starts false (fail closed) until the gate resolves.
private(set) var isOwner = false
private(set) var hubInfo: HubInfoState = .loading
var hubUrl: String { session.hubUrl }
private let session: HubSession
private var started = false
init(session: HubSession) {
self.session = session
}
func start() {
guard !started else { return }
started = true
Task { await self.loadOwnerGate() }
Task { await self.loadHubInfo() }
}
func retryHubInfo() {
hubInfo = .loading
Task { await self.loadHubInfo() }
}
/// Reads the current JWT (loading the persisted one, refreshing when
/// stale) and peeks its `ns` claim — the Android `currentJwt()` +
/// `isOwnerNamespace` shape. Any failure leaves the gate closed.
private func loadOwnerGate() async {
let jwt = try? await session.authManager.validToken()
isOwner = OwnerGate.isOwnerNamespace(jwt: jwt)
}
private func loadHubInfo() async {
do {
hubInfo = .loaded(try await session.api.health())
} catch is CancellationError {
// View dismissed mid-flight; nothing to show.
} catch {
hubInfo = .failed(error.localizedDescription)
}
}
}
/// Settings home (A-M4e), presented as a sheet off the home toolbar menu:
/// Appearance (theme mode), Language (persist-only until M5), the owner-only
/// Usage/Storage entries, and About (app/protocol versions + hub health).
struct SettingsView: View {
@State private var model: SettingsModel
@State private var languagePrefs: LanguagePrefs
@Environment(ThemePrefs.self) private var themePrefs
@Environment(\.dismiss) private var dismiss
private let session: HubSession
init(session: HubSession) {
_model = State(initialValue: SettingsModel(session: session))
_languagePrefs = State(initialValue: LanguagePrefs())
self.session = session
}
var body: some View {
NavigationStack {
List {
appearanceSection
languageSection
if model.isOwner {
insightsSection
}
aboutSection
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.task {
model.start()
}
}
// The sheet is its own presentation; re-apply the explicit override
// so it does not fall back to the system scheme.
.preferredColorScheme(themePrefs.mode.preferredColorScheme)
}
// MARK: - Sections
private var appearanceSection: some View {
Section("Appearance") {
Picker("Theme", selection: themeBinding) {
ForEach(ThemeMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
}
}
private var themeBinding: Binding<ThemeMode> {
Binding(
get: { themePrefs.mode },
set: { themePrefs.setMode($0) }
)
}
private var languageSection: some View {
Section {
Picker("App language", selection: languageBinding) {
ForEach(AppLanguage.allCases, id: \.self) { language in
Text(Self.pickerLabel(for: language)).tag(language)
}
}
} header: {
Text("Language")
} footer: {
Text("Applies after the app is relaunched.")
}
}
/// Explicit languages show their native names (package data); the
/// follow-system row is UI copy localized here (the package stays
/// language-free).
private static func pickerLabel(for language: AppLanguage) -> String {
language == .system ? String(localized: "Follow system") : language.displayName
}
/// SwiftUI has no supported in-place locale swap without replumbing every
/// scene, so an explicit pick writes the standard `AppleLanguages`
/// override (applied by the OS on next launch) and "Follow system"
/// removes it — the footer says a relaunch is needed.
private var languageBinding: Binding<AppLanguage> {
Binding(
get: { languagePrefs.language },
set: { language in
languagePrefs.setLanguage(language)
switch language {
case .system:
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
case .english:
UserDefaults.standard.set(["en"], forKey: "AppleLanguages")
case .simplifiedChinese:
UserDefaults.standard.set(["zh-Hans"], forKey: "AppleLanguages")
}
}
)
}
/// Owner-only dashboards — hidden unless the JWT namespace is `default`
/// (the endpoints 403 for everyone else anyway; this just avoids
/// dead-end rows).
private var insightsSection: some View {
Section("Insights") {
NavigationLink {
UsageView(api: session.api)
} label: {
detailRow(title: "Usage", detail: "Token usage across agents and models")
}
NavigationLink {
StorageView(api: session.api)
} label: {
detailRow(title: "Storage", detail: "Hub database size on disk")
}
}
}
private var aboutSection: some View {
Section("About") {
LabeledContent("App version", value: appVersion)
LabeledContent("Protocol version", value: String(ProtocolVersion.supported))
hubRow
}
}
private var hubRow: some View {
VStack(alignment: .leading, spacing: 4) {
LabeledContent("Hub", value: HubDisplay.host(model.hubUrl))
Text(model.hubUrl)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
switch model.hubInfo {
case .loading:
Text("Checking hub…")
.font(.caption)
.foregroundStyle(.secondary)
case .loaded(let health):
Text("Status: \(health.status) · protocol v\(health.protocolVersion)")
.font(.caption)
.foregroundStyle(.secondary)
case .failed:
Button {
model.retryHubInfo()
} label: {
Text("Hub unreachable — tap to retry.")
.font(.caption)
.foregroundStyle(.orange)
}
.buttonStyle(.plain)
}
}
}
private func detailRow(title: LocalizedStringKey, detail: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var appVersion: String {
let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
return short ?? "—"
}
}
// MARK: - Shared dashboard states
/// Shared owner-gate/error body for both dashboards (usage + storage).
/// 403 means the hub rejected a non-owner namespace — explain, don't retry.
struct DashboardErrorView: View {
let isForbidden: Bool
let message: String?
let onRetry: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(isForbidden
? String(localized: "This dashboard is only available to the hub owner (default namespace).")
: String(localized: "Could not load this dashboard."))
.font(.subheadline)
if !isForbidden, let message, !message.isEmpty {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
}
if !isForbidden {
Button("Retry", action: onRetry)
.buttonStyle(.borderedProminent)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
}
}
/// Rounded card container shared by the dashboard screens.
struct DashboardCard<Content: View>: View {
let title: LocalizedStringKey?
let content: Content
init(title: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let title {
Text(title)
.font(.subheadline.weight(.semibold))
}
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
}
}