05/02 Phase C 2

This commit is contained in:
Nguyen Ngo
2026-05-03 09:29:12 -04:00
parent 59e698b5f7
commit 51d2923e6d
6 changed files with 439 additions and 309 deletions
+131 -144
View File
@@ -1,12 +1,9 @@
// API/APIClient.swift
// -------------------
// Central HTTP client for all JQC API calls.
// Phase C adds: fetchInspectionHistory()
import Foundation
import Combine
enum APIError: Error, LocalizedError {
enum APIError: Error, LocalizedError, Sendable {
case invalidURL
case notAuthenticated
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 {
static let shared = APIClient()
private let baseURL: String
private let session: URLSession
private let decoder: JSONDecoder
private init() {
self.baseURL = Constants.baseURL
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: config)
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
}
// Generic JSON Request
func request<T: Decodable>(
func request<T: Decodable & Sendable>(
_ endpoint: String,
method: String = "GET",
body: [String: Any]? = nil,
retrying: Bool = false
) 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 {
throw APIError.invalidURL
}
let (data, response) = try await performRequest(req)
var req = URLRequest(url: url)
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 {
if shouldRefresh(response, retrying: retrying) {
let refreshed = await refreshAccessToken()
if refreshed {
return try await request(endpoint, method: method, body: body, retrying: true)
} else {
}
throw APIError.notAuthenticated
}
return try decode(data)
}
let decoder = JSONDecoder()
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 {
func post<T: Decodable & Sendable>(_ endpoint: String, body: [String: Any]) async throws -> T {
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(
limit: Int = 50,
offset: Int = 0,
facilityId: Int? = nil
) async throws -> InspectionHistoryResponseData {
var endpoint = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
if let fid = facilityId {
endpoint += "&facility_id=\(fid)"
}
return try await request(endpoint)
var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
if let fid = facilityId { ep += "&facility_id=\(fid)" }
return try await request(ep)
}
// Photo Upload (multipart/form-data)
// Photo Upload
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else {
throw APIError.invalidURL
}
let url = try buildURL("/api/v1/photos/upload")
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)"
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 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)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
var body = Data()
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\n\(entityType)\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("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
injectToken(&req)
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 {
throw APIError.networkError("Invalid response")
}
if http.statusCode == 401 {
if shouldRefresh(response, retrying: false) {
let refreshed = await refreshAccessToken()
if refreshed {
return try await uploadPhoto(localPath: localPath, entityType: entityType)
} else {
if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType) }
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")
}
@@ -190,25 +152,18 @@ actor APIClient {
"status": "completed",
"form_data": inspection.formData,
"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 !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
let formatter = ISO8601DateFormatter()
body["inspection_date"] = formatter.string(from: inspection.inspectionDate)
if let completedAt = inspection.completedAt {
body["completed_at"] = formatter.string(from: completedAt)
}
let fmt = ISO8601DateFormatter()
body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
struct InspectionResponseData: Decodable {
let inspectionId: Int
let duplicate: Bool
}
let result: InspectionResponseData = try await post("/api/v1/inspections", body: body)
return result.inspectionId
struct R: Decodable, Sendable { let inspectionId: Int; let duplicate: Bool }
let r: R = try await post("/api/v1/inspections", body: body)
return r.inspectionId
}
// Submit Issue
@@ -220,53 +175,85 @@ actor APIClient {
"description": issue.issueDescription,
"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 {
body["inspection_id"] = inspServerId
}
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
struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
let r: R = try await post("/api/v1/issues", body: body)
return r.issueId
}
// Token Refresh
private func refreshAccessToken() async -> Bool {
guard let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken),
let url = URL(string: baseURL + "/api/v1/auth/refresh") else {
return false
}
guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
let url = URL(string: Constants.baseURL + "/api/v1/auth/refresh")
else { return false }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(
withJSONObject: ["refresh_token": refreshToken]
)
req.httpBody = try? JSONSerialization.data(withJSONObject: ["refresh_token": token])
guard let (data, response) = try? await session.data(for: req),
let http = response as? HTTPURLResponse,
http.statusCode == 200
let http = response as? HTTPURLResponse, http.statusCode == 200
else { return false }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let envelope = try? decoder.decode(APIResponse<RefreshResponseData>.self, from: data),
envelope.ok,
let refreshData = envelope.data
guard let env = try? decoder.decode(_RefreshEnvelope.self, from: data),
env.ok,
let tokens = env.data
else { return false }
KeychainHelper.set(refreshData.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(refreshData.refreshToken, forKey: Constants.Keychain.refreshToken)
KeychainHelper.set(tokens.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(tokens.refreshToken, forKey: Constants.Keychain.refreshToken)
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)
}
}
}
+258 -82
View File
@@ -1,62 +1,145 @@
// API/APIModels.swift
// -------------------
// Codable structs that map to the JSON responses from the JQC Flask API.
// All API responses use the envelope: { "ok": bool, "data": {...}, "error": string? }
// All types in this file are pure value types with no actor isolation.
// AnyDecodable uses a JSONValue enum (not `Any`) so it is fully Sendable
// without @unchecked and causes no actor-isolation warnings.
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
struct APIResponse<T: Decodable>: Decodable {
struct APIResponse<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 }
}
// Auth
struct LoginResponseData: Decodable {
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 {
struct APIUser: Decodable, Sendable {
let id: Int
let username: String
let email: String
let role: String
let createdAt: String?
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
struct FacilitiesResponseData: Decodable {
let facilities: [APIFacility]
let count: Int
}
struct AreasResponseData: Decodable {
let facilityId: Int
let areas: [APIArea]
let count: Int
}
struct APIFacility: Decodable, Identifiable {
struct APIFacility: Decodable, Identifiable, Sendable {
let id: Int
let name: String
let address: String
@@ -65,51 +148,130 @@ struct APIFacility: Decodable, Identifiable {
let projectId: Int?
let projectName: String?
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 facilityId: Int
let name: 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
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 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
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 {
let id: Int
let name: String
let description: String
let frequency: String
}
// Inspections
struct APITemplate: Decodable, Identifiable {
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 {
struct APIInspectionSummary: Decodable, Identifiable, Sendable {
let id: Int
let templateId: Int
let templateName: String
@@ -127,27 +289,41 @@ struct APIInspectionSummary: Decodable, Identifiable {
guard let str = inspectionDate else { return nil }
return ISO8601DateFormatter().date(from: str)
}
}
// AnyDecodable helper
struct AnyDecodable: Decodable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let bool = try? container.decode(Bool.self) { value = bool; return }
if let int = try? container.decode(Int.self) { value = int; return }
if let dbl = try? container.decode(Double.self) { value = dbl; return }
if let str = try? container.decode(String.self) { value = str; return }
if let arr = try? container.decode([AnyDecodable].self) {
value = arr.map(\.value); return
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
templateId = try c.decode(Int.self, forKey: .templateId)
templateName = try c.decode(String.self, forKey: .templateName)
facilityId = try c.decode(Int.self, forKey: .facilityId)
facilityName = try c.decode(String.self, forKey: .facilityName)
areaId = try? c.decode(Int.self, forKey: .areaId)
areaName = try? c.decode(String.self, forKey: .areaName)
status = try c.decode(String.self, forKey: .status)
overallScore = try? c.decode(Double.self, forKey: .overallScore)
inspectionDate = try? c.decode(String.self, forKey: .inspectionDate)
completedAt = try? c.decode(String.self, forKey: .completedAt)
mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId)
}
if let dict = try? container.decode([String: AnyDecodable].self) {
value = dict.mapValues(\.value); return
}
value = NSNull()
private enum CodingKeys: String, CodingKey {
case id, templateId, templateName, facilityId, facilityName
case areaId, areaName, status, overallScore
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 }
}
+4 -34
View File
@@ -1,20 +1,16 @@
// 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 SwiftUI
import Combine
struct EmptyDecodable: Decodable, Sendable {
nonisolated init(from decoder: any Decoder) throws {}
}
@MainActor
class AuthManager: ObservableObject {
// Published State
@Published var isAuthenticated = false
@Published var isLoading = false
@Published var errorMessage: String?
@@ -24,21 +20,14 @@ class AuthManager: ObservableObject {
@Published var currentUserRole: String = ""
@Published var currentDisplayName: String = ""
// Singleton
static let shared = AuthManager()
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 {
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else {
isAuthenticated = false
return
}
isLoading = true
defer { isLoading = false }
@@ -47,19 +36,14 @@ class AuthManager: ObservableObject {
applyUser(response.user)
isAuthenticated = true
} catch APIError.notAuthenticated {
// Refresh failed (APIClient tried automatically) need fresh login
KeychainHelper.clearAll()
isAuthenticated = false
} catch {
// Network error still show as authenticated using cached Keychain data
// so the inspector can work offline
restoreUserFromKeychain()
isAuthenticated = true
}
}
// Login
func login(username: String, password: String) async {
isLoading = true
errorMessage = nil
@@ -70,14 +54,10 @@ class AuthManager: ObservableObject {
"/api/v1/auth/login",
body: ["username": username, "password": password]
)
// Store tokens in Keychain
KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
applyUser(response.user)
isAuthenticated = true
} catch APIError.serverError(let msg) {
errorMessage = msg
} catch APIError.networkError {
@@ -87,17 +67,13 @@ class AuthManager: ObservableObject {
}
}
// Logout
func logout() async {
// Best-effort server-side logout (revokes refresh token)
if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) {
_ = try? await APIClient.shared.post(
"/api/v1/auth/logout",
body: ["refresh_token": refreshToken]
) as EmptyDecodable
}
KeychainHelper.clearAll()
isAuthenticated = false
currentUserId = 0
@@ -106,14 +82,11 @@ class AuthManager: ObservableObject {
currentDisplayName = ""
}
// Helpers
private func applyUser(_ user: APIUser) {
currentUserId = user.id
currentUsername = user.username
currentUserRole = user.role
currentDisplayName = user.displayName
KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId)
KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole)
KeychainHelper.set(user.username, forKey: Constants.Keychain.username)
@@ -127,6 +100,3 @@ class AuthManager: ObservableObject {
currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? ""
}
}
// Used for logout response decoding (empty data field)
struct EmptyDecodable: Decodable {}
+8 -7
View File
@@ -3,8 +3,10 @@
// Thin wrapper around iOS Security framework for storing sensitive data
// (tokens) in the device Keychain.
//
// The Keychain persists across app reinstalls (on the same device) and
// is encrypted by the OS. Never store tokens in UserDefaults.
// All methods are marked nonisolated so they can be called from any
// actor context (including APIClient which is an actor) without
// Swift 6 actor-isolation warnings.
// Security framework calls are internally thread-safe.
import Foundation
import Security
@@ -15,10 +17,9 @@ enum KeychainHelper {
// Write
@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 }
// Delete any existing entry first to avoid duplicate-item errors
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
@@ -37,7 +38,7 @@ enum KeychainHelper {
// Read
static func get(_ key: String) -> String? {
nonisolated static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
@@ -56,7 +57,7 @@ enum KeychainHelper {
// Delete
@discardableResult
static func delete(_ key: String) -> Bool {
nonisolated static func delete(_ key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
@@ -67,7 +68,7 @@ enum KeychainHelper {
// Delete All JQC Keys
static func clearAll() {
nonisolated static func clearAll() {
let keys = [
Constants.Keychain.accessToken,
Constants.Keychain.refreshToken,
+7 -8
View File
@@ -1,7 +1,7 @@
// Models/LocalTemplate.swift
// --------------------------
// 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 SwiftData
@@ -12,7 +12,6 @@ final class LocalTemplate {
var name: String
var templateDescription: String
var frequency: String
/// Raw JSON string of the form_schema array decoded on demand
var formSchemaJSON: String
var lastSyncedAt: Date
@@ -33,18 +32,18 @@ final class LocalTemplate {
}
func updateSchema(from template: APITemplate) {
// Re-serialize the form_schema to JSON for local storage
if let data = try? JSONSerialization.data(withJSONObject: template.formSchema.map({ dict in
dict.mapValues { $0.value }
})),
// Convert [[String: JSONValue]] JSON string via anyValue bridge
let raw = template.formSchema.map { dict in
dict.mapValues { $0.anyValue }
}
if let data = try? JSONSerialization.data(withJSONObject: raw),
let str = String(data: data, encoding: .utf8) {
self.formSchemaJSON = str
}
self.lastSyncedAt = Date()
}
/// Decode the stored JSON string back into an array of field dictionaries.
/// Returns an empty array if the JSON is invalid.
/// Decode stored JSON back into [[String: Any]] for the form renderer
var formSchema: [[String: Any]] {
guard let data = formSchemaJSON.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
+12 -15
View File
@@ -1,27 +1,24 @@
// Utils/Constants.swift
// ---------------------
// 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
enum Constants {
// Server
/// Your JanitorialQC server base URL. No trailing slash.
/// Example: "https://your-domain.com"
// Explicitly not @MainActor these constants must be readable from
// any actor context including APIClient and KeychainHelper.
nonisolated enum Constants {
static let baseURL = "https://jqc1.ltservicesinc.com"
// Keychain keys
enum Keychain {
static let accessToken = "com.JanitorialQC.accessToken"
static let refreshToken = "com.JanitorialQC.refreshToken"
static let userId = "com.JanitorialQC.userId"
static let userRole = "com.JanitorialQC.userRole"
static let username = "com.JanitorialQC.username"
static let displayName = "com.JanitorialQC.displayName"
nonisolated enum Keychain {
static let accessToken = "com.jqc.accessToken"
static let refreshToken = "com.jqc.refreshToken"
static let userId = "com.jqc.userId"
static let userRole = "com.jqc.userRole"
static let username = "com.jqc.username"
static let displayName = "com.jqc.displayName"
}
// Sync
/// How many minutes before the access token expires to trigger a refresh
static let tokenRefreshBufferMinutes: Double = 5
}