Files
boc/aamos-admin-upgrade/ios/AamosAdmin/ContentView.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

1313 lines
40 KiB
Swift

import SwiftUI
// MARK: - Shared Models
struct DashboardStats: Decodable {
let totalUsers: Int
let activeUsers: Int
let totalModules: Int
let activeModules: Int
let auditEventsToday: Int
let systemHealthPercent: Double
}
struct ManagedUser: Decodable, Identifiable {
let id: String
let name: String
let email: String
let role: String
let isActive: Bool
let lastSeen: Date?
let createdAt: Date
}
struct AppModule: Decodable, Identifiable {
let id: String
let name: String
let description: String
let category: String
let version: String
let isEnabled: Bool
let iconName: String
}
struct AuditEntry: Decodable, Identifiable {
let id: String
let action: String
let resource: String
let actorName: String
let actorEmail: String
let timestamp: Date
let severity: AuditSeverity
let details: String?
enum AuditSeverity: String, Decodable {
case info, warning, critical
var color: Color {
switch self {
case .info: return Color(.systemBlue)
case .warning: return Color(.systemOrange)
case .critical: return Color(.systemRed)
}
}
var icon: String {
switch self {
case .info: return "info.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .critical: return "xmark.circle.fill"
}
}
var label: String {
switch self {
case .info: return "Info"
case .warning: return "Varning"
case .critical: return "Kritisk"
}
}
}
}
// MARK: - Dashboard ViewModel
@MainActor
final class DashboardViewModel: ObservableObject {
@Published var stats: DashboardStats?
@Published var recentActivity: [AuditEntry] = []
@Published var isLoading = false
@Published var error: String?
private let api = APIClient.shared
func load() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
async let statsReq: DashboardStats = api.get("/dashboard/stats")
async let auditReq: [AuditEntry] = api.get("/audit?limit=5&sort=desc")
let (s, a) = try await (statsReq, auditReq)
stats = s
recentActivity = a
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Dashboard View
struct DashboardView: View {
@StateObject private var vm = DashboardViewModel()
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 16) {
if vm.isLoading && vm.stats == nil {
skeletonCards(count: 4)
} else if let err = vm.error, vm.stats == nil {
inlineError(err) { Task { await vm.load() } }
} else {
if let stats = vm.stats {
statsSection(stats)
}
if !vm.recentActivity.isEmpty {
recentSection
}
}
}
.padding(16)
}
.navigationTitle("Dashboard")
.background(Color(.systemBackground))
.refreshable { await vm.load() }
}
.task { await vm.load() }
}
private func statsSection(_ s: DashboardStats) -> some View {
VStack(spacing: 8) {
Text("Översikt")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
StatCard(
title: "Användare",
value: "\(s.activeUsers)/\(s.totalUsers)",
subtitle: "aktiva",
icon: "person.2.fill",
color: Color(.systemBlue)
)
StatCard(
title: "Moduler",
value: "\(s.activeModules)/\(s.totalModules)",
subtitle: "aktiverade",
icon: "square.grid.2x2.fill",
color: Color(.systemGreen)
)
StatCard(
title: "Händelser",
value: "\(s.auditEventsToday)",
subtitle: "idag",
icon: "doc.text.fill",
color: Color(.systemOrange)
)
StatCard(
title: "Systemhälsa",
value: "\(Int(s.systemHealthPercent))%",
subtitle: healthLabel(s.systemHealthPercent),
icon: "heart.fill",
color: healthColor(s.systemHealthPercent)
)
}
}
}
private var recentSection: some View {
VStack(spacing: 8) {
Text("Senaste aktivitet")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
LazyVStack(spacing: 8) {
ForEach(vm.recentActivity) { entry in
DashboardAuditRow(entry: entry)
}
}
}
}
private func healthLabel(_ v: Double) -> String {
v >= 90 ? "utmärkt" : v >= 70 ? "bra" : "varning"
}
private func healthColor(_ v: Double) -> Color {
v >= 90 ? Color(.systemGreen) : v >= 70 ? Color(.systemOrange) : Color(.systemRed)
}
private func skeletonCards(count: Int) -> some View {
LazyVStack(spacing: 8) {
ForEach(0..<count, id: \.self) { _ in
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
.frame(height: 88)
}
}
}
}
struct StatCard: View {
let title: String
let value: String
let subtitle: String
let icon: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: icon)
.font(.callout)
.foregroundStyle(color)
Text(value)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
Text(subtitle)
.font(.caption2)
.foregroundStyle(color)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
struct DashboardAuditRow: View {
let entry: AuditEntry
var body: some View {
HStack(spacing: 12) {
Image(systemName: entry.severity.icon)
.font(.callout)
.foregroundStyle(entry.severity.color)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(entry.action)
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
Text(entry.actorName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(entry.timestamp, style: .relative)
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(12)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
// MARK: - Users ViewModel
@MainActor
final class UsersViewModel: ObservableObject {
@Published var users: [ManagedUser] = []
@Published var filteredUsers: [ManagedUser] = []
@Published var searchText = "" { didSet { applyFilter() } }
@Published var selectedRole: String? = nil { didSet { applyFilter() } }
@Published var isLoading = false
@Published var error: String?
private let api = APIClient.shared
var availableRoles: [String] {
Array(Set(users.map(\.role))).sorted()
}
func load() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
users = try await api.get("/users")
applyFilter()
} catch {
self.error = error.localizedDescription
}
}
func toggleStatus(_ user: ManagedUser) async {
struct Body: Encodable { let isActive: Bool }
do {
let updated: ManagedUser = try await api.patch(
"/users/\(user.id)",
body: Body(isActive: !user.isActive)
)
if let idx = users.firstIndex(where: { $0.id == updated.id }) {
users[idx] = updated
applyFilter()
}
} catch {
self.error = error.localizedDescription
}
}
private func applyFilter() {
var result = users
if let role = selectedRole { result = result.filter { $0.role == role } }
if !searchText.isEmpty {
let q = searchText.lowercased()
result = result.filter {
$0.name.lowercased().contains(q) || $0.email.lowercased().contains(q)
}
}
filteredUsers = result
}
}
// MARK: - Users View
struct UsersView: View {
@StateObject private var vm = UsersViewModel()
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.users.isEmpty {
skeletonList
} else if let err = vm.error, vm.users.isEmpty {
inlineError(err) { Task { await vm.load() } }
} else {
userList
}
}
.navigationTitle("Användare")
.searchable(text: $vm.searchText, prompt: "Sök namn eller e-post")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
RoleFilterMenu(
roles: vm.availableRoles,
selected: $vm.selectedRole
)
}
}
.refreshable { await vm.load() }
}
.task { await vm.load() }
}
private var userList: some View {
ScrollView {
LazyVStack(spacing: 8) {
if vm.filteredUsers.isEmpty {
emptySearchState
} else {
ForEach(vm.filteredUsers) { user in
NavigationLink {
UserDetailView(user: user) {
Task { await vm.toggleStatus(user) }
}
} label: {
UserRowView(user: user)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
}
}
private var skeletonList: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(0..<8, id: \.self) { _ in
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
.frame(height: 72)
}
}
.padding(16)
}
}
private var emptySearchState: some View {
VStack(spacing: 8) {
Image(systemName: "person.slash")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("Inga användare hittades")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(48)
}
}
struct UserRowView: View {
let user: ManagedUser
private var initials: String {
user.name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined()
}
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(user.isActive ? Color(.systemBlue).opacity(0.15) : Color(.systemGray5))
Text(initials)
.font(.callout.weight(.semibold))
.foregroundStyle(user.isActive ? Color(.systemBlue) : Color(.systemGray))
}
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(user.name)
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
Text(user.email)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing, spacing: 6) {
Text(user.role.capitalized)
.font(.caption.weight(.medium))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.systemBlue).opacity(0.1))
.foregroundStyle(Color(.systemBlue))
.cornerRadius(6)
Circle()
.fill(user.isActive ? Color(.systemGreen) : Color(.systemGray3))
.frame(width: 8, height: 8)
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.tertiaryLabel))
}
.padding(12)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
struct UserDetailView: View {
let user: ManagedUser
let onToggle: () -> Void
@Environment(\.dismiss) private var dismiss
private var initials: String {
user.name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined()
}
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
profileHeader
infoCard
actionCard
}
.padding(16)
}
.navigationTitle(user.name)
.navigationBarTitleDisplayMode(.large)
.background(Color(.systemBackground))
}
private var profileHeader: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(user.isActive ? Color(.systemBlue).opacity(0.15) : Color(.systemGray5))
Text(initials)
.font(.system(size: 32, weight: .semibold))
.foregroundStyle(user.isActive ? Color(.systemBlue) : Color(.systemGray))
}
.frame(width: 80, height: 80)
VStack(spacing: 4) {
Text(user.name).font(.title2.bold())
Text(user.email)
.font(.callout)
.foregroundStyle(.secondary)
}
StatusBadge(isActive: user.isActive)
}
.padding(24)
.frame(maxWidth: .infinity)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
private var infoCard: some View {
VStack(spacing: 8) {
Text("Information")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
InfoRow(label: "Roll", value: user.role.capitalized)
Divider().padding(.leading, 16)
InfoRow(
label: "Skapad",
value: user.createdAt.formatted(date: .abbreviated, time: .omitted)
)
if let lastSeen = user.lastSeen {
Divider().padding(.leading, 16)
InfoRow(
label: "Senast aktiv",
value: lastSeen.formatted(.relative(presentation: .named))
)
}
}
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
private var actionCard: some View {
VStack(spacing: 8) {
Text("Åtgärder")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
Button {
onToggle()
dismiss()
} label: {
HStack(spacing: 12) {
Image(systemName: user.isActive ? "person.slash.fill" : "person.fill.checkmark")
Text(user.isActive ? "Inaktivera användare" : "Aktivera användare")
.font(.callout.weight(.medium))
Spacer()
}
.padding(16)
.background(
user.isActive
? Color(.systemRed).opacity(0.1)
: Color(.systemGreen).opacity(0.1)
)
.foregroundStyle(user.isActive ? Color(.systemRed) : Color(.systemGreen))
.cornerRadius(12)
}
}
}
}
struct StatusBadge: View {
let isActive: Bool
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(isActive ? Color(.systemGreen) : Color(.systemGray3))
.frame(width: 8, height: 8)
Text(isActive ? "Aktiv" : "Inaktiv")
.font(.footnote.weight(.medium))
.foregroundStyle(isActive ? Color(.systemGreen) : Color(.systemGray))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(isActive ? Color(.systemGreen).opacity(0.1) : Color(.systemGray5))
.cornerRadius(20)
}
}
struct InfoRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.callout)
.foregroundStyle(.secondary)
Spacer()
Text(value)
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
struct RoleFilterMenu: View {
let roles: [String]
@Binding var selected: String?
var body: some View {
Menu {
Button {
selected = nil
} label: {
Label("Alla roller", systemImage: selected == nil ? "checkmark" : "")
}
ForEach(roles, id: \.self) { role in
Button {
selected = selected == role ? nil : role
} label: {
Label(role.capitalized, systemImage: selected == role ? "checkmark" : "")
}
}
} label: {
Image(systemName: selected != nil
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.foregroundStyle(Color(.systemBlue))
}
}
}
// MARK: - Modules ViewModel
@MainActor
final class ModulesViewModel: ObservableObject {
@Published var modules: [AppModule] = []
@Published var selectedCategory: String? = nil
@Published var isLoading = false
@Published var togglingId: String? = nil
@Published var error: String?
private let api = APIClient.shared
var categories: [String] { Array(Set(modules.map(\.category))).sorted() }
var filtered: [AppModule] {
guard let cat = selectedCategory else { return modules }
return modules.filter { $0.category == cat }
}
func load() async {
isLoading = true
error = nil
defer { isLoading = false }
do {
modules = try await api.get("/modules")
} catch {
self.error = error.localizedDescription
}
}
func toggle(_ module: AppModule) async {
guard togglingId == nil else { return }
togglingId = module.id
defer { togglingId = nil }
struct Body: Encodable { let isEnabled: Bool }
do {
let updated: AppModule = try await api.patch(
"/modules/\(module.id)",
body: Body(isEnabled: !module.isEnabled)
)
if let idx = modules.firstIndex(where: { $0.id == updated.id }) {
modules[idx] = updated
}
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Modules View
struct ModulesView: View {
@StateObject private var vm = ModulesViewModel()
private let columns = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.modules.isEmpty {
skeletonGrid
} else if let err = vm.error, vm.modules.isEmpty {
inlineError(err) { Task { await vm.load() } }
} else {
moduleGrid
}
}
.navigationTitle("Moduler")
.toolbar {
if !vm.categories.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
CategoryFilterMenu(
categories: vm.categories,
selected: $vm.selectedCategory
)
}
}
}
.refreshable { await vm.load() }
}
.task { await vm.load() }
}
private var moduleGrid: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(vm.filtered) { module in
ModuleCardView(
module: module,
isToggling: vm.togglingId == module.id
) {
Task { await vm.toggle(module) }
}
}
}
.padding(16)
}
}
private var skeletonGrid: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(0..<6, id: \.self) { _ in
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
.frame(height: 150)
}
}
.padding(16)
}
}
}
struct ModuleCardView: View {
let module: AppModule
let isToggling: Bool
let onToggle: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: module.iconName)
.font(.title3.weight(.medium))
.foregroundStyle(module.isEnabled ? Color(.systemBlue) : Color(.systemGray))
Spacer()
if isToggling {
ProgressView().scaleEffect(0.75)
} else {
Toggle("", isOn: Binding(
get: { module.isEnabled },
set: { _ in onToggle() }
))
.labelsHidden()
.tint(Color(.systemGreen))
.scaleEffect(0.8)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(module.name)
.font(.callout.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
Text(module.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
HStack {
Text(module.category)
.font(.caption2.weight(.medium))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color(.systemGray5))
.foregroundStyle(.secondary)
.cornerRadius(4)
Spacer()
Text("v\(module.version)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(16)
.frame(minHeight: 150, alignment: .top)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.opacity(module.isEnabled ? 1.0 : 0.65)
}
}
struct CategoryFilterMenu: View {
let categories: [String]
@Binding var selected: String?
var body: some View {
Menu {
Button {
selected = nil
} label: {
Label("Alla kategorier", systemImage: selected == nil ? "checkmark" : "")
}
ForEach(categories, id: \.self) { cat in
Button {
selected = selected == cat ? nil : cat
} label: {
Label(cat, systemImage: selected == cat ? "checkmark" : "")
}
}
} label: {
Image(systemName: selected != nil
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.foregroundStyle(Color(.systemBlue))
}
}
}
// MARK: - Audit ViewModel
@MainActor
final class AuditViewModel: ObservableObject {
@Published var entries: [AuditEntry] = []
@Published var selectedSeverity: AuditEntry.AuditSeverity? = nil
@Published var isLoading = false
@Published var isLoadingMore = false
@Published var hasMore = true
@Published var error: String?
private let api = APIClient.shared
private let pageSize = 25
private var page = 0
var filtered: [AuditEntry] {
guard let sev = selectedSeverity else { return entries }
return entries.filter { $0.severity == sev }
}
func load() async {
page = 0
hasMore = true
isLoading = true
error = nil
defer { isLoading = false }
do {
let result: [AuditEntry] = try await api.get("/audit?page=0&limit=\(pageSize)&sort=desc")
entries = result
hasMore = result.count == pageSize
page = 1
} catch {
self.error = error.localizedDescription
}
}
func loadMore() async {
guard hasMore, !isLoadingMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let result: [AuditEntry] = try await api.get("/audit?page=\(page)&limit=\(pageSize)&sort=desc")
entries.append(contentsOf: result)
hasMore = result.count == pageSize
page += 1
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Audit View
struct AuditView: View {
@StateObject private var vm = AuditViewModel()
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.entries.isEmpty {
skeletonList
} else if let err = vm.error, vm.entries.isEmpty {
inlineError(err) { Task { await vm.load() } }
} else {
auditList
}
}
.navigationTitle("Auditlogg")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
SeverityFilterMenu(selected: $vm.selectedSeverity)
}
}
.refreshable { await vm.load() }
}
.task { await vm.load() }
}
private var auditList: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(vm.filtered) { entry in
AuditRowView(entry: entry)
.onAppear {
if entry.id == vm.filtered.last?.id {
Task { await vm.loadMore() }
}
}
}
if vm.isLoadingMore {
ProgressView().padding(16)
}
}
.padding(16)
}
}
private var skeletonList: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(0..<10, id: \.self) { _ in
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
.frame(height: 76)
}
}
.padding(16)
}
}
}
struct AuditRowView: View {
let entry: AuditEntry
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
} label: {
HStack(spacing: 12) {
Image(systemName: entry.severity.icon)
.font(.callout)
.foregroundStyle(entry.severity.color)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(entry.action)
.font(.callout.weight(.medium))
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 4) {
Text(entry.actorName)
Text("·")
Text(entry.resource)
}
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .trailing, spacing: 2) {
Text(entry.timestamp, style: .time)
Text(entry.timestamp, style: .date)
}
.font(.caption2)
.foregroundStyle(.tertiary)
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color(.tertiaryLabel))
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.padding(12)
}
.buttonStyle(.plain)
if expanded, let details = entry.details, !details.isEmpty {
Divider().padding(.leading, 48)
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 48)
.padding(.trailing, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.clipped()
}
}
struct SeverityFilterMenu: View {
@Binding var selected: AuditEntry.AuditSeverity?
var body: some View {
Menu {
Button {
selected = nil
} label: {
Label("Alla nivåer", systemImage: selected == nil ? "checkmark" : "")
}
ForEach([AuditEntry.AuditSeverity.info, .warning, .critical], id: \.self) { sev in
Button {
selected = selected == sev ? nil : sev
} label: {
Label(sev.label, systemImage: selected == sev ? "checkmark" : "")
}
}
} label: {
Image(systemName: selected != nil
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.foregroundStyle(Color(.systemBlue))
}
}
}
// MARK: - Settings View
struct SettingsView: View {
@EnvironmentObject private var auth: AuthViewModel
@State private var showLogoutConfirm = false
@State private var notificationsEnabled = true
@State private var biometricEnabled = false
@AppStorage("prefersDarkMode") private var prefersDarkMode = false
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 16) {
profileSection
preferencesSection
securitySection
aboutSection
logoutButton
}
.padding(16)
}
.navigationTitle("Inställningar")
.background(Color(.systemBackground))
}
.confirmationDialog(
"Logga ut",
isPresented: $showLogoutConfirm,
titleVisibility: .visible
) {
Button("Logga ut", role: .destructive) { auth.logout() }
Button("Avbryt", role: .cancel) {}
} message: {
Text("Är du säker på att du vill logga ut?")
}
}
private var profileSection: some View {
Group {
if let user = auth.currentUser {
HStack(spacing: 16) {
ZStack {
Circle()
.fill(Color(.systemBlue).opacity(0.15))
Text(initials(user.name))
.font(.title3.weight(.semibold))
.foregroundStyle(Color(.systemBlue))
}
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 4) {
Text(user.name).font(.headline)
Text(user.email)
.font(.callout)
.foregroundStyle(.secondary)
Text(user.role.capitalized)
.font(.caption.weight(.medium))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.systemBlue).opacity(0.1))
.foregroundStyle(Color(.systemBlue))
.cornerRadius(6)
}
Spacer()
}
.padding(16)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
}
private var preferencesSection: some View {
VStack(spacing: 8) {
Text("Inställningar")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
SettingsToggleRow(
icon: "bell.fill", iconColor: Color(.systemRed),
title: "Notifikationer", isOn: $notificationsEnabled
)
Divider().padding(.leading, 56)
SettingsToggleRow(
icon: "moon.fill", iconColor: Color(.systemIndigo),
title: "Mörkt läge", isOn: $prefersDarkMode
)
}
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
private var securitySection: some View {
VStack(spacing: 8) {
Text("Säkerhet")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
SettingsToggleRow(
icon: "faceid", iconColor: Color(.systemGreen),
title: "Face ID / Touch ID", isOn: $biometricEnabled
)
Divider().padding(.leading, 56)
SettingsNavRow(
icon: "lock.rotation", iconColor: Color(.systemOrange),
title: "Byt lösenord"
) {}
Divider().padding(.leading, 56)
SettingsNavRow(
icon: "key.fill", iconColor: Color(.systemBlue),
title: "API-nycklar"
) {}
}
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
private var aboutSection: some View {
VStack(spacing: 8) {
Text("Om")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
InfoRow(
label: "Version",
value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
)
Divider().padding(.leading, 16)
InfoRow(
label: "Build",
value: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
)
Divider().padding(.leading, 16)
InfoRow(label: "Server", value: APIClient.shared.baseURL)
}
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
}
private var logoutButton: some View {
Button { showLogoutConfirm = true } label: {
HStack {
Spacer()
Image(systemName: "rectangle.portrait.and.arrow.right")
Text("Logga ut").font(.callout.weight(.semibold))
Spacer()
}
.padding(16)
.background(Color(.systemRed).opacity(0.1))
.foregroundStyle(Color(.systemRed))
.cornerRadius(12)
}
}
private func initials(_ name: String) -> String {
name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined()
}
}
struct SettingsToggleRow: View {
let icon: String
let iconColor: Color
let title: String
@Binding var isOn: Bool
var body: some View {
HStack(spacing: 12) {
iconView
Text(title).font(.callout).foregroundStyle(.primary)
Spacer()
Toggle("", isOn: $isOn).labelsHidden().tint(Color(.systemGreen))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var iconView: some View {
ZStack {
RoundedRectangle(cornerRadius: 8).fill(iconColor)
Image(systemName: icon)
.font(.callout.weight(.medium))
.foregroundStyle(.white)
}
.frame(width: 32, height: 32)
}
}
struct SettingsNavRow: View {
let icon: String
let iconColor: Color
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8).fill(iconColor)
Image(systemName: icon)
.font(.callout.weight(.medium))
.foregroundStyle(.white)
}
.frame(width: 32, height: 32)
Text(title).font(.callout).foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.tertiaryLabel))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}
// MARK: - Shared Error Component
private func inlineError(_ message: String, retry: @escaping () -> Void) -> some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundStyle(Color(.systemOrange))
Text(message)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Försök igen", action: retry)
.buttonStyle(.borderedProminent)
.tint(Color(.systemBlue))
}
.padding(40)
.frame(maxWidth: .infinity)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.padding(16)
}
// MARK: - Content View
struct ContentView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
DashboardView()
.tabItem { Label("Dashboard", systemImage: "house.fill") }
.tag(0)
UsersView()
.tabItem { Label("Användare", systemImage: "person.2.fill") }
.tag(1)
ModulesView()
.tabItem { Label("Moduler", systemImage: "square.grid.2x2.fill") }
.tag(2)
AuditView()
.tabItem { Label("Logg", systemImage: "doc.text.fill") }
.tag(3)
SettingsView()
.tabItem { Label("Inställningar", systemImage: "gear") }
.tag(4)
}
.tint(Color(.systemBlue))
}
}