mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
Translate the unknown delivery state, keep decorative content verbatim, and pin integer interpolation formats to existing catalog keys.
346 lines
12 KiB
Swift
346 lines
12 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import SwiftUI
|
|
import UIKit
|
|
import UserNotifications
|
|
|
|
/// 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
|
|
@Environment(\.openURL) private var openURL
|
|
|
|
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
|
|
notificationsSection
|
|
if model.isOwner {
|
|
insightsSection
|
|
}
|
|
aboutSection
|
|
}
|
|
.navigationTitle("Settings")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
model.start()
|
|
PushCoordinator.shared.refreshStatus()
|
|
}
|
|
}
|
|
// 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")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
/// Push status (P3): permission state, how many paired hubs currently
|
|
/// hold this install's device registration, and a manual re-register
|
|
/// escape hatch (iOS has no background retry queue — the next app start
|
|
/// or token rotation heals registrations too).
|
|
private var notificationsSection: some View {
|
|
let push = PushCoordinator.shared
|
|
return Section {
|
|
LabeledContent("Push notifications", value: permissionLabel(push.authorizationStatus))
|
|
if push.authorizationStatus == .denied {
|
|
Button {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
openURL(url)
|
|
}
|
|
} label: {
|
|
Text("Enable in system settings…")
|
|
.font(.callout)
|
|
}
|
|
} else {
|
|
LabeledContent(
|
|
"Registered hubs",
|
|
value: "\(push.registeredHubs.count) / \(push.pairedHubs.count)"
|
|
)
|
|
Button("Re-register push") {
|
|
PushCoordinator.shared.reregisterAll()
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Notifications")
|
|
} footer: {
|
|
if let problem = push.lastRegistrationProblem {
|
|
Text(problem)
|
|
} else {
|
|
Text("Pushes are end-to-end encrypted; only this device can read them.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func permissionLabel(_ status: UNAuthorizationStatus) -> String {
|
|
switch status {
|
|
case .authorized, .provisional, .ephemeral:
|
|
String(localized: "Enabled")
|
|
case .denied:
|
|
String(localized: "Denied")
|
|
default:
|
|
String(localized: "Not requested")
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
PrivacyPolicyLink()
|
|
}
|
|
}
|
|
|
|
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, specifier: "%lld")")
|
|
.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))
|
|
}
|
|
}
|