Files
boc/aamos-admin-upgrade/ios/AamosAdmin/Views/SettingsView.swift
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- Added GLOBAL_MARKETS_TITLE to all translation files
- Updated footer with 12 markets (4 active + 8 upcoming)
- Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi
- Built and deployed to production
- CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
2026-07-08 19:56:03 +00:00

290 lines
8.2 KiB
Swift

import SwiftUI
// MARK: - Model
struct AppSettings: Equatable {
var systemName: String
var timezone: String
var jwtExpiryHours: Int
var twoFactorEnabled: Bool
var serverURL: String
static let `default` = AppSettings(
systemName: "AAMOS",
timezone: "Europe/Stockholm",
jwtExpiryHours: 24,
twoFactorEnabled: false,
serverURL: "https://api.aamos.io"
)
}
// MARK: - ViewModel
@MainActor
@Observable
final class SettingsViewModel {
var settings: AppSettings
var savedSettings: AppSettings
var isSaving = false
var showLogoutConfirmation = false
var saveError: String?
var hasChanges: Bool { settings != savedSettings }
init(initial: AppSettings = .default) {
self.settings = initial
self.savedSettings = initial
}
func save() async {
isSaving = true
defer { isSaving = false }
do {
try await Task.sleep(for: .milliseconds(400))
savedSettings = settings
} catch {
saveError = error.localizedDescription
}
}
func discardChanges() {
settings = savedSettings
}
func logout() {
// Clear credentials and navigate to auth screen
}
}
// MARK: - View
struct SettingsView: View {
@State private var vm = SettingsViewModel()
private let timezones: [String] = {
var zones = TimeZone.knownTimeZoneIdentifiers.sorted()
if let idx = zones.firstIndex(of: "Europe/Stockholm") {
zones.move(fromOffsets: IndexSet(integer: idx), toOffset: 0)
}
return zones
}()
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
}
var body: some View {
NavigationStack {
Form {
generalSection
securitySection
aboutSection
logoutSection
}
.navigationTitle("Inställningar")
.navigationBarTitleDisplayMode(.large)
.toolbar { toolbarContent }
.disabled(vm.isSaving)
.overlay {
if vm.isSaving {
savingOverlay
}
}
.confirmationDialog(
"Logga ut",
isPresented: $vm.showLogoutConfirmation,
titleVisibility: .visible
) {
Button("Logga ut", role: .destructive) { vm.logout() }
Button("Avbryt", role: .cancel) {}
} message: {
Text("Du loggas ut från AAMOS Admin. Osparade ändringar försvinner.")
}
.alert("Kunde inte spara", isPresented: Binding(
get: { vm.saveError != nil },
set: { if !$0 { vm.saveError = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(vm.saveError ?? "")
}
}
}
// MARK: General
private var generalSection: some View {
Section {
LabeledContent {
TextField("Systemnamn", text: $vm.settings.systemName)
.multilineTextAlignment(.trailing)
.foregroundStyle(Color(.systemBlue))
} label: {
Label("Systemnamn", systemImage: "server.rack")
}
Picker(selection: $vm.settings.timezone) {
ForEach(timezones, id: \.self) { tz in
Text(tz.replacingOccurrences(of: "_", with: " "))
.tag(tz)
}
} label: {
Label("Tidszon", systemImage: "globe")
}
.pickerStyle(.navigationLink)
} header: {
sectionHeader("Allmänt", icon: "gearshape")
}
}
// MARK: Security
private var securitySection: some View {
Section {
LabeledContent {
Stepper(
"\(vm.settings.jwtExpiryHours) tim",
value: $vm.settings.jwtExpiryHours,
in: 1...168,
step: 1
)
.fixedSize()
} label: {
Label("JWT-utgång", systemImage: "key.fill")
}
Toggle(isOn: $vm.settings.twoFactorEnabled) {
Label("Tvåfaktorsautentisering", systemImage: "lock.shield.fill")
}
.tint(Color(.systemGreen))
} header: {
sectionHeader("Säkerhet", icon: "lock.fill")
} footer: {
Text("JWT-tokens ogiltigförklaras automatiskt efter vald tid. Tvåfaktorsinloggning kräver TOTP-app.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: About
private var aboutSection: some View {
Section {
LabeledContent {
TextField("https://api.aamos.io", text: $vm.settings.serverURL)
.multilineTextAlignment(.trailing)
.foregroundStyle(Color(.systemBlue))
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
} label: {
Label("Server-URL", systemImage: "network")
}
LabeledContent {
Text(appVersion)
.foregroundStyle(.secondary)
.monospacedDigit()
} label: {
Label("Version", systemImage: "info.circle")
}
LabeledContent {
Text(buildNumber)
.foregroundStyle(.secondary)
.monospacedDigit()
} label: {
Label("Build", systemImage: "hammer")
}
} header: {
sectionHeader("Om appen", icon: "info.circle")
}
}
// MARK: Logout
private var logoutSection: some View {
Section {
Button(role: .destructive) {
vm.showLogoutConfirmation = true
} label: {
HStack {
Spacer()
Label("Logga ut", systemImage: "rectangle.portrait.and.arrow.right")
.fontWeight(.semibold)
Spacer()
}
}
}
}
// MARK: Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await vm.save() }
} label: {
if vm.isSaving {
ProgressView()
.controlSize(.small)
} else {
Text("Spara")
.fontWeight(.semibold)
.foregroundStyle(vm.hasChanges ? Color(.systemBlue) : .secondary)
}
}
.disabled(!vm.hasChanges || vm.isSaving)
}
ToolbarItem(placement: .topBarLeading) {
if vm.hasChanges {
Button("Ångra") { vm.discardChanges() }
.foregroundStyle(Color(.systemOrange))
}
}
}
// MARK: Helpers
private func sectionHeader(_ title: String, icon: String) -> some View {
Label(title, systemImage: icon)
.font(.footnote)
.fontWeight(.semibold)
.textCase(nil)
.foregroundStyle(.secondary)
}
private var savingOverlay: some View {
ZStack {
Color(.systemBackground)
.opacity(0.6)
.ignoresSafeArea()
VStack(spacing: 8) {
ProgressView()
.controlSize(.large)
Text("Sparar…")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.secondarySystemBackground))
.shadow(color: .black.opacity(0.12), radius: 16, y: 4)
)
}
}
}
// MARK: - Preview
#Preview {
SettingsView()
}