From 22f69ff75ea9cf73b7d4f7749424a4e801551e5c Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 27 Mar 2026 22:20:32 -0400 Subject: [PATCH] feat: redo the settings ui --- overpass/Services/APIClient.swift | 2 +- overpass/Views/SettingsView.swift | 208 ++++++++++++++++++++---------- 2 files changed, 141 insertions(+), 69 deletions(-) diff --git a/overpass/Services/APIClient.swift b/overpass/Services/APIClient.swift index 500fbde..98a3207 100644 --- a/overpass/Services/APIClient.swift +++ b/overpass/Services/APIClient.swift @@ -20,7 +20,7 @@ final class APIClient: ObservableObject { // Base URL of the Overpass server var baseURL: String { - get { UserDefaults.standard.string(forKey: "sh.dunkirk.overpass.base_url") ?? "" } + get { UserDefaults.standard.string(forKey: "sh.dunkirk.overpass.base_url") ?? "https://overpass.dunkirk.sh" } set { UserDefaults.standard.set(newValue, forKey: "sh.dunkirk.overpass.base_url") } } diff --git a/overpass/Views/SettingsView.swift b/overpass/Views/SettingsView.swift index e45220c..5e5e68b 100644 --- a/overpass/Views/SettingsView.swift +++ b/overpass/Views/SettingsView.swift @@ -7,85 +7,32 @@ struct SettingsView: View { @AppStorage("balance_tank") private var balanceTank: Double = 12 @State private var baseURL: String = "" @State private var deviceSecret: String = "" - @State private var registrationStatus: String? + @State private var registrationError: String? @State private var isRegistering = false @State private var health: HealthResponse? @State private var isLoadingHealth = false + @State private var hasApiKey = false + @State private var showClearConfirm = false + + private static let iso8601 = ISO8601DateFormatter() var body: some View { NavigationStack { Form { - Section("Server") { - TextField("Base URL (e.g. http://100.x.x.x:7878)", text: $baseURL) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .keyboardType(.URL) - } - - Section { - LabeledContent("Local cache", value: "\(store.byId.count) stations") - Button("Clear local cache", role: .destructive) { - store.clear() - } - if let h = health { - LabeledContent("Server cache", value: "\(h.cachedStations) stations") - if let newest = h.newestFetch.flatMap({ ISO8601DateFormatter().date(from: $0) }) { - LabeledContent("Last fetch", value: newest.formatted(.relative(presentation: .named))) - } - } - Button(isLoadingHealth ? "Refreshing…" : "Refresh stats") { - Task { await loadHealth() } - } - .disabled(isLoadingHealth || baseURL.isEmpty) - } header: { - Text("Cache") - } - - Section { - Stepper(value: $balanceMpg, in: 10...60, step: 1) { - LabeledContent("Fuel economy", value: "\(Int(balanceMpg)) mpg") - } - Stepper(value: $balanceTank, in: 5...40, step: 1) { - LabeledContent("Tank size", value: "\(Int(balanceTank)) gal") - } - } header: { - Text("Best Value Sort") - } footer: { - Text("Used to estimate the real cost of driving to a cheaper station.") - } - - Section("Authentication") { - SecureField("Device secret", text: $deviceSecret) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - - Button(isRegistering ? "Registering…" : "Register API key") { - Task { await register() } - } - .disabled(isRegistering || deviceSecret.isEmpty || baseURL.isEmpty) - - if let status = registrationStatus { - Text(status) - .font(.caption) - .foregroundStyle(status.hasPrefix("✓") ? .green : .red) - } - - if KeychainService.load(forKey: "user_api_key") != nil { - Label("API key stored", systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) - .font(.caption) - } - } + serverSection + cacheSection + bestValueSection + authSection + versionSection } .navigationTitle("Settings") .onAppear { baseURL = api.baseURL deviceSecret = KeychainService.load(forKey: "device_secret") ?? "" + hasApiKey = KeychainService.load(forKey: "user_api_key") != nil Task { await loadHealth() } } - .onChange(of: baseURL) { _, new in - api.baseURL = new - } + .onChange(of: baseURL) { _, new in api.baseURL = new } .onChange(of: deviceSecret) { _, new in if new.isEmpty { KeychainService.delete(forKey: "device_secret") @@ -93,9 +40,134 @@ struct SettingsView: View { KeychainService.save(new, forKey: "device_secret") } } + .confirmationDialog( + "Clear on-device cache?", + isPresented: $showClearConfirm, + titleVisibility: .visible + ) { + Button("Clear \(store.byId.count) stations", role: .destructive) { + store.clear() + } + } message: { + Text("Station data will be re-fetched from the server when you next open the map.") + } } } + // MARK: - Sections + + private var serverSection: some View { + Section("Server") { + TextField("Base URL", text: $baseURL, prompt: Text("https://overpass.dunkirk.sh")) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } + } + + private var cacheSection: some View { + Section { + LabeledContent("On device") { + Text("\(store.byId.count) stations") + .foregroundStyle(.secondary) + } + + if isLoadingHealth { + LabeledContent("Server") { + ProgressView() + } + } else if let h = health { + LabeledContent("Server") { + Text("\(h.cachedStations) stations") + .foregroundStyle(.secondary) + } + if let raw = h.newestFetch, + let date = Self.iso8601.date(from: raw) { + LabeledContent("Last fetched") { + Text(date, format: .relative(presentation: .named)) + .foregroundStyle(.secondary) + } + } + } else if !baseURL.isEmpty { + LabeledContent("Server") { + Text("Unavailable") + .foregroundStyle(.tertiary) + } + } + + Button("Clear on-device cache", role: .destructive) { + showClearConfirm = true + } + .disabled(store.byId.isEmpty) + } header: { + HStack { + Text("Cache") + Spacer() + if !baseURL.isEmpty { + Button("Refresh") { Task { await loadHealth() } } + .disabled(isLoadingHealth) + .font(.footnote) + .textCase(nil) + } + } + } + } + + private var bestValueSection: some View { + Section { + Stepper(value: $balanceMpg, in: 10...60, step: 1) { + LabeledContent("Fuel economy", value: "\(Int(balanceMpg)) mpg") + } + Stepper(value: $balanceTank, in: 5...40, step: 1) { + LabeledContent("Tank size", value: "\(Int(balanceTank)) gal") + } + } header: { + Text("Best Value Sort") + } footer: { + Text("Used to estimate the real cost of driving to a cheaper station.") + } + } + + @ViewBuilder + private var authSection: some View { + Section("Authentication") { + if hasApiKey { + Label("API key registered", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + Button("Reset API key", role: .destructive) { + KeychainService.delete(forKey: "user_api_key") + hasApiKey = false + } + } else { + SecureField("Device secret", text: $deviceSecret) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Button(isRegistering ? "Registering…" : "Register API key") { + Task { await register() } + } + .disabled(isRegistering || deviceSecret.isEmpty || baseURL.isEmpty) + + if let error = registrationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + + private var versionSection: some View { + Section { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" + LabeledContent("Version", value: "\(version) (\(build))") + .foregroundStyle(.secondary) + } + } + + // MARK: - Actions + private func loadHealth() async { guard !baseURL.isEmpty else { return } isLoadingHealth = true @@ -105,12 +177,12 @@ struct SettingsView: View { private func register() async { isRegistering = true - registrationStatus = nil + registrationError = nil do { try await api.registerKey() - registrationStatus = "✓ Key registered successfully" + hasApiKey = true } catch { - registrationStatus = "✗ \(error.localizedDescription)" + registrationError = error.localizedDescription } isRegistering = false } -- 2.51.2