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() }