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

293 lines
9.4 KiB
Swift

import SwiftUI
// MARK: - API Infrastructure
enum APIError: LocalizedError {
case invalidURL
case unauthorized
case serverError(Int, String?)
case networkError(Error)
case decodingError
var errorDescription: String? {
switch self {
case .invalidURL: return "Ogiltig URL"
case .unauthorized: return "Felaktiga inloggningsuppgifter"
case .serverError(let c, let m): return m ?? "Serverfel (\(c))"
case .networkError(let e): return "Nätverksfel: \(e.localizedDescription)"
case .decodingError: return "Dataformatfel från servern"
}
}
}
final class APIClient {
static let shared = APIClient()
var baseURL = "https://api.aamos.io/v1"
private var token: String?
private let session: URLSession
private init() {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
session = URLSession(configuration: cfg)
}
func setToken(_ token: String?) { self.token = token }
func get<T: Decodable>(_ path: String) async throws -> T {
try await request(path, method: "GET")
}
func post<B: Encodable, T: Decodable>(_ path: String, body: B) async throws -> T {
try await request(path, method: "POST", body: try JSONEncoder().encode(body))
}
func patch<B: Encodable, T: Decodable>(_ path: String, body: B) async throws -> T {
try await request(path, method: "PATCH", body: try JSONEncoder().encode(body))
}
func delete(_ path: String) async throws {
let _: EmptyResponse = try await request(path, method: "DELETE")
}
private struct EmptyResponse: Decodable {}
private struct ServerError: Decodable { let message: String? }
private func request<T: Decodable>(_ path: String, method: String, body: Data? = nil) async throws -> T {
guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
req.httpBody = body
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: req)
} catch {
throw APIError.networkError(error)
}
if let http = response as? HTTPURLResponse {
if http.statusCode == 401 { throw APIError.unauthorized }
if http.statusCode >= 400 {
let msg = try? JSONDecoder().decode(ServerError.self, from: data)
throw APIError.serverError(http.statusCode, msg?.message)
}
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let result = try? decoder.decode(T.self, from: data) else {
throw APIError.decodingError
}
return result
}
}
// MARK: - Auth Model
struct AdminUser: Codable, Identifiable {
let id: String
let name: String
let email: String
let role: String
}
// MARK: - Auth ViewModel
@MainActor
final class AuthViewModel: ObservableObject {
@Published var isAuthenticated = false
@Published var isLoading = false
@Published var errorMessage: String?
@Published var currentUser: AdminUser?
private let api = APIClient.shared
private struct LoginRequest: Encodable {
let email: String
let password: String
}
private struct LoginResponse: Decodable {
let token: String
let user: AdminUser
}
func login(email: String, password: String) async {
guard !email.trimmingCharacters(in: .whitespaces).isEmpty, !password.isEmpty else {
errorMessage = "Fyll i e-post och lösenord"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response: LoginResponse = try await api.post(
"/auth/login",
body: LoginRequest(email: email, password: password)
)
api.setToken(response.token)
currentUser = response.user
isAuthenticated = true
} catch {
errorMessage = error.localizedDescription
}
}
func logout() {
api.setToken(nil)
currentUser = nil
isAuthenticated = false
}
}
// MARK: - Login View
struct LoginView: View {
@EnvironmentObject private var auth: AuthViewModel
@State private var email = ""
@State private var password = ""
@FocusState private var focused: LoginField?
private enum LoginField { case email, password }
var body: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
Spacer(minLength: 72)
logoSection
Spacer(minLength: 48)
formSection
Spacer(minLength: 40)
Text("AAMOS Admin v1.0")
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.bottom, 8)
}
.padding(.horizontal, 24)
}
}
}
private var logoSection: some View {
VStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color(.systemBlue).opacity(0.12))
.frame(width: 88, height: 88)
Image(systemName: "shield.checkered")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(Color(.systemBlue))
}
Text("AAMOS Admin")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.primary)
Text("Administrationsportal")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private var formSection: some View {
VStack(spacing: 16) {
VStack(spacing: 8) {
TextField("E-postadress", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.autocorrectionDisabled()
.focused($focused, equals: .email)
.submitLabel(.next)
.onSubmit { focused = .password }
.padding(16)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
SecureField("Lösenord", text: $password)
.textContentType(.password)
.focused($focused, equals: .password)
.submitLabel(.go)
.onSubmit {
focused = nil
Task { await auth.login(email: email, password: password) }
}
.padding(16)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
if let error = auth.errorMessage {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.circle.fill")
.font(.callout)
.foregroundStyle(Color(.systemRed))
Text(error)
.font(.callout)
.foregroundStyle(Color(.systemRed))
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemRed).opacity(0.08))
.cornerRadius(12)
.transition(.opacity.combined(with: .move(edge: .top)))
}
Button {
focused = nil
Task { await auth.login(email: email, password: password) }
} label: {
Group {
if auth.isLoading {
ProgressView().tint(.white).scaleEffect(0.9)
} else {
Text("Logga in").font(.headline)
}
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(auth.isLoading ? Color(.systemBlue).opacity(0.7) : Color(.systemBlue))
.foregroundStyle(.white)
.cornerRadius(12)
}
.disabled(auth.isLoading)
.animation(.easeInOut(duration: 0.15), value: auth.isLoading)
}
}
}
// MARK: - App Entry Point
@main
struct AamosAdminApp: App {
@StateObject private var auth = AuthViewModel()
var body: some Scene {
WindowGroup {
Group {
if auth.isAuthenticated {
ContentView()
.environmentObject(auth)
.transition(.opacity)
} else {
LoginView()
.environmentObject(auth)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.25), value: auth.isAuthenticated)
}
}
}