05/02 Phase C 2
This commit is contained in:
+131
-144
@@ -1,12 +1,9 @@
|
|||||||
// API/APIClient.swift
|
// API/APIClient.swift
|
||||||
// -------------------
|
|
||||||
// Central HTTP client for all JQC API calls.
|
|
||||||
// Phase C adds: fetchInspectionHistory()
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
enum APIError: Error, LocalizedError {
|
enum APIError: Error, LocalizedError, Sendable {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
case notAuthenticated
|
case notAuthenticated
|
||||||
case serverError(String)
|
case serverError(String)
|
||||||
@@ -24,160 +21,125 @@ enum APIError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File-scope envelope — cannot be nested in a generic function (Swift restriction).
|
||||||
|
// T is constrained to Sendable so `data: T?` does not inherit @MainActor isolation.
|
||||||
|
private struct _Envelope<T: Decodable & Sendable>: Decodable, Sendable {
|
||||||
|
let ok: Bool
|
||||||
|
let data: T?
|
||||||
|
let error: String?
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
ok = try c.decode(Bool.self, forKey: .ok)
|
||||||
|
data = try? c.decode(T.self, forKey: .data)
|
||||||
|
error = try? c.decode(String.self, forKey: .error)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case ok, data, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh-only envelope — uses a non-Sendable-constrained local struct
|
||||||
|
// decoded manually to avoid pulling RefreshResponseData into the Sendable chain.
|
||||||
|
private struct _RefreshEnvelope: Decodable {
|
||||||
|
struct Tokens: Decodable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
}
|
||||||
|
let ok: Bool
|
||||||
|
let data: Tokens?
|
||||||
|
}
|
||||||
|
|
||||||
actor APIClient {
|
actor APIClient {
|
||||||
static let shared = APIClient()
|
static let shared = APIClient()
|
||||||
|
|
||||||
private let baseURL: String
|
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.baseURL = Constants.baseURL
|
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.timeoutIntervalForRequest = 30
|
config.timeoutIntervalForRequest = 30
|
||||||
self.session = URLSession(configuration: config)
|
self.session = URLSession(configuration: config)
|
||||||
|
self.decoder = JSONDecoder()
|
||||||
|
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generic JSON Request ──────────────────────────────────────────────
|
// ── Generic JSON Request ──────────────────────────────────────────────
|
||||||
|
|
||||||
func request<T: Decodable>(
|
func request<T: Decodable & Sendable>(
|
||||||
_ endpoint: String,
|
_ endpoint: String,
|
||||||
method: String = "GET",
|
method: String = "GET",
|
||||||
body: [String: Any]? = nil,
|
body: [String: Any]? = nil,
|
||||||
retrying: Bool = false
|
retrying: Bool = false
|
||||||
) async throws -> T {
|
) async throws -> T {
|
||||||
|
let url = try buildURL(endpoint)
|
||||||
|
var req = buildRequest(url: url, method: method, body: body)
|
||||||
|
injectToken(&req)
|
||||||
|
|
||||||
guard let url = URL(string: baseURL + endpoint) else {
|
let (data, response) = try await performRequest(req)
|
||||||
throw APIError.invalidURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
if shouldRefresh(response, retrying: retrying) {
|
||||||
req.httpMethod = method
|
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
|
|
||||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
||||||
}
|
|
||||||
|
|
||||||
if let body {
|
|
||||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
|
||||||
}
|
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: req)
|
|
||||||
|
|
||||||
guard let http = response as? HTTPURLResponse else {
|
|
||||||
throw APIError.networkError("Invalid response")
|
|
||||||
}
|
|
||||||
|
|
||||||
if http.statusCode == 401 && !retrying {
|
|
||||||
let refreshed = await refreshAccessToken()
|
let refreshed = await refreshAccessToken()
|
||||||
if refreshed {
|
if refreshed {
|
||||||
return try await request(endpoint, method: method, body: body, retrying: true)
|
return try await request(endpoint, method: method, body: body, retrying: true)
|
||||||
} else {
|
}
|
||||||
throw APIError.notAuthenticated
|
throw APIError.notAuthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return try decode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
func post<T: Decodable & Sendable>(_ endpoint: String, body: [String: Any]) async throws -> T {
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
|
|
||||||
if let envelope = try? decoder.decode(APIResponse<T>.self, from: data) {
|
|
||||||
if envelope.ok, let result = envelope.data {
|
|
||||||
return result
|
|
||||||
} else {
|
|
||||||
throw APIError.serverError(envelope.error ?? "Unknown server error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
return try decoder.decode(T.self, from: data)
|
|
||||||
} catch {
|
|
||||||
throw APIError.decodingError(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func post<T: Decodable>(_ endpoint: String, body: [String: Any]) async throws -> T {
|
|
||||||
return try await request(endpoint, method: "POST", body: body)
|
return try await request(endpoint, method: "POST", body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Inspection History (Phase C) ──────────────────────────────────────
|
// ── Inspection History ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Fetch the inspector's synced inspection history from the server.
|
|
||||||
/// Returns up to `limit` records starting at `offset`.
|
|
||||||
func fetchInspectionHistory(
|
func fetchInspectionHistory(
|
||||||
limit: Int = 50,
|
limit: Int = 50,
|
||||||
offset: Int = 0,
|
offset: Int = 0,
|
||||||
facilityId: Int? = nil
|
facilityId: Int? = nil
|
||||||
) async throws -> InspectionHistoryResponseData {
|
) async throws -> InspectionHistoryResponseData {
|
||||||
var endpoint = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
|
var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
|
||||||
if let fid = facilityId {
|
if let fid = facilityId { ep += "&facility_id=\(fid)" }
|
||||||
endpoint += "&facility_id=\(fid)"
|
return try await request(ep)
|
||||||
}
|
|
||||||
return try await request(endpoint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Photo Upload (multipart/form-data) ────────────────────────────────
|
// ── Photo Upload ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
|
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
|
||||||
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else {
|
let url = try buildURL("/api/v1/photos/upload")
|
||||||
throw APIError.invalidURL
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
||||||
throw APIError.networkError("Could not read photo file: \(localPath)")
|
throw APIError.networkError("Could not read photo: \(localPath)")
|
||||||
}
|
}
|
||||||
|
|
||||||
let boundary = "Boundary-\(UUID().uuidString)"
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
var body = Data()
|
|
||||||
|
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
|
||||||
body.append("Content-Disposition: form-data; name=\"entity_type\"\r\n\r\n".data(using: .utf8)!)
|
|
||||||
body.append("\(entityType)\r\n".data(using: .utf8)!)
|
|
||||||
|
|
||||||
let filename = URL(fileURLWithPath: localPath).lastPathComponent
|
let filename = URL(fileURLWithPath: localPath).lastPathComponent
|
||||||
let ext = (filename as NSString).pathExtension.lowercased()
|
let ext = (filename as NSString).pathExtension.lowercased()
|
||||||
let mimeType = ext == "png" ? "image/png" : "image/jpeg"
|
let mime = ext == "png" ? "image/png" : "image/jpeg"
|
||||||
|
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
var body = Data()
|
||||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\n\(entityType)\r\n".data(using: .utf8)!)
|
||||||
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
|
||||||
body.append(imageData)
|
body.append(imageData)
|
||||||
body.append("\r\n".data(using: .utf8)!)
|
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.httpMethod = "POST"
|
req.httpMethod = "POST"
|
||||||
req.setValue("multipart/form-data; boundary=\(boundary)",
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
forHTTPHeaderField: "Content-Type")
|
injectToken(&req)
|
||||||
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
|
|
||||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
||||||
}
|
|
||||||
req.httpBody = body
|
req.httpBody = body
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: req)
|
let (data, response) = try await performRequest(req)
|
||||||
|
|
||||||
guard let http = response as? HTTPURLResponse else {
|
if shouldRefresh(response, retrying: false) {
|
||||||
throw APIError.networkError("Invalid response")
|
|
||||||
}
|
|
||||||
|
|
||||||
if http.statusCode == 401 {
|
|
||||||
let refreshed = await refreshAccessToken()
|
let refreshed = await refreshAccessToken()
|
||||||
if refreshed {
|
if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType) }
|
||||||
return try await uploadPhoto(localPath: localPath, entityType: entityType)
|
|
||||||
} else {
|
|
||||||
throw APIError.notAuthenticated
|
throw APIError.notAuthenticated
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
||||||
|
|
||||||
struct PhotoUploadData: Decodable { let serverPath: String }
|
|
||||||
if let envelope = try? decoder.decode(APIResponse<PhotoUploadData>.self, from: data),
|
|
||||||
envelope.ok,
|
|
||||||
let result = envelope.data {
|
|
||||||
return result.serverPath
|
|
||||||
}
|
|
||||||
|
|
||||||
|
struct PhotoResult: Decodable, Sendable { let serverPath: String }
|
||||||
|
if let env = try? decoder.decode(_Envelope<PhotoResult>.self, from: data),
|
||||||
|
env.ok, let r = env.data { return r.serverPath }
|
||||||
throw APIError.serverError("Photo upload failed")
|
throw APIError.serverError("Photo upload failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,25 +152,18 @@ actor APIClient {
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"form_data": inspection.formData,
|
"form_data": inspection.formData,
|
||||||
"mobile_local_id": inspection.localId,
|
"mobile_local_id": inspection.localId,
|
||||||
"overall_score": inspection.overallScore as Any,
|
|
||||||
]
|
]
|
||||||
|
if let score = inspection.overallScore { body["overall_score"] = score }
|
||||||
if let areaId = inspection.areaServerId { body["area_id"] = areaId }
|
if let areaId = inspection.areaServerId { body["area_id"] = areaId }
|
||||||
if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
|
if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
|
||||||
|
|
||||||
let formatter = ISO8601DateFormatter()
|
let fmt = ISO8601DateFormatter()
|
||||||
body["inspection_date"] = formatter.string(from: inspection.inspectionDate)
|
body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
|
||||||
if let completedAt = inspection.completedAt {
|
if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
|
||||||
body["completed_at"] = formatter.string(from: completedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct InspectionResponseData: Decodable {
|
struct R: Decodable, Sendable { let inspectionId: Int; let duplicate: Bool }
|
||||||
let inspectionId: Int
|
let r: R = try await post("/api/v1/inspections", body: body)
|
||||||
let duplicate: Bool
|
return r.inspectionId
|
||||||
}
|
|
||||||
|
|
||||||
let result: InspectionResponseData = try await post("/api/v1/inspections", body: body)
|
|
||||||
return result.inspectionId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Submit Issue ──────────────────────────────────────────────────────
|
// ── Submit Issue ──────────────────────────────────────────────────────
|
||||||
@@ -220,53 +175,85 @@ actor APIClient {
|
|||||||
"description": issue.issueDescription,
|
"description": issue.issueDescription,
|
||||||
"mobile_local_id": issue.localId,
|
"mobile_local_id": issue.localId,
|
||||||
]
|
]
|
||||||
|
if let id = issue.inspection?.serverId { body["inspection_id"] = id }
|
||||||
|
if let path = issue.photoServerPath { body["photo_path"] = path }
|
||||||
|
|
||||||
if let inspServerId = issue.inspection?.serverId {
|
struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
|
||||||
body["inspection_id"] = inspServerId
|
let r: R = try await post("/api/v1/issues", body: body)
|
||||||
}
|
return r.issueId
|
||||||
if let serverPhotoPath = issue.photoServerPath {
|
|
||||||
body["photo_path"] = serverPhotoPath
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IssueResponseData: Decodable {
|
|
||||||
let issueId: Int
|
|
||||||
let duplicate: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: IssueResponseData = try await post("/api/v1/issues", body: body)
|
|
||||||
return result.issueId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Token Refresh ─────────────────────────────────────────────────────
|
// ── Token Refresh ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private func refreshAccessToken() async -> Bool {
|
private func refreshAccessToken() async -> Bool {
|
||||||
guard let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken),
|
guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
|
||||||
let url = URL(string: baseURL + "/api/v1/auth/refresh") else {
|
let url = URL(string: Constants.baseURL + "/api/v1/auth/refresh")
|
||||||
return false
|
else { return false }
|
||||||
}
|
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.httpMethod = "POST"
|
req.httpMethod = "POST"
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
req.httpBody = try? JSONSerialization.data(
|
req.httpBody = try? JSONSerialization.data(withJSONObject: ["refresh_token": token])
|
||||||
withJSONObject: ["refresh_token": refreshToken]
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let (data, response) = try? await session.data(for: req),
|
guard let (data, response) = try? await session.data(for: req),
|
||||||
let http = response as? HTTPURLResponse,
|
let http = response as? HTTPURLResponse, http.statusCode == 200
|
||||||
http.statusCode == 200
|
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
guard let env = try? decoder.decode(_RefreshEnvelope.self, from: data),
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
env.ok,
|
||||||
|
let tokens = env.data
|
||||||
guard let envelope = try? decoder.decode(APIResponse<RefreshResponseData>.self, from: data),
|
|
||||||
envelope.ok,
|
|
||||||
let refreshData = envelope.data
|
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
KeychainHelper.set(refreshData.accessToken, forKey: Constants.Keychain.accessToken)
|
KeychainHelper.set(tokens.accessToken, forKey: Constants.Keychain.accessToken)
|
||||||
KeychainHelper.set(refreshData.refreshToken, forKey: Constants.Keychain.refreshToken)
|
KeychainHelper.set(tokens.refreshToken, forKey: Constants.Keychain.refreshToken)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Private Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func buildURL(_ endpoint: String) throws -> URL {
|
||||||
|
guard let url = URL(string: Constants.baseURL + endpoint) else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildRequest(url: URL, method: String, body: [String: Any]?) -> URLRequest {
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = method
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
if let body { req.httpBody = try? JSONSerialization.data(withJSONObject: body) }
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
private func injectToken(_ req: inout URLRequest) {
|
||||||
|
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
|
||||||
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performRequest(_ req: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
do {
|
||||||
|
return try await session.data(for: req)
|
||||||
|
} catch {
|
||||||
|
throw APIError.networkError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldRefresh(_ response: URLResponse, retrying: Bool) -> Bool {
|
||||||
|
guard !retrying, let http = response as? HTTPURLResponse else { return false }
|
||||||
|
return http.statusCode == 401
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decode<T: Decodable & Sendable>(_ data: Data) throws -> T {
|
||||||
|
if let env = try? decoder.decode(_Envelope<T>.self, from: data) {
|
||||||
|
if env.ok, let result = env.data { return result }
|
||||||
|
throw APIError.serverError(env.error ?? "Unknown server error")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw APIError.decodingError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,145 @@
|
|||||||
// API/APIModels.swift
|
// API/APIModels.swift
|
||||||
// -------------------
|
// -------------------
|
||||||
// Codable structs that map to the JSON responses from the JQC Flask API.
|
// All types in this file are pure value types with no actor isolation.
|
||||||
// All API responses use the envelope: { "ok": bool, "data": {...}, "error": string? }
|
// AnyDecodable uses a JSONValue enum (not `Any`) so it is fully Sendable
|
||||||
|
// without @unchecked and causes no actor-isolation warnings.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// ── JSONValue — replaces AnyDecodable ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Using `Any` as a stored property is not Sendable, which causes the Swift
|
||||||
|
// compiler to defensively infer @MainActor on any struct containing it.
|
||||||
|
// A typed enum avoids this entirely: every case is a concrete Sendable type.
|
||||||
|
|
||||||
|
enum JSONValue: Decodable, Sendable {
|
||||||
|
case string(String)
|
||||||
|
case int(Int)
|
||||||
|
case double(Double)
|
||||||
|
case bool(Bool)
|
||||||
|
case array([JSONValue])
|
||||||
|
case object([String: JSONValue])
|
||||||
|
case null
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.singleValueContainer()
|
||||||
|
// Order matters: Bool before Int (Bool is Int-decodable on some platforms)
|
||||||
|
if let v = try? c.decode(Bool.self) { self = .bool(v); return }
|
||||||
|
if let v = try? c.decode(Int.self) { self = .int(v); return }
|
||||||
|
if let v = try? c.decode(Double.self) { self = .double(v); return }
|
||||||
|
if let v = try? c.decode(String.self) { self = .string(v); return }
|
||||||
|
if let v = try? c.decode([JSONValue].self) { self = .array(v); return }
|
||||||
|
if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return }
|
||||||
|
self = .null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Any for compatibility with existing formData/formSchema code
|
||||||
|
var anyValue: Any {
|
||||||
|
switch self {
|
||||||
|
case .string(let v): return v
|
||||||
|
case .int(let v): return v
|
||||||
|
case .double(let v): return v
|
||||||
|
case .bool(let v): return v
|
||||||
|
case .null: return NSNull()
|
||||||
|
case .array(let v): return v.map(\.anyValue)
|
||||||
|
case .object(let v): return v.mapValues(\.anyValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typealias so existing code that references AnyDecodable still compiles
|
||||||
|
typealias AnyDecodable = JSONValue
|
||||||
|
|
||||||
// ── API Envelope ──────────────────────────────────────────────────────────────
|
// ── API Envelope ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
struct APIResponse<T: Decodable>: Decodable {
|
struct APIResponse<T: Decodable & Sendable>: Decodable, Sendable {
|
||||||
let ok: Bool
|
let ok: Bool
|
||||||
let data: T?
|
let data: T?
|
||||||
let error: String?
|
let error: String?
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
ok = try c.decode(Bool.self, forKey: .ok)
|
||||||
|
data = try? c.decode(T.self, forKey: .data)
|
||||||
|
error = try? c.decode(String.self, forKey: .error)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case ok, data, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
struct LoginResponseData: Decodable {
|
struct APIUser: Decodable, Sendable {
|
||||||
let accessToken: String
|
|
||||||
let refreshToken: String
|
|
||||||
let tokenType: String
|
|
||||||
let expiresIn: Int
|
|
||||||
let user: APIUser
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RefreshResponseData: Decodable {
|
|
||||||
let accessToken: String
|
|
||||||
let refreshToken: String
|
|
||||||
let tokenType: String
|
|
||||||
let expiresIn: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MeResponseData: Decodable {
|
|
||||||
let user: APIUser
|
|
||||||
}
|
|
||||||
|
|
||||||
struct APIUser: Decodable {
|
|
||||||
let id: Int
|
let id: Int
|
||||||
let username: String
|
let username: String
|
||||||
let email: String
|
let email: String
|
||||||
let role: String
|
let role: String
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
var displayName: String { username }
|
var displayName: String { username }
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
username = try c.decode(String.self, forKey: .username)
|
||||||
|
email = try c.decode(String.self, forKey: .email)
|
||||||
|
role = try c.decode(String.self, forKey: .role)
|
||||||
|
createdAt = try? c.decode(String.self, forKey: .createdAt)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, username, email, role, createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginResponseData: Decodable, Sendable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
let tokenType: String
|
||||||
|
let expiresIn: Int
|
||||||
|
let user: APIUser
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
accessToken = try c.decode(String.self, forKey: .accessToken)
|
||||||
|
refreshToken = try c.decode(String.self, forKey: .refreshToken)
|
||||||
|
tokenType = try c.decode(String.self, forKey: .tokenType)
|
||||||
|
expiresIn = try c.decode(Int.self, forKey: .expiresIn)
|
||||||
|
user = try c.decode(APIUser.self, forKey: .user)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken, refreshToken, tokenType, expiresIn, user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RefreshResponseData: Decodable, Sendable {
|
||||||
|
let accessToken: String
|
||||||
|
let refreshToken: String
|
||||||
|
let tokenType: String
|
||||||
|
let expiresIn: Int
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
accessToken = try c.decode(String.self, forKey: .accessToken)
|
||||||
|
refreshToken = try c.decode(String.self, forKey: .refreshToken)
|
||||||
|
tokenType = try c.decode(String.self, forKey: .tokenType)
|
||||||
|
expiresIn = try c.decode(Int.self, forKey: .expiresIn)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken, refreshToken, tokenType, expiresIn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MeResponseData: Decodable, Sendable {
|
||||||
|
let user: APIUser
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
user = try c.decode(APIUser.self, forKey: .user)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case user }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Facilities ────────────────────────────────────────────────────────────────
|
// ── Facilities ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
struct FacilitiesResponseData: Decodable {
|
struct APIFacility: Decodable, Identifiable, Sendable {
|
||||||
let facilities: [APIFacility]
|
|
||||||
let count: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AreasResponseData: Decodable {
|
|
||||||
let facilityId: Int
|
|
||||||
let areas: [APIArea]
|
|
||||||
let count: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
struct APIFacility: Decodable, Identifiable {
|
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
let address: String
|
let address: String
|
||||||
@@ -65,51 +148,130 @@ struct APIFacility: Decodable, Identifiable {
|
|||||||
let projectId: Int?
|
let projectId: Int?
|
||||||
let projectName: String?
|
let projectName: String?
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
address = try c.decode(String.self, forKey: .address)
|
||||||
|
contactPerson = try c.decode(String.self, forKey: .contactPerson)
|
||||||
|
contactPhone = try c.decode(String.self, forKey: .contactPhone)
|
||||||
|
projectId = try? c.decode(Int.self, forKey: .projectId)
|
||||||
|
projectName = try? c.decode(String.self, forKey: .projectName)
|
||||||
|
isActive = try c.decode(Bool.self, forKey: .isActive)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, address, contactPerson, contactPhone
|
||||||
|
case projectId, projectName, isActive
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct APIArea: Decodable, Identifiable {
|
struct FacilitiesResponseData: Decodable, Sendable {
|
||||||
|
let facilities: [APIFacility]
|
||||||
|
let count: Int
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
facilities = try c.decode([APIFacility].self, forKey: .facilities)
|
||||||
|
count = try c.decode(Int.self, forKey: .count)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case facilities, count }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APIArea: Decodable, Identifiable, Sendable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let facilityId: Int
|
let facilityId: Int
|
||||||
let name: String
|
let name: String
|
||||||
let areaType: String
|
let areaType: String
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
facilityId = try c.decode(Int.self, forKey: .facilityId)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
areaType = try c.decode(String.self, forKey: .areaType)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case id, facilityId, name, areaType }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AreasResponseData: Decodable, Sendable {
|
||||||
|
let facilityId: Int
|
||||||
|
let areas: [APIArea]
|
||||||
|
let count: Int
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
facilityId = try c.decode(Int.self, forKey: .facilityId)
|
||||||
|
areas = try c.decode([APIArea].self, forKey: .areas)
|
||||||
|
count = try c.decode(Int.self, forKey: .count)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case facilityId, areas, count }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Templates ─────────────────────────────────────────────────────────────────
|
// ── Templates ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
struct TemplatesResponseData: Decodable {
|
struct APITemplateSummary: Decodable, Identifiable, Sendable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let frequency: String
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
description = try c.decode(String.self, forKey: .description)
|
||||||
|
frequency = try c.decode(String.self, forKey: .frequency)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case id, name, description, frequency }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplatesResponseData: Decodable, Sendable {
|
||||||
let templates: [APITemplateSummary]
|
let templates: [APITemplateSummary]
|
||||||
let count: Int
|
let count: Int
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
templates = try c.decode([APITemplateSummary].self, forKey: .templates)
|
||||||
|
count = try c.decode(Int.self, forKey: .count)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case templates, count }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TemplateDetailResponseData: Decodable {
|
struct APITemplate: Decodable, Identifiable, Sendable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let frequency: String
|
||||||
|
// Use [[String: JSONValue]] instead of [[String: AnyDecodable]] — fully Sendable
|
||||||
|
let formSchema: [[String: JSONValue]]
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
description = try c.decode(String.self, forKey: .description)
|
||||||
|
frequency = try c.decode(String.self, forKey: .frequency)
|
||||||
|
formSchema = try c.decode([[String: JSONValue]].self, forKey: .formSchema)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, description, frequency, formSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateDetailResponseData: Decodable, Sendable {
|
||||||
let template: APITemplate
|
let template: APITemplate
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
template = try c.decode(APITemplate.self, forKey: .template)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case template }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct APITemplateSummary: Decodable, Identifiable {
|
// ── Inspections ───────────────────────────────────────────────────────────────
|
||||||
let id: Int
|
|
||||||
let name: String
|
|
||||||
let description: String
|
|
||||||
let frequency: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct APITemplate: Decodable, Identifiable {
|
struct APIInspectionSummary: Decodable, Identifiable, Sendable {
|
||||||
let id: Int
|
|
||||||
let name: String
|
|
||||||
let description: String
|
|
||||||
let frequency: String
|
|
||||||
let formSchema: [[String: AnyDecodable]]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inspections (Phase C — history) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
struct InspectionHistoryResponseData: Decodable {
|
|
||||||
let inspections: [APIInspectionSummary]
|
|
||||||
let total: Int
|
|
||||||
let limit: Int
|
|
||||||
let offset: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
struct APIInspectionSummary: Decodable, Identifiable {
|
|
||||||
let id: Int
|
let id: Int
|
||||||
let templateId: Int
|
let templateId: Int
|
||||||
let templateName: String
|
let templateName: String
|
||||||
@@ -127,27 +289,41 @@ struct APIInspectionSummary: Decodable, Identifiable {
|
|||||||
guard let str = inspectionDate else { return nil }
|
guard let str = inspectionDate else { return nil }
|
||||||
return ISO8601DateFormatter().date(from: str)
|
return ISO8601DateFormatter().date(from: str)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── AnyDecodable helper ───────────────────────────────────────────────────────
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
struct AnyDecodable: Decodable {
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
let value: Any
|
templateId = try c.decode(Int.self, forKey: .templateId)
|
||||||
|
templateName = try c.decode(String.self, forKey: .templateName)
|
||||||
init(_ value: Any) { self.value = value }
|
facilityId = try c.decode(Int.self, forKey: .facilityId)
|
||||||
|
facilityName = try c.decode(String.self, forKey: .facilityName)
|
||||||
init(from decoder: Decoder) throws {
|
areaId = try? c.decode(Int.self, forKey: .areaId)
|
||||||
let container = try decoder.singleValueContainer()
|
areaName = try? c.decode(String.self, forKey: .areaName)
|
||||||
if let bool = try? container.decode(Bool.self) { value = bool; return }
|
status = try c.decode(String.self, forKey: .status)
|
||||||
if let int = try? container.decode(Int.self) { value = int; return }
|
overallScore = try? c.decode(Double.self, forKey: .overallScore)
|
||||||
if let dbl = try? container.decode(Double.self) { value = dbl; return }
|
inspectionDate = try? c.decode(String.self, forKey: .inspectionDate)
|
||||||
if let str = try? container.decode(String.self) { value = str; return }
|
completedAt = try? c.decode(String.self, forKey: .completedAt)
|
||||||
if let arr = try? container.decode([AnyDecodable].self) {
|
mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId)
|
||||||
value = arr.map(\.value); return
|
|
||||||
}
|
}
|
||||||
if let dict = try? container.decode([String: AnyDecodable].self) {
|
private enum CodingKeys: String, CodingKey {
|
||||||
value = dict.mapValues(\.value); return
|
case id, templateId, templateName, facilityId, facilityName
|
||||||
}
|
case areaId, areaName, status, overallScore
|
||||||
value = NSNull()
|
case inspectionDate, completedAt, mobileLocalId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct InspectionHistoryResponseData: Decodable, Sendable {
|
||||||
|
let inspections: [APIInspectionSummary]
|
||||||
|
let total: Int
|
||||||
|
let limit: Int
|
||||||
|
let offset: Int
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
inspections = try c.decode([APIInspectionSummary].self, forKey: .inspections)
|
||||||
|
total = try c.decode(Int.self, forKey: .total)
|
||||||
|
limit = try c.decode(Int.self, forKey: .limit)
|
||||||
|
offset = try c.decode(Int.self, forKey: .offset)
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case inspections, total, limit, offset }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
// Auth/AuthManager.swift
|
// Auth/AuthManager.swift
|
||||||
// ----------------------
|
|
||||||
// Observable class that manages the entire authentication lifecycle:
|
|
||||||
// - Login (POST /api/v1/auth/login)
|
|
||||||
// - Logout (POST /api/v1/auth/logout)
|
|
||||||
// - Session restoration on app launch (GET /api/v1/auth/me)
|
|
||||||
// - Keychain persistence of tokens and user info
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
struct EmptyDecodable: Decodable, Sendable {
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class AuthManager: ObservableObject {
|
class AuthManager: ObservableObject {
|
||||||
|
|
||||||
// ── Published State ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Published var isAuthenticated = false
|
@Published var isAuthenticated = false
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@@ -24,21 +20,14 @@ class AuthManager: ObservableObject {
|
|||||||
@Published var currentUserRole: String = ""
|
@Published var currentUserRole: String = ""
|
||||||
@Published var currentDisplayName: String = ""
|
@Published var currentDisplayName: String = ""
|
||||||
|
|
||||||
// ── Singleton ─────────────────────────────────────────────────────────
|
|
||||||
static let shared = AuthManager()
|
static let shared = AuthManager()
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
// ── App Launch: Restore Session ───────────────────────────────────────
|
|
||||||
|
|
||||||
/// Called once on app launch. Checks if a valid session exists in Keychain.
|
|
||||||
/// If an access token is present, verifies it with /api/v1/auth/me.
|
|
||||||
/// If expired, attempts a refresh. If all fails, shows the login screen.
|
|
||||||
func restoreSession() async {
|
func restoreSession() async {
|
||||||
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else {
|
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else {
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
@@ -47,19 +36,14 @@ class AuthManager: ObservableObject {
|
|||||||
applyUser(response.user)
|
applyUser(response.user)
|
||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
} catch APIError.notAuthenticated {
|
} catch APIError.notAuthenticated {
|
||||||
// Refresh failed (APIClient tried automatically) — need fresh login
|
|
||||||
KeychainHelper.clearAll()
|
KeychainHelper.clearAll()
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
} catch {
|
} catch {
|
||||||
// Network error — still show as authenticated using cached Keychain data
|
|
||||||
// so the inspector can work offline
|
|
||||||
restoreUserFromKeychain()
|
restoreUserFromKeychain()
|
||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Login ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func login(username: String, password: String) async {
|
func login(username: String, password: String) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -70,14 +54,10 @@ class AuthManager: ObservableObject {
|
|||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
body: ["username": username, "password": password]
|
body: ["username": username, "password": password]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store tokens in Keychain
|
|
||||||
KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken)
|
KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken)
|
||||||
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
|
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
|
||||||
|
|
||||||
applyUser(response.user)
|
applyUser(response.user)
|
||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
|
|
||||||
} catch APIError.serverError(let msg) {
|
} catch APIError.serverError(let msg) {
|
||||||
errorMessage = msg
|
errorMessage = msg
|
||||||
} catch APIError.networkError {
|
} catch APIError.networkError {
|
||||||
@@ -87,17 +67,13 @@ class AuthManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Logout ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func logout() async {
|
func logout() async {
|
||||||
// Best-effort server-side logout (revokes refresh token)
|
|
||||||
if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) {
|
if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) {
|
||||||
_ = try? await APIClient.shared.post(
|
_ = try? await APIClient.shared.post(
|
||||||
"/api/v1/auth/logout",
|
"/api/v1/auth/logout",
|
||||||
body: ["refresh_token": refreshToken]
|
body: ["refresh_token": refreshToken]
|
||||||
) as EmptyDecodable
|
) as EmptyDecodable
|
||||||
}
|
}
|
||||||
|
|
||||||
KeychainHelper.clearAll()
|
KeychainHelper.clearAll()
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
currentUserId = 0
|
currentUserId = 0
|
||||||
@@ -106,14 +82,11 @@ class AuthManager: ObservableObject {
|
|||||||
currentDisplayName = ""
|
currentDisplayName = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private func applyUser(_ user: APIUser) {
|
private func applyUser(_ user: APIUser) {
|
||||||
currentUserId = user.id
|
currentUserId = user.id
|
||||||
currentUsername = user.username
|
currentUsername = user.username
|
||||||
currentUserRole = user.role
|
currentUserRole = user.role
|
||||||
currentDisplayName = user.displayName
|
currentDisplayName = user.displayName
|
||||||
|
|
||||||
KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId)
|
KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId)
|
||||||
KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole)
|
KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole)
|
||||||
KeychainHelper.set(user.username, forKey: Constants.Keychain.username)
|
KeychainHelper.set(user.username, forKey: Constants.Keychain.username)
|
||||||
@@ -127,6 +100,3 @@ class AuthManager: ObservableObject {
|
|||||||
currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? ""
|
currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used for logout response decoding (empty data field)
|
|
||||||
struct EmptyDecodable: Decodable {}
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
// Thin wrapper around iOS Security framework for storing sensitive data
|
// Thin wrapper around iOS Security framework for storing sensitive data
|
||||||
// (tokens) in the device Keychain.
|
// (tokens) in the device Keychain.
|
||||||
//
|
//
|
||||||
// The Keychain persists across app reinstalls (on the same device) and
|
// All methods are marked nonisolated so they can be called from any
|
||||||
// is encrypted by the OS. Never store tokens in UserDefaults.
|
// actor context (including APIClient which is an actor) without
|
||||||
|
// Swift 6 actor-isolation warnings.
|
||||||
|
// Security framework calls are internally thread-safe.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
@@ -15,10 +17,9 @@ enum KeychainHelper {
|
|||||||
// ── Write ─────────────────────────────────────────────────────────────
|
// ── Write ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func set(_ value: String, forKey key: String) -> Bool {
|
nonisolated static func set(_ value: String, forKey key: String) -> Bool {
|
||||||
guard let data = value.data(using: .utf8) else { return false }
|
guard let data = value.data(using: .utf8) else { return false }
|
||||||
|
|
||||||
// Delete any existing entry first to avoid duplicate-item errors
|
|
||||||
let deleteQuery: [String: Any] = [
|
let deleteQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
@@ -37,7 +38,7 @@ enum KeychainHelper {
|
|||||||
|
|
||||||
// ── Read ──────────────────────────────────────────────────────────────
|
// ── Read ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static func get(_ key: String) -> String? {
|
nonisolated static func get(_ key: String) -> String? {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
@@ -56,7 +57,7 @@ enum KeychainHelper {
|
|||||||
// ── Delete ────────────────────────────────────────────────────────────
|
// ── Delete ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func delete(_ key: String) -> Bool {
|
nonisolated static func delete(_ key: String) -> Bool {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
@@ -67,7 +68,7 @@ enum KeychainHelper {
|
|||||||
|
|
||||||
// ── Delete All JQC Keys ───────────────────────────────────────────────
|
// ── Delete All JQC Keys ───────────────────────────────────────────────
|
||||||
|
|
||||||
static func clearAll() {
|
nonisolated static func clearAll() {
|
||||||
let keys = [
|
let keys = [
|
||||||
Constants.Keychain.accessToken,
|
Constants.Keychain.accessToken,
|
||||||
Constants.Keychain.refreshToken,
|
Constants.Keychain.refreshToken,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Models/LocalTemplate.swift
|
// Models/LocalTemplate.swift
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// SwiftData model for locally cached inspection templates.
|
// SwiftData model for locally cached inspection templates.
|
||||||
// The form_schema is stored as a raw JSON string and decoded on demand.
|
// form_schema stored as raw JSON string, decoded on demand via formSchema property.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
@@ -12,7 +12,6 @@ final class LocalTemplate {
|
|||||||
var name: String
|
var name: String
|
||||||
var templateDescription: String
|
var templateDescription: String
|
||||||
var frequency: String
|
var frequency: String
|
||||||
/// Raw JSON string of the form_schema array — decoded on demand
|
|
||||||
var formSchemaJSON: String
|
var formSchemaJSON: String
|
||||||
var lastSyncedAt: Date
|
var lastSyncedAt: Date
|
||||||
|
|
||||||
@@ -33,18 +32,18 @@ final class LocalTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateSchema(from template: APITemplate) {
|
func updateSchema(from template: APITemplate) {
|
||||||
// Re-serialize the form_schema to JSON for local storage
|
// Convert [[String: JSONValue]] → JSON string via anyValue bridge
|
||||||
if let data = try? JSONSerialization.data(withJSONObject: template.formSchema.map({ dict in
|
let raw = template.formSchema.map { dict in
|
||||||
dict.mapValues { $0.value }
|
dict.mapValues { $0.anyValue }
|
||||||
})),
|
}
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: raw),
|
||||||
let str = String(data: data, encoding: .utf8) {
|
let str = String(data: data, encoding: .utf8) {
|
||||||
self.formSchemaJSON = str
|
self.formSchemaJSON = str
|
||||||
}
|
}
|
||||||
self.lastSyncedAt = Date()
|
self.lastSyncedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode the stored JSON string back into an array of field dictionaries.
|
/// Decode stored JSON back into [[String: Any]] for the form renderer
|
||||||
/// Returns an empty array if the JSON is invalid.
|
|
||||||
var formSchema: [[String: Any]] {
|
var formSchema: [[String: Any]] {
|
||||||
guard let data = formSchemaJSON.data(using: .utf8),
|
guard let data = formSchemaJSON.data(using: .utf8),
|
||||||
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
// Utils/Constants.swift
|
// Utils/Constants.swift
|
||||||
// ---------------------
|
// ---------------------
|
||||||
// Central place for app-wide constants.
|
// Central place for app-wide constants.
|
||||||
// IMPORTANT: Replace the baseURL with your actual server URL.
|
// IMPORTANT: Replace baseURL with your actual server URL.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Constants {
|
// Explicitly not @MainActor — these constants must be readable from
|
||||||
// ── Server ────────────────────────────────────────────────────────────
|
// any actor context including APIClient and KeychainHelper.
|
||||||
/// Your JanitorialQC server base URL. No trailing slash.
|
nonisolated enum Constants {
|
||||||
/// Example: "https://your-domain.com"
|
|
||||||
static let baseURL = "https://jqc1.ltservicesinc.com"
|
static let baseURL = "https://jqc1.ltservicesinc.com"
|
||||||
|
|
||||||
// ── Keychain keys ─────────────────────────────────────────────────────
|
nonisolated enum Keychain {
|
||||||
enum Keychain {
|
static let accessToken = "com.jqc.accessToken"
|
||||||
static let accessToken = "com.JanitorialQC.accessToken"
|
static let refreshToken = "com.jqc.refreshToken"
|
||||||
static let refreshToken = "com.JanitorialQC.refreshToken"
|
static let userId = "com.jqc.userId"
|
||||||
static let userId = "com.JanitorialQC.userId"
|
static let userRole = "com.jqc.userRole"
|
||||||
static let userRole = "com.JanitorialQC.userRole"
|
static let username = "com.jqc.username"
|
||||||
static let username = "com.JanitorialQC.username"
|
static let displayName = "com.jqc.displayName"
|
||||||
static let displayName = "com.JanitorialQC.displayName"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync ──────────────────────────────────────────────────────────────
|
|
||||||
/// How many minutes before the access token expires to trigger a refresh
|
|
||||||
static let tokenRefreshBufferMinutes: Double = 5
|
static let tokenRefreshBufferMinutes: Double = 5
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user