05/02 Phase B
This commit is contained in:
@@ -400,11 +400,14 @@
|
|||||||
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -413,6 +416,10 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -432,11 +439,14 @@
|
|||||||
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -445,6 +455,10 @@
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// API/APIClient.swift
|
||||||
|
// -------------------
|
||||||
|
// Central HTTP client for all JQC API calls.
|
||||||
|
// Phase B adds: uploadPhoto(), submitInspection(), submitIssue()
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
enum APIError: Error, LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case notAuthenticated
|
||||||
|
case serverError(String)
|
||||||
|
case decodingError(String)
|
||||||
|
case networkError(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: return "Invalid URL."
|
||||||
|
case .notAuthenticated: return "Session expired. Please log in again."
|
||||||
|
case .serverError(let msg): return msg
|
||||||
|
case .decodingError(let msg): return "Data error: \(msg)"
|
||||||
|
case .networkError(let msg): return "Network error: \(msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor APIClient {
|
||||||
|
static let shared = APIClient()
|
||||||
|
|
||||||
|
private let baseURL: String
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.baseURL = Constants.baseURL
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.timeoutIntervalForRequest = 30
|
||||||
|
self.session = URLSession(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic JSON Request ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func request<T: Decodable>(
|
||||||
|
_ endpoint: String,
|
||||||
|
method: String = "GET",
|
||||||
|
body: [String: Any]? = nil,
|
||||||
|
retrying: Bool = false
|
||||||
|
) async throws -> T {
|
||||||
|
|
||||||
|
guard let url = URL(string: baseURL + endpoint) else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let refreshed = await refreshAccessToken()
|
||||||
|
if refreshed {
|
||||||
|
return try await request(endpoint, method: method, body: body, retrying: true)
|
||||||
|
} else {
|
||||||
|
throw APIError.notAuthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return try await request(endpoint, method: "POST", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Upload (multipart/form-data) ────────────────────────────────
|
||||||
|
|
||||||
|
/// Upload a local photo file to the server.
|
||||||
|
/// Returns the server_path string on success.
|
||||||
|
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
|
||||||
|
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
||||||
|
throw APIError.networkError("Could not read photo file: \(localPath)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
var body = Data()
|
||||||
|
|
||||||
|
// -- entity_type field
|
||||||
|
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)!)
|
||||||
|
|
||||||
|
// -- file field
|
||||||
|
let filename = URL(fileURLWithPath: localPath).lastPathComponent
|
||||||
|
let ext = (filename as NSString).pathExtension.lowercased()
|
||||||
|
let mimeType = 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)!)
|
||||||
|
body.append(imageData)
|
||||||
|
body.append("\r\n".data(using: .utf8)!)
|
||||||
|
body.append("--\(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.httpBody = 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 {
|
||||||
|
let refreshed = await refreshAccessToken()
|
||||||
|
if refreshed {
|
||||||
|
return try await uploadPhoto(localPath: localPath, entityType: entityType)
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
throw APIError.serverError("Photo upload failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit Inspection ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Submit a completed LocalInspection to the server.
|
||||||
|
/// Returns the server-assigned inspection ID.
|
||||||
|
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
|
||||||
|
var body: [String: Any] = [
|
||||||
|
"template_id": inspection.templateServerId,
|
||||||
|
"facility_id": inspection.facilityServerId,
|
||||||
|
"status": "completed",
|
||||||
|
"form_data": inspection.formData,
|
||||||
|
"mobile_local_id": inspection.localId,
|
||||||
|
"overall_score": inspection.overallScore as Any,
|
||||||
|
]
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InspectionResponseData: Decodable {
|
||||||
|
let inspectionId: Int
|
||||||
|
let duplicate: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: InspectionResponseData = try await post("/api/v1/inspections", body: body)
|
||||||
|
return result.inspectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit Issue ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Submit a LocalIssue to the server.
|
||||||
|
/// Returns the server-assigned issue ID.
|
||||||
|
func submitIssue(_ issue: LocalIssue) async throws -> Int {
|
||||||
|
var body: [String: Any] = [
|
||||||
|
"area_id": issue.areaServerId,
|
||||||
|
"severity": issue.severity,
|
||||||
|
"description": issue.issueDescription,
|
||||||
|
"mobile_local_id": issue.localId,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Link to server inspection if already synced
|
||||||
|
// (inspection must be synced before its issues)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = try? JSONSerialization.data(
|
||||||
|
withJSONObject: ["refresh_token": refreshToken]
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let (data, response) = try? await session.data(for: req),
|
||||||
|
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
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
KeychainHelper.set(refreshData.accessToken, forKey: Constants.Keychain.accessToken)
|
||||||
|
KeychainHelper.set(refreshData.refreshToken, forKey: Constants.Keychain.refreshToken)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// 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? }
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// ── API Envelope ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct APIResponse<T: Decodable>: Decodable {
|
||||||
|
let ok: Bool
|
||||||
|
let data: T?
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
|
let id: Int
|
||||||
|
let username: String
|
||||||
|
let email: String
|
||||||
|
let role: String
|
||||||
|
let createdAt: String?
|
||||||
|
|
||||||
|
// Computed display name: full name if present, otherwise username.
|
||||||
|
// The server's /auth/me endpoint returns username; display_name would
|
||||||
|
// require a separate field. For Phase A we use username as the display name
|
||||||
|
// and can extend later.
|
||||||
|
var displayName: String { username }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let address: String
|
||||||
|
let contactPerson: String
|
||||||
|
let contactPhone: String
|
||||||
|
let projectId: Int?
|
||||||
|
let projectName: String?
|
||||||
|
let isActive: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APIArea: Decodable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let facilityId: Int
|
||||||
|
let name: String
|
||||||
|
let areaType: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Templates ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct TemplatesResponseData: Decodable {
|
||||||
|
let templates: [APITemplateSummary]
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateDetailResponseData: Decodable {
|
||||||
|
let template: APITemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APITemplateSummary: Decodable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let frequency: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APITemplate: Decodable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let frequency: String
|
||||||
|
let formSchema: [[String: AnyDecodable]] // Dynamic JSON fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AnyDecodable helper ───────────────────────────────────────────────────────
|
||||||
|
// Allows decoding JSON values of unknown type (String, Int, Bool, Array, Dict)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if let dict = try? container.decode([String: AnyDecodable].self) {
|
||||||
|
value = dict.mapValues(\.value); return
|
||||||
|
}
|
||||||
|
value = NSNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class AuthManager: ObservableObject {
|
||||||
|
|
||||||
|
// ── Published State ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Published var isAuthenticated = false
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
@Published var currentUserId: Int = 0
|
||||||
|
@Published var currentUsername: String = ""
|
||||||
|
@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 }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response: MeResponseData = try await APIClient.shared.request("/api/v1/auth/me")
|
||||||
|
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
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response: LoginResponseData = try await APIClient.shared.post(
|
||||||
|
"/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 {
|
||||||
|
errorMessage = "Cannot reach the server. Please check your connection."
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
currentUsername = ""
|
||||||
|
currentUserRole = ""
|
||||||
|
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)
|
||||||
|
KeychainHelper.set(user.displayName, forKey: Constants.Keychain.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreUserFromKeychain() {
|
||||||
|
currentUserId = Int(KeychainHelper.get(Constants.Keychain.userId) ?? "0") ?? 0
|
||||||
|
currentUserRole = KeychainHelper.get(Constants.Keychain.userRole) ?? ""
|
||||||
|
currentUsername = KeychainHelper.get(Constants.Keychain.username) ?? ""
|
||||||
|
currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for logout response decoding (empty data field)
|
||||||
|
struct EmptyDecodable: Decodable {}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Auth/KeychainHelper.swift
|
||||||
|
// -------------------------
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
enum KeychainHelper {
|
||||||
|
|
||||||
|
// ── Write ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
|
||||||
|
let addQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||||
|
]
|
||||||
|
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||||
|
return status == errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static func get(_ key: String) -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
guard status == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let string = String(data: data, encoding: .utf8)
|
||||||
|
else { return nil }
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func delete(_ key: String) -> Bool {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
]
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
return status == errSecSuccess || status == errSecItemNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete All JQC Keys ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
static func clearAll() {
|
||||||
|
let keys = [
|
||||||
|
Constants.Keychain.accessToken,
|
||||||
|
Constants.Keychain.refreshToken,
|
||||||
|
Constants.Keychain.userId,
|
||||||
|
Constants.Keychain.userRole,
|
||||||
|
Constants.Keychain.username,
|
||||||
|
Constants.Keychain.displayName,
|
||||||
|
]
|
||||||
|
keys.forEach { delete($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +1,28 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
// ContentView.swift
|
||||||
// JanitorialQC
|
// -----------------
|
||||||
//
|
// Root view. Shows LoginView or DashboardView based on auth state.
|
||||||
// Created by Nguyen Ngo on 5/2/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@EnvironmentObject private var auth: AuthManager
|
||||||
@Query private var items: [Item]
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
Group {
|
||||||
List {
|
if auth.isLoading && !auth.isAuthenticated {
|
||||||
ForEach(items) { item in
|
// Splash / loading state on app launch
|
||||||
NavigationLink {
|
VStack(spacing: 16) {
|
||||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
} label: {
|
.font(.system(size: 64))
|
||||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
.foregroundStyle(.blue)
|
||||||
}
|
ProgressView("Loading…")
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteItems)
|
} else if auth.isAuthenticated {
|
||||||
}
|
DashboardView()
|
||||||
.toolbar {
|
} else {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
LoginView()
|
||||||
EditButton()
|
|
||||||
}
|
|
||||||
ToolbarItem {
|
|
||||||
Button(action: addItem) {
|
|
||||||
Label("Add Item", systemImage: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} detail: {
|
|
||||||
Text("Select an item")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addItem() {
|
|
||||||
withAnimation {
|
|
||||||
let newItem = Item(timestamp: Date())
|
|
||||||
modelContext.insert(newItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteItems(offsets: IndexSet) {
|
|
||||||
withAnimation {
|
|
||||||
for index in offsets {
|
|
||||||
modelContext.delete(items[index])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: auth.isAuthenticated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
.modelContainer(for: Item.self, inMemory: true)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,32 +1,47 @@
|
|||||||
//
|
// JanitorialQC.swift
|
||||||
// JanitorialQCApp.swift
|
// ------------
|
||||||
// JanitorialQC
|
// App entry point. Phase B adds LocalInspection, LocalIssue,
|
||||||
//
|
// PendingPhoto, and SyncQueueEntry to the SwiftData model container.
|
||||||
// Created by Nguyen Ngo on 5/2/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct JanitorialQCApp: App {
|
struct JanitorialQC: App {
|
||||||
var sharedModelContainer: ModelContainer = {
|
|
||||||
let schema = Schema([
|
|
||||||
Item.self,
|
|
||||||
])
|
|
||||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
|
||||||
|
|
||||||
do {
|
@StateObject private var auth = AuthManager.shared
|
||||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
@StateObject private var sync = SyncManager.shared
|
||||||
} catch {
|
|
||||||
fatalError("Could not create ModelContainer: \(error)")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
.environmentObject(auth)
|
||||||
|
.environmentObject(sync)
|
||||||
|
}
|
||||||
|
.modelContainer(for: [
|
||||||
|
// Phase A
|
||||||
|
LocalFacility.self,
|
||||||
|
LocalArea.self,
|
||||||
|
LocalTemplate.self,
|
||||||
|
// Phase B
|
||||||
|
LocalInspection.self,
|
||||||
|
LocalIssue.self,
|
||||||
|
PendingPhoto.self,
|
||||||
|
SyncQueueEntry.self,
|
||||||
|
], isUndoEnabled: false) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let container):
|
||||||
|
Task { @MainActor in
|
||||||
|
SyncManager.shared.modelContext = container.mainContext
|
||||||
|
await AuthManager.shared.restoreSession()
|
||||||
|
SyncManager.shared.startMonitoring()
|
||||||
|
if SyncManager.shared.isOnline {
|
||||||
|
await SyncManager.shared.triggerSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
fatalError("SwiftData container failed: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Models/LocalArea.swift
|
||||||
|
// ----------------------
|
||||||
|
// SwiftData model for locally cached facility areas.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class LocalArea {
|
||||||
|
@Attribute(.unique) var serverId: Int
|
||||||
|
var facilityServerId: Int
|
||||||
|
var name: String
|
||||||
|
var areaType: String
|
||||||
|
var lastSyncedAt: Date
|
||||||
|
|
||||||
|
/// Back-reference to parent facility
|
||||||
|
var facility: LocalFacility?
|
||||||
|
|
||||||
|
init(from api: APIArea) {
|
||||||
|
self.serverId = api.id
|
||||||
|
self.facilityServerId = api.facilityId
|
||||||
|
self.name = api.name
|
||||||
|
self.areaType = api.areaType
|
||||||
|
self.lastSyncedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(from api: APIArea) {
|
||||||
|
self.name = api.name
|
||||||
|
self.areaType = api.areaType
|
||||||
|
self.lastSyncedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable type label
|
||||||
|
var areaTypeLabel: String {
|
||||||
|
switch areaType {
|
||||||
|
case "restroom": return "Restroom"
|
||||||
|
case "lobby": return "Lobby"
|
||||||
|
case "hallway": return "Hallway"
|
||||||
|
case "office": return "Office"
|
||||||
|
case "kitchen": return "Kitchen"
|
||||||
|
case "storage": return "Storage"
|
||||||
|
case "floor": return "Floor"
|
||||||
|
case "outdoor": return "Outdoor"
|
||||||
|
default: return "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Models/LocalFacility.swift
|
||||||
|
// --------------------------
|
||||||
|
// SwiftData model for locally cached facilities.
|
||||||
|
// Populated by SyncManager.pullReferenceData() and never written by the inspector.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class LocalFacility {
|
||||||
|
/// The server's primary key — used to match server records to local ones
|
||||||
|
@Attribute(.unique) var serverId: Int
|
||||||
|
var name: String
|
||||||
|
var address: String
|
||||||
|
var contactPerson: String
|
||||||
|
var projectId: Int
|
||||||
|
var projectName: String
|
||||||
|
var isActive: Bool
|
||||||
|
var lastSyncedAt: Date
|
||||||
|
|
||||||
|
/// Areas are stored as a separate model, linked by facilityServerId
|
||||||
|
@Relationship(deleteRule: .cascade) var areas: [LocalArea]
|
||||||
|
|
||||||
|
init(from api: APIFacility) {
|
||||||
|
self.serverId = api.id
|
||||||
|
self.name = api.name
|
||||||
|
self.address = api.address
|
||||||
|
self.contactPerson = api.contactPerson
|
||||||
|
self.projectId = api.projectId ?? 0
|
||||||
|
self.projectName = api.projectName ?? "No Contract"
|
||||||
|
self.isActive = api.isActive
|
||||||
|
self.lastSyncedAt = Date()
|
||||||
|
self.areas = []
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(from api: APIFacility) {
|
||||||
|
self.name = api.name
|
||||||
|
self.address = api.address
|
||||||
|
self.contactPerson = api.contactPerson
|
||||||
|
self.projectId = api.projectId ?? 0
|
||||||
|
self.projectName = api.projectName ?? "No Contract"
|
||||||
|
self.isActive = api.isActive
|
||||||
|
self.lastSyncedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Models/LocalInspection.swift
|
||||||
|
// ----------------------------
|
||||||
|
// SwiftData model for locally stored inspections.
|
||||||
|
// Created immediately when the inspector starts a new inspection.
|
||||||
|
// Written entirely offline; synced to server when connectivity returns.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class LocalInspection {
|
||||||
|
|
||||||
|
// ── Identity ──────────────────────────────────────────────────────────
|
||||||
|
/// UUID generated on device — stable local identity and idempotency key
|
||||||
|
@Attribute(.unique) var localId: String
|
||||||
|
/// Set by server after successful sync; nil until then
|
||||||
|
var serverId: Int?
|
||||||
|
|
||||||
|
// ── Foreign keys (server IDs, from cached reference data) ─────────────
|
||||||
|
var templateServerId: Int
|
||||||
|
var facilityServerId: Int
|
||||||
|
var areaServerId: Int?
|
||||||
|
var inspectorUserId: Int
|
||||||
|
|
||||||
|
// ── Inspection data ────────────────────────────────────────────────────
|
||||||
|
/// "draft" | "completed" | "synced" | "sync_failed"
|
||||||
|
var status: String
|
||||||
|
/// JSON dict of { field_id: value } — same shape as server form_data
|
||||||
|
var formDataJSON: String
|
||||||
|
var inspectorNotes: String
|
||||||
|
var overallScore: Double?
|
||||||
|
var inspectionDate: Date
|
||||||
|
var completedAt: Date?
|
||||||
|
var createdAt: Date
|
||||||
|
var lastModifiedAt: Date
|
||||||
|
|
||||||
|
// ── Sync ──────────────────────────────────────────────────────────────
|
||||||
|
var syncStatus: String // "pending" | "synced" | "failed"
|
||||||
|
var syncErrorMessage: String?
|
||||||
|
var syncRetryCount: Int
|
||||||
|
|
||||||
|
// ── Relationships ──────────────────────────────────────────────────────
|
||||||
|
@Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto]
|
||||||
|
@Relationship(deleteRule: .cascade) var localIssues: [LocalIssue]
|
||||||
|
|
||||||
|
init(
|
||||||
|
templateServerId: Int,
|
||||||
|
facilityServerId: Int,
|
||||||
|
areaServerId: Int?,
|
||||||
|
inspectorUserId: Int
|
||||||
|
) {
|
||||||
|
self.localId = UUID().uuidString
|
||||||
|
self.serverId = nil
|
||||||
|
self.templateServerId = templateServerId
|
||||||
|
self.facilityServerId = facilityServerId
|
||||||
|
self.areaServerId = areaServerId
|
||||||
|
self.inspectorUserId = inspectorUserId
|
||||||
|
self.status = "draft"
|
||||||
|
self.formDataJSON = "{}"
|
||||||
|
self.inspectorNotes = ""
|
||||||
|
self.overallScore = nil
|
||||||
|
self.inspectionDate = Date()
|
||||||
|
self.completedAt = nil
|
||||||
|
self.createdAt = Date()
|
||||||
|
self.lastModifiedAt = Date()
|
||||||
|
self.syncStatus = "pending"
|
||||||
|
self.syncErrorMessage = nil
|
||||||
|
self.syncRetryCount = 0
|
||||||
|
self.pendingPhotos = []
|
||||||
|
self.localIssues = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form data helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var formData: [String: Any] {
|
||||||
|
get {
|
||||||
|
guard let data = formDataJSON.data(using: .utf8),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else { return [:] }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: newValue),
|
||||||
|
let str = String(data: data, encoding: .utf8) {
|
||||||
|
formDataJSON = str
|
||||||
|
lastModifiedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setValue(_ value: Any, forFieldId fieldId: String) {
|
||||||
|
var current = formData
|
||||||
|
current[fieldId] = value
|
||||||
|
formData = current
|
||||||
|
}
|
||||||
|
|
||||||
|
func getValue(forFieldId fieldId: String) -> Any? {
|
||||||
|
formData[fieldId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Score calculation (mirrors Python _compute_score_from_form) ────────
|
||||||
|
|
||||||
|
func computeScore(fromSchema schema: [[String: Any]]) -> Double? {
|
||||||
|
let scoreable = schema.filter {
|
||||||
|
["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "")
|
||||||
|
}
|
||||||
|
guard !scoreable.isEmpty else { return nil }
|
||||||
|
|
||||||
|
var total = 0
|
||||||
|
var earned = 0
|
||||||
|
|
||||||
|
for field in scoreable {
|
||||||
|
guard let fid = field["id"] as? String ?? (field["id"].map { "\($0)" }),
|
||||||
|
let ftype = field["type"] as? String
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let val = formData[fid].map { "\($0)" } ?? ""
|
||||||
|
|
||||||
|
switch ftype {
|
||||||
|
case "rating":
|
||||||
|
if let v = Int(val), v > 0 {
|
||||||
|
earned += v
|
||||||
|
total += 5
|
||||||
|
}
|
||||||
|
case "checkbox":
|
||||||
|
total += 1
|
||||||
|
if val == "true" { earned += 1 }
|
||||||
|
case "radio":
|
||||||
|
total += 1
|
||||||
|
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) {
|
||||||
|
earned += 1
|
||||||
|
}
|
||||||
|
case "pass_fail":
|
||||||
|
if val.isEmpty { continue }
|
||||||
|
total += 1
|
||||||
|
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) {
|
||||||
|
earned += 1
|
||||||
|
}
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard total > 0 else { return nil }
|
||||||
|
return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for rounding
|
||||||
|
extension Double {
|
||||||
|
func rounded(toPlaces places: Int) -> Double {
|
||||||
|
let divisor = pow(10.0, Double(places))
|
||||||
|
return (self * divisor).rounded() / divisor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Models/LocalIssue.swift
|
||||||
|
// -----------------------
|
||||||
|
// SwiftData model for issues flagged during an offline inspection.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class LocalIssue {
|
||||||
|
|
||||||
|
@Attribute(.unique) var localId: String
|
||||||
|
var serverId: Int?
|
||||||
|
|
||||||
|
var inspectionLocalId: String // references LocalInspection.localId
|
||||||
|
var areaServerId: Int
|
||||||
|
var severity: String // "low" | "medium" | "high" | "critical"
|
||||||
|
var issueDescription: String
|
||||||
|
var photoLocalPath: String? // local file path before upload
|
||||||
|
var photoServerPath: String? // server path after upload
|
||||||
|
|
||||||
|
var createdAt: Date
|
||||||
|
var syncStatus: String // "pending" | "synced" | "failed"
|
||||||
|
var syncRetryCount: Int
|
||||||
|
var syncErrorMessage: String?
|
||||||
|
|
||||||
|
var inspection: LocalInspection?
|
||||||
|
|
||||||
|
init(
|
||||||
|
inspectionLocalId: String,
|
||||||
|
areaServerId: Int,
|
||||||
|
severity: String,
|
||||||
|
description: String
|
||||||
|
) {
|
||||||
|
self.localId = UUID().uuidString
|
||||||
|
self.serverId = nil
|
||||||
|
self.inspectionLocalId = inspectionLocalId
|
||||||
|
self.areaServerId = areaServerId
|
||||||
|
self.severity = severity
|
||||||
|
self.issueDescription = description
|
||||||
|
self.photoLocalPath = nil
|
||||||
|
self.photoServerPath = nil
|
||||||
|
self.createdAt = Date()
|
||||||
|
self.syncStatus = "pending"
|
||||||
|
self.syncRetryCount = 0
|
||||||
|
self.syncErrorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var severityColor: String {
|
||||||
|
switch severity {
|
||||||
|
case "critical": return "red"
|
||||||
|
case "high": return "orange"
|
||||||
|
case "medium": return "yellow"
|
||||||
|
default: return "blue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Models/LocalTemplate.swift
|
||||||
|
// --------------------------
|
||||||
|
// SwiftData model for locally cached inspection templates.
|
||||||
|
// The form_schema is stored as a raw JSON string and decoded on demand.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class LocalTemplate {
|
||||||
|
@Attribute(.unique) var serverId: Int
|
||||||
|
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
|
||||||
|
|
||||||
|
init(from summary: APITemplateSummary) {
|
||||||
|
self.serverId = summary.id
|
||||||
|
self.name = summary.name
|
||||||
|
self.templateDescription = summary.description
|
||||||
|
self.frequency = summary.frequency
|
||||||
|
self.formSchemaJSON = "[]"
|
||||||
|
self.lastSyncedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSummary(from summary: APITemplateSummary) {
|
||||||
|
self.name = summary.name
|
||||||
|
self.templateDescription = summary.description
|
||||||
|
self.frequency = summary.frequency
|
||||||
|
self.lastSyncedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
})),
|
||||||
|
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.
|
||||||
|
var formSchema: [[String: Any]] {
|
||||||
|
guard let data = formSchemaJSON.data(using: .utf8),
|
||||||
|
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||||
|
else { return [] }
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
||||||
|
var frequencyLabel: String {
|
||||||
|
switch frequency {
|
||||||
|
case "daily": return "Daily"
|
||||||
|
case "weekly": return "Weekly"
|
||||||
|
case "monthly": return "Monthly"
|
||||||
|
case "quarterly": return "Quarterly"
|
||||||
|
default: return frequency.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Models/PendingPhoto.swift
|
||||||
|
// -------------------------
|
||||||
|
// SwiftData model for photos waiting to be uploaded to the server.
|
||||||
|
// Photos are saved locally first, uploaded during sync, then the
|
||||||
|
// local path reference is replaced with the server path.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class PendingPhoto {
|
||||||
|
|
||||||
|
@Attribute(.unique) var localId: String
|
||||||
|
/// Absolute path in app's Documents/JQC/Photos/ directory
|
||||||
|
var localFilePath: String
|
||||||
|
/// "inspection" | "issue"
|
||||||
|
var entityType: String
|
||||||
|
/// References LocalInspection.localId or LocalIssue.localId
|
||||||
|
var entityLocalId: String
|
||||||
|
/// For inspection form image fields — the field's id string
|
||||||
|
var fieldId: String?
|
||||||
|
/// Populated after successful upload
|
||||||
|
var serverPath: String?
|
||||||
|
/// "pending" | "uploaded" | "failed"
|
||||||
|
var uploadStatus: String
|
||||||
|
var createdAt: Date
|
||||||
|
|
||||||
|
var inspection: LocalInspection?
|
||||||
|
|
||||||
|
init(
|
||||||
|
localFilePath: String,
|
||||||
|
entityType: String,
|
||||||
|
entityLocalId: String,
|
||||||
|
fieldId: String? = nil
|
||||||
|
) {
|
||||||
|
self.localId = UUID().uuidString
|
||||||
|
self.localFilePath = localFilePath
|
||||||
|
self.entityType = entityType
|
||||||
|
self.entityLocalId = entityLocalId
|
||||||
|
self.fieldId = fieldId
|
||||||
|
self.serverPath = nil
|
||||||
|
self.uploadStatus = "pending"
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Models/SyncQueueEntry.swift
|
||||||
|
// ---------------------------
|
||||||
|
// SwiftData model for the outbox sync queue.
|
||||||
|
// Every offline write (inspection, issue, photo) enqueues an entry here.
|
||||||
|
// SyncManager processes entries in FIFO order when connectivity is restored.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class SyncQueueEntry {
|
||||||
|
|
||||||
|
@Attribute(.unique) var entryId: String
|
||||||
|
var createdAt: Date
|
||||||
|
/// "inspection" | "issue" | "photo"
|
||||||
|
var entityType: String
|
||||||
|
/// References the entity's localId
|
||||||
|
var localId: String
|
||||||
|
/// "pending" | "in_flight" | "synced" | "failed"
|
||||||
|
var syncStatus: String
|
||||||
|
var retryCount: Int
|
||||||
|
var lastAttemptAt: Date?
|
||||||
|
var lastErrorMessage: String?
|
||||||
|
/// JSON-serialized payload to POST to the server
|
||||||
|
var payloadJSON: String
|
||||||
|
|
||||||
|
init(entityType: String, localId: String, payloadJSON: String) {
|
||||||
|
self.entryId = UUID().uuidString
|
||||||
|
self.createdAt = Date()
|
||||||
|
self.entityType = entityType
|
||||||
|
self.localId = localId
|
||||||
|
self.syncStatus = "pending"
|
||||||
|
self.retryCount = 0
|
||||||
|
self.lastAttemptAt = nil
|
||||||
|
self.lastErrorMessage = nil
|
||||||
|
self.payloadJSON = payloadJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload: [String: Any] {
|
||||||
|
guard let data = payloadJSON.data(using: .utf8),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else { return [:] }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// Sync/SyncManager.swift
|
||||||
|
// ----------------------
|
||||||
|
// Manages connectivity monitoring, reference data sync (Phase A),
|
||||||
|
// and the outbox queue for offline inspection/issue submission (Phase B).
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class SyncManager: ObservableObject {
|
||||||
|
|
||||||
|
// ── Published State ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Published var isOnline = false
|
||||||
|
@Published var isSyncing = false
|
||||||
|
@Published var lastSyncAt: Date?
|
||||||
|
@Published var syncError: String?
|
||||||
|
@Published var pendingCount = 0
|
||||||
|
|
||||||
|
// ── Dependencies ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor")
|
||||||
|
var modelContext: ModelContext?
|
||||||
|
|
||||||
|
static let shared = SyncManager()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// ── Start Monitoring ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func startMonitoring() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let wasOffline = !self.isOnline
|
||||||
|
self.isOnline = path.status == .satisfied
|
||||||
|
if wasOffline && self.isOnline {
|
||||||
|
await self.triggerSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full Sync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func triggerSync() async {
|
||||||
|
guard isOnline, let context = modelContext else { return }
|
||||||
|
isSyncing = true
|
||||||
|
syncError = nil
|
||||||
|
defer { isSyncing = false }
|
||||||
|
|
||||||
|
await processPhotoQueue(context: context)
|
||||||
|
await processInspectionQueue(context: context)
|
||||||
|
await processIssueQueue(context: context)
|
||||||
|
await pullReferenceData()
|
||||||
|
|
||||||
|
updatePendingCount(context: context)
|
||||||
|
lastSyncAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outbox: Photos ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func processPhotoQueue(context: ModelContext) async {
|
||||||
|
// Fetch all then filter in Swift — #Predicate cannot reference
|
||||||
|
// string literals against PendingPhoto.uploadStatus reliably
|
||||||
|
// when the predicate type is inferred across model boundaries.
|
||||||
|
guard let allPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) else { return }
|
||||||
|
let pending = allPhotos
|
||||||
|
.filter { $0.uploadStatus == "pending" }
|
||||||
|
.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
|
||||||
|
for photo in pending {
|
||||||
|
do {
|
||||||
|
let serverPath = try await APIClient.shared.uploadPhoto(
|
||||||
|
localPath: photo.localFilePath,
|
||||||
|
entityType: photo.entityType
|
||||||
|
)
|
||||||
|
photo.serverPath = serverPath
|
||||||
|
photo.uploadStatus = "uploaded"
|
||||||
|
|
||||||
|
// Update parent inspection form field value
|
||||||
|
if photo.entityType == "inspection", let fieldId = photo.fieldId {
|
||||||
|
let entityId = photo.entityLocalId
|
||||||
|
let inspections = try? context.fetch(
|
||||||
|
FetchDescriptor<LocalInspection>(
|
||||||
|
predicate: #Predicate { $0.localId == entityId }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inspections?.first?.setValue(serverPath, forFieldId: fieldId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parent issue photo path
|
||||||
|
if photo.entityType == "issue" {
|
||||||
|
let entityId = photo.entityLocalId
|
||||||
|
let issues = try? context.fetch(
|
||||||
|
FetchDescriptor<LocalIssue>(
|
||||||
|
predicate: #Predicate { $0.localId == entityId }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
issues?.first?.photoServerPath = serverPath
|
||||||
|
}
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
photo.uploadStatus = "failed"
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outbox: Inspections ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func processInspectionQueue(context: ModelContext) async {
|
||||||
|
// Fetch all and filter in Swift to avoid #Predicate compound
|
||||||
|
// string comparison issues across Xcode versions.
|
||||||
|
guard let all = try? context.fetch(FetchDescriptor<LocalInspection>()) else { return }
|
||||||
|
let pending = all
|
||||||
|
.filter { $0.status == "completed" && $0.syncStatus == "pending" }
|
||||||
|
.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
|
||||||
|
for inspection in pending {
|
||||||
|
let photosReady = inspection.pendingPhotos.allSatisfy {
|
||||||
|
$0.uploadStatus == "uploaded" || $0.uploadStatus == "failed"
|
||||||
|
}
|
||||||
|
guard photosReady else { continue }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let inspectionId = try await APIClient.shared.submitInspection(inspection)
|
||||||
|
inspection.serverId = inspectionId
|
||||||
|
inspection.syncStatus = "synced"
|
||||||
|
inspection.status = "synced"
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
inspection.syncRetryCount += 1
|
||||||
|
inspection.syncErrorMessage = error.localizedDescription
|
||||||
|
if inspection.syncRetryCount >= 5 {
|
||||||
|
inspection.syncStatus = "failed"
|
||||||
|
}
|
||||||
|
syncError = "Failed to sync inspection: \(error.localizedDescription)"
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outbox: Issues ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func processIssueQueue(context: ModelContext) async {
|
||||||
|
guard let all = try? context.fetch(FetchDescriptor<LocalIssue>()) else { return }
|
||||||
|
let pending = all
|
||||||
|
.filter { $0.syncStatus == "pending" }
|
||||||
|
.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
|
||||||
|
for issue in pending {
|
||||||
|
do {
|
||||||
|
let issueId = try await APIClient.shared.submitIssue(issue)
|
||||||
|
issue.serverId = issueId
|
||||||
|
issue.syncStatus = "synced"
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
issue.syncRetryCount += 1
|
||||||
|
issue.syncErrorMessage = error.localizedDescription
|
||||||
|
if issue.syncRetryCount >= 5 {
|
||||||
|
issue.syncStatus = "failed"
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reference Data ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func pullReferenceData() async {
|
||||||
|
guard isOnline, let context = modelContext else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Sequential fetches avoid Swift 6 actor-isolation warnings
|
||||||
|
// on Decodable structs used across async boundaries.
|
||||||
|
let facilitiesData: FacilitiesResponseData =
|
||||||
|
try await APIClient.shared.request("/api/v1/facilities")
|
||||||
|
let templatesData: TemplatesResponseData =
|
||||||
|
try await APIClient.shared.request("/api/v1/templates")
|
||||||
|
|
||||||
|
let existingFacilities = try context.fetch(FetchDescriptor<LocalFacility>())
|
||||||
|
let facilityMap = Dictionary(
|
||||||
|
existingFacilities.map { ($0.serverId, $0) },
|
||||||
|
uniquingKeysWith: { a, _ in a }
|
||||||
|
)
|
||||||
|
|
||||||
|
for apiFacility in facilitiesData.facilities {
|
||||||
|
if let existing = facilityMap[apiFacility.id] {
|
||||||
|
existing.update(from: apiFacility)
|
||||||
|
} else {
|
||||||
|
context.insert(LocalFacility(from: apiFacility))
|
||||||
|
}
|
||||||
|
try await upsertAreas(for: apiFacility.id, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingTemplates = try context.fetch(FetchDescriptor<LocalTemplate>())
|
||||||
|
let templateMap = Dictionary(
|
||||||
|
existingTemplates.map { ($0.serverId, $0) },
|
||||||
|
uniquingKeysWith: { a, _ in a }
|
||||||
|
)
|
||||||
|
|
||||||
|
for apiSummary in templatesData.templates {
|
||||||
|
if let existing = templateMap[apiSummary.id] {
|
||||||
|
existing.updateSummary(from: apiSummary)
|
||||||
|
} else {
|
||||||
|
context.insert(LocalTemplate(from: apiSummary))
|
||||||
|
}
|
||||||
|
try await upsertTemplateSchema(
|
||||||
|
id: apiSummary.id, context: context, templateMap: templateMap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
} catch APIError.notAuthenticated {
|
||||||
|
syncError = "Session expired. Please log in again."
|
||||||
|
} catch {
|
||||||
|
syncError = "Sync failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pending Count ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func updatePendingCount(context: ModelContext) {
|
||||||
|
let inspCount = (try? context.fetch(FetchDescriptor<LocalInspection>()))?
|
||||||
|
.filter { $0.syncStatus == "pending" }.count ?? 0
|
||||||
|
let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))?
|
||||||
|
.filter { $0.syncStatus == "pending" }.count ?? 0
|
||||||
|
pendingCount = inspCount + issueCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func upsertAreas(for facilityId: Int, context: ModelContext) async throws {
|
||||||
|
let areasData: AreasResponseData = try await APIClient.shared.request(
|
||||||
|
"/api/v1/facilities/\(facilityId)/areas"
|
||||||
|
)
|
||||||
|
let existing = (try? context.fetch(FetchDescriptor<LocalArea>()))?
|
||||||
|
.filter { $0.facilityServerId == facilityId } ?? []
|
||||||
|
let areaMap = Dictionary(existing.map { ($0.serverId, $0) },
|
||||||
|
uniquingKeysWith: { a, _ in a })
|
||||||
|
for apiArea in areasData.areas {
|
||||||
|
if let ex = areaMap[apiArea.id] { ex.update(from: apiArea) }
|
||||||
|
else { context.insert(LocalArea(from: apiArea)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func upsertTemplateSchema(
|
||||||
|
id: Int,
|
||||||
|
context: ModelContext,
|
||||||
|
templateMap: [Int: LocalTemplate]
|
||||||
|
) async throws {
|
||||||
|
let detailData: TemplateDetailResponseData = try await APIClient.shared.request(
|
||||||
|
"/api/v1/templates/\(id)"
|
||||||
|
)
|
||||||
|
if let existing = templateMap[id] {
|
||||||
|
existing.updateSchema(from: detailData.template)
|
||||||
|
} else {
|
||||||
|
(try? context.fetch(FetchDescriptor<LocalTemplate>()))?
|
||||||
|
.first { $0.serverId == id }?
|
||||||
|
.updateSchema(from: detailData.template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Utils/Constants.swift
|
||||||
|
// ---------------------
|
||||||
|
// Central place for app-wide constants.
|
||||||
|
// IMPORTANT: Replace the baseURL with your actual server URL.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Constants {
|
||||||
|
// ── Server ────────────────────────────────────────────────────────────
|
||||||
|
/// Your JanitorialQC server base URL. No trailing slash.
|
||||||
|
/// Example: "https://your-domain.com"
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync ──────────────────────────────────────────────────────────────
|
||||||
|
/// How many minutes before the access token expires to trigger a refresh
|
||||||
|
static let tokenRefreshBufferMinutes: Double = 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Views/Auth/LoginView.swift
|
||||||
|
// --------------------------
|
||||||
|
// The login screen shown when the user is not authenticated.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject private var auth: AuthManager
|
||||||
|
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
|
private enum Field { case username, password }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
// Background
|
||||||
|
Color(.systemGroupedBackground).ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// ── Logo / Header ──────────────────────────────────────
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text("JanitorialQC Inspector")
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
|
||||||
|
Text("Janitorial Quality Control")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 48)
|
||||||
|
|
||||||
|
// ── Login Form ─────────────────────────────────────────
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
GroupBox {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "person")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 24)
|
||||||
|
TextField("Username", text: $username)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .username)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focusedField = .password }
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "lock")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 24)
|
||||||
|
SecureField("Password", text: $password)
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit { Task { await signIn() } }
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if let error = auth.errorMessage {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign In button
|
||||||
|
Button {
|
||||||
|
Task { await signIn() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
if auth.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
}
|
||||||
|
Text("Sign In")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(auth.isLoading || username.isEmpty || password.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// ── App Version ────────────────────────────────────────
|
||||||
|
Text("JanitorialQC Inspector · Phase A")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signIn() async {
|
||||||
|
focusedField = nil
|
||||||
|
await auth.login(username: username, password: password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LoginView()
|
||||||
|
.environmentObject(AuthManager.shared)
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
// Views/Dashboard/DashboardView.swift
|
||||||
|
// ------------------------------------
|
||||||
|
// Phase B: adds My Inspections list and Pending Sync status to the sidebar.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject private var auth: AuthManager
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
||||||
|
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
||||||
|
@Query(
|
||||||
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
||||||
|
sort: \LocalInspection.lastModifiedAt,
|
||||||
|
order: .reverse
|
||||||
|
) private var myInspections: [LocalInspection]
|
||||||
|
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
@State private var showNewInspection = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
// ── Sidebar ────────────────────────────────────────────────────
|
||||||
|
List {
|
||||||
|
Button { selectedTab = 0 } label: {
|
||||||
|
HStack {
|
||||||
|
Label("My Inspections", systemImage: "checklist")
|
||||||
|
.foregroundStyle(selectedTab == 0 ? .blue : .primary)
|
||||||
|
Spacer()
|
||||||
|
if !myInspections.isEmpty {
|
||||||
|
Text("\(myInspections.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.blue.opacity(0.15))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
Button { selectedTab = 1 } label: {
|
||||||
|
Label("Facilities", systemImage: "building.2")
|
||||||
|
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
|
||||||
|
}
|
||||||
|
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
Button { selectedTab = 2 } label: {
|
||||||
|
Label("Templates", systemImage: "doc.text")
|
||||||
|
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
|
||||||
|
}
|
||||||
|
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
Button { selectedTab = 3 } label: {
|
||||||
|
HStack {
|
||||||
|
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
|
||||||
|
Spacer()
|
||||||
|
if sync.pendingCount > 0 {
|
||||||
|
Text("\(sync.pendingCount)")
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.2))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
Button { selectedTab = 4 } label: {
|
||||||
|
Label("Settings", systemImage: "gear")
|
||||||
|
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
|
||||||
|
}
|
||||||
|
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
}
|
||||||
|
.navigationTitle("JQC Inspector")
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showNewInspection = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
syncStatusFooter
|
||||||
|
}
|
||||||
|
|
||||||
|
} detail: {
|
||||||
|
switch selectedTab {
|
||||||
|
case 0: MyInspectionsView()
|
||||||
|
case 1: FacilitiesListView()
|
||||||
|
case 2: TemplatesListView()
|
||||||
|
case 3: SyncStatusView()
|
||||||
|
default: SettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewInspection) {
|
||||||
|
StartInspectionView()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if sync.isOnline {
|
||||||
|
await sync.triggerSync()
|
||||||
|
} else {
|
||||||
|
sync.updatePendingCount(context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync Status Footer ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private var syncStatusFooter: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(sync.isOnline ? Color.green : Color.orange)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(sync.isOnline ? "Online" : "Offline")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if sync.isSyncing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.7)
|
||||||
|
} else if let lastSync = sync.lastSyncAt {
|
||||||
|
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - My Inspections
|
||||||
|
|
||||||
|
struct MyInspectionsView: View {
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
||||||
|
sort: \LocalInspection.lastModifiedAt,
|
||||||
|
order: .reverse
|
||||||
|
) private var inspections: [LocalInspection]
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if inspections.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Inspections",
|
||||||
|
systemImage: "checklist",
|
||||||
|
description: Text("Tap + to start a new inspection.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(inspections) { inspection in
|
||||||
|
NavigationLink {
|
||||||
|
if inspection.status == "draft" {
|
||||||
|
ExecuteInspectionView(inspection: inspection)
|
||||||
|
} else {
|
||||||
|
CompletedInspectionView(inspection: inspection)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
InspectionRowView(inspection: inspection, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("My Inspections")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InspectionRowView: View {
|
||||||
|
let inspection: LocalInspection
|
||||||
|
let context: ModelContext
|
||||||
|
|
||||||
|
private var facilityName: String {
|
||||||
|
let id = inspection.facilityServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Unknown Facility"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var templateName: String {
|
||||||
|
let id = inspection.templateServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Unknown Template"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(templateName)
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
||||||
|
}
|
||||||
|
Text(facilityName)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
if let score = inspection.overallScore {
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.1f%%", score))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusBadge: View {
|
||||||
|
let status: String
|
||||||
|
let syncStatus: String
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch status {
|
||||||
|
case "draft": return "Draft"
|
||||||
|
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
|
||||||
|
case "sync_failed": return "Sync Failed"
|
||||||
|
default: return status.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch status {
|
||||||
|
case "draft": return .blue
|
||||||
|
case "completed": return syncStatus == "pending" ? .orange : .green
|
||||||
|
case "sync_failed": return .red
|
||||||
|
default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(color.opacity(0.15))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Completed Inspection (read-only view)
|
||||||
|
|
||||||
|
struct CompletedInspectionView: View {
|
||||||
|
let inspection: LocalInspection
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
private var templateName: String {
|
||||||
|
let id = inspection.templateServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Inspection"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Summary card
|
||||||
|
GroupBox {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let score = inspection.overallScore {
|
||||||
|
HStack {
|
||||||
|
Text("Overall Score")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.1f%%", score))
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let completedAt = inspection.completedAt {
|
||||||
|
HStack {
|
||||||
|
Text("Completed")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Sync Status")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
StatusBadge(status: inspection.status,
|
||||||
|
syncStatus: inspection.syncStatus)
|
||||||
|
}
|
||||||
|
if let error = inspection.syncErrorMessage {
|
||||||
|
Text("Error: \(error)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
if !inspection.localIssues.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Flagged Issues (\(inspection.localIssues.count))")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal)
|
||||||
|
ForEach(inspection.localIssues) { issue in
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(issue.severity == "critical" ? Color.red :
|
||||||
|
issue.severity == "high" ? Color.orange :
|
||||||
|
issue.severity == "medium" ? Color.yellow : Color.blue)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.padding(.top, 4)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(issue.severity.capitalized)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(issue.issueDescription)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
.navigationTitle(templateName)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status View
|
||||||
|
|
||||||
|
struct SyncStatusView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
filter: #Predicate<LocalInspection> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
||||||
|
sort: \LocalInspection.createdAt
|
||||||
|
) private var pendingInspections: [LocalInspection]
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
filter: #Predicate<LocalIssue> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
||||||
|
sort: \LocalIssue.createdAt
|
||||||
|
) private var pendingIssues: [LocalIssue]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Status") {
|
||||||
|
HStack {
|
||||||
|
Circle()
|
||||||
|
.fill(sync.isOnline ? Color.green : Color.orange)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(sync.isOnline ? "Online" : "Offline")
|
||||||
|
}
|
||||||
|
if let lastSync = sync.lastSyncAt {
|
||||||
|
LabeledContent("Last Sync",
|
||||||
|
value: lastSync.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
}
|
||||||
|
if sync.isSyncing {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
Text("Syncing…")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let error = sync.syncError {
|
||||||
|
Text(error).foregroundStyle(.red).font(.callout)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await sync.triggerSync() }
|
||||||
|
} label: {
|
||||||
|
Label("Sync Now", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.disabled(!sync.isOnline || sync.isSyncing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pendingInspections.isEmpty {
|
||||||
|
Section("Pending Inspections (\(pendingInspections.count))") {
|
||||||
|
ForEach(pendingInspections) { inspection in
|
||||||
|
SyncRowView(
|
||||||
|
title: "Inspection",
|
||||||
|
status: inspection.syncStatus,
|
||||||
|
retryCount: inspection.syncRetryCount,
|
||||||
|
error: inspection.syncErrorMessage,
|
||||||
|
date: inspection.createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pendingIssues.isEmpty {
|
||||||
|
Section("Pending Issues (\(pendingIssues.count))") {
|
||||||
|
ForEach(pendingIssues) { issue in
|
||||||
|
SyncRowView(
|
||||||
|
title: "\(issue.severity.capitalized) Issue",
|
||||||
|
status: issue.syncStatus,
|
||||||
|
retryCount: issue.syncRetryCount,
|
||||||
|
error: issue.syncErrorMessage,
|
||||||
|
date: issue.createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing {
|
||||||
|
Section {
|
||||||
|
Label("All items synced.", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Pending Sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncRowView: View {
|
||||||
|
let title: String
|
||||||
|
let status: String
|
||||||
|
let retryCount: Int
|
||||||
|
let error: String?
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(title).font(.callout)
|
||||||
|
Spacer()
|
||||||
|
Text(status.capitalized)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(status == "failed" ? .red : .orange)
|
||||||
|
}
|
||||||
|
Text(date.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
if let err = error {
|
||||||
|
Text(err)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
if retryCount > 0 {
|
||||||
|
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Facilities, Templates, Settings (unchanged from Phase A)
|
||||||
|
|
||||||
|
struct FacilitiesListView: View {
|
||||||
|
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if facilities.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Facilities",
|
||||||
|
systemImage: "building.2.slash",
|
||||||
|
description: Text("Connect to the internet to sync your assigned facilities.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(facilities) { facility in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(facility.name).font(.headline)
|
||||||
|
if !facility.address.isEmpty {
|
||||||
|
Text(facility.address).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if facility.projectName != "No Contract" {
|
||||||
|
Text(facility.projectName).font(.caption2).foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")")
|
||||||
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Facilities (\(facilities.count))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplatesListView: View {
|
||||||
|
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if templates.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Templates",
|
||||||
|
systemImage: "doc.text.magnifyingglass",
|
||||||
|
description: Text("Connect to the internet to sync inspection templates.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(templates) { template in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(template.name).font(.headline)
|
||||||
|
if !template.templateDescription.isEmpty {
|
||||||
|
Text(template.templateDescription)
|
||||||
|
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
if !template.frequency.isEmpty {
|
||||||
|
Label(template.frequencyLabel, systemImage: "clock")
|
||||||
|
.font(.caption2).foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("\(template.formSchema.count) field\(template.formSchema.count == 1 ? "" : "s")")
|
||||||
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Templates (\(templates.count))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject private var auth: AuthManager
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Account") {
|
||||||
|
LabeledContent("Username", value: auth.currentUsername)
|
||||||
|
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
||||||
|
}
|
||||||
|
Section("Sync") {
|
||||||
|
Button { Task { await sync.triggerSync() } } label: {
|
||||||
|
Label("Sync Now", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.disabled(!sync.isOnline || sync.isSyncing)
|
||||||
|
if let error = sync.syncError {
|
||||||
|
Text(error).font(.caption).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await auth.logout() }
|
||||||
|
} label: {
|
||||||
|
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("App Info") {
|
||||||
|
LabeledContent("Version", value: "Phase B")
|
||||||
|
LabeledContent("Server", value: Constants.baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// Views/Inspection/ExecuteInspectionView.swift
|
||||||
|
// --------------------------------------------
|
||||||
|
// The primary work surface for completing an inspection.
|
||||||
|
// Renders the dynamic form_schema from the selected template.
|
||||||
|
// All writes go to SwiftData (offline-safe). Auto-saves every 30 seconds.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ExecuteInspectionView: View {
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
@EnvironmentObject private var auth: AuthManager
|
||||||
|
|
||||||
|
let inspection: LocalInspection
|
||||||
|
|
||||||
|
// Local state for the form — mirrors inspection.formData
|
||||||
|
@State private var formValues: [String: String] = [:]
|
||||||
|
@State private var showFlagIssue = false
|
||||||
|
@State private var showSubmitAlert = false
|
||||||
|
@State private var showOfflineBanner = false
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var isSubmitting = false
|
||||||
|
@State private var submitMessage = ""
|
||||||
|
|
||||||
|
// Auto-save timer
|
||||||
|
private let autoSaveInterval: TimeInterval = 30
|
||||||
|
|
||||||
|
private var template: LocalTemplate? {
|
||||||
|
// Look up the template from SwiftData
|
||||||
|
let id = inspection.templateServerId
|
||||||
|
return try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var facility: LocalFacility? {
|
||||||
|
let id = inspection.facilityServerId
|
||||||
|
return try? context.fetch(
|
||||||
|
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formSchema: [[String: Any]] {
|
||||||
|
template?.formSchema ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 16) {
|
||||||
|
|
||||||
|
// ── Offline banner ─────────────────────────────────────────
|
||||||
|
if !sync.isOnline {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
Text("Offline — your work saves locally and will sync automatically.")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inspection header ──────────────────────────────────────
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(template?.name ?? "Inspection Form")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text(facility?.name ?? "")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(inspection.inspectionDate.formatted(date: .long, time: .shortened))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// ── Form fields ────────────────────────────────────────────
|
||||||
|
ForEach(formSchema.indices, id: \.self) { idx in
|
||||||
|
let field = formSchema[idx]
|
||||||
|
let fid = fieldId(field)
|
||||||
|
let ftype = field["type"] as? String ?? ""
|
||||||
|
|
||||||
|
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||||
|
FormFieldView(
|
||||||
|
field: field,
|
||||||
|
value: Binding(
|
||||||
|
get: { formValues[fid] ?? "" },
|
||||||
|
set: { formValues[fid] = $0; saveDraft() }
|
||||||
|
),
|
||||||
|
onPhotoSelected: { localPath in
|
||||||
|
handlePhotoSelected(localPath: localPath, field: field)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inspector notes ────────────────────────────────────────
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Inspector Notes")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
TextEditor(text: Binding(
|
||||||
|
get: { inspection.inspectorNotes },
|
||||||
|
set: { inspection.inspectorNotes = $0 }
|
||||||
|
))
|
||||||
|
.frame(minHeight: 80)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4)))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// ── Action buttons ─────────────────────────────────────────
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
// Flag Issue
|
||||||
|
Button {
|
||||||
|
showFlagIssue = true
|
||||||
|
} label: {
|
||||||
|
Label("Flag an Issue", systemImage: "exclamationmark.triangle")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.orange)
|
||||||
|
|
||||||
|
// Save Draft
|
||||||
|
Button {
|
||||||
|
saveDraft(force: true)
|
||||||
|
} label: {
|
||||||
|
Label(isSaving ? "Saving…" : "Save Draft", systemImage: "square.and.arrow.down")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(isSaving)
|
||||||
|
|
||||||
|
// Submit Inspection
|
||||||
|
Button {
|
||||||
|
showSubmitAlert = true
|
||||||
|
} label: {
|
||||||
|
Label("Submit Inspection", systemImage: "checkmark.circle.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(isSubmitting)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Inspection")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
ConnectivityBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Load saved form data into local state
|
||||||
|
formValues = inspection.formData.compactMapValues { "\($0)" }
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
saveDraft(force: true)
|
||||||
|
}
|
||||||
|
// Auto-save every 30 seconds
|
||||||
|
.task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(autoSaveInterval))
|
||||||
|
saveDraft()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showFlagIssue) {
|
||||||
|
FlagIssueView(inspection: inspection)
|
||||||
|
}
|
||||||
|
.alert("Submit Inspection", isPresented: $showSubmitAlert) {
|
||||||
|
Button("Submit", role: .none) { submitInspection() }
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Once submitted, the inspection cannot be edited. " +
|
||||||
|
(sync.isOnline
|
||||||
|
? "It will be sent to the server now."
|
||||||
|
: "It will sync automatically when you're back online."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save Draft ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func saveDraft(force: Bool = false) {
|
||||||
|
guard inspection.status == "draft" else { return }
|
||||||
|
if force { isSaving = true }
|
||||||
|
|
||||||
|
// Write form values back to the model
|
||||||
|
var data: [String: Any] = [:]
|
||||||
|
for (k, v) in formValues { data[k] = v }
|
||||||
|
inspection.formData = data
|
||||||
|
inspection.lastModifiedAt = Date()
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
if force {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func submitInspection() {
|
||||||
|
isSubmitting = true
|
||||||
|
|
||||||
|
// Persist final form data
|
||||||
|
var data: [String: Any] = [:]
|
||||||
|
for (k, v) in formValues { data[k] = v }
|
||||||
|
inspection.formData = data
|
||||||
|
|
||||||
|
// Compute score
|
||||||
|
inspection.overallScore = inspection.computeScore(fromSchema: formSchema)
|
||||||
|
inspection.status = "completed"
|
||||||
|
inspection.completedAt = Date()
|
||||||
|
inspection.syncStatus = "pending"
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
// Trigger sync if online
|
||||||
|
if sync.isOnline {
|
||||||
|
Task { await sync.triggerSync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo handling ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
|
||||||
|
let fid = fieldId(field)
|
||||||
|
|
||||||
|
// Store local sentinel in form values
|
||||||
|
formValues[fid] = "local://\(localPath)"
|
||||||
|
|
||||||
|
// Create PendingPhoto record
|
||||||
|
let photo = PendingPhoto(
|
||||||
|
localFilePath: localPath,
|
||||||
|
entityType: "inspection",
|
||||||
|
entityLocalId: inspection.localId,
|
||||||
|
fieldId: fid
|
||||||
|
)
|
||||||
|
inspection.pendingPhotos.append(photo)
|
||||||
|
context.insert(photo)
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func fieldId(_ field: [String: Any]) -> String {
|
||||||
|
if let id = field["id"] as? String { return id }
|
||||||
|
if let id = field["id"] as? Int { return String(id) }
|
||||||
|
return UUID().uuidString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connectivity Badge
|
||||||
|
|
||||||
|
struct ConnectivityBadge: View {
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(sync.isOnline ? Color.green : Color.orange)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(sync.isOnline ? "Online" : "Offline")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Views/Inspection/FlagIssueView.swift
|
||||||
|
// -------------------------------------
|
||||||
|
// Sheet for flagging an issue during an inspection.
|
||||||
|
// Saves locally immediately; syncs to server when online.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct FlagIssueView: View {
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
|
||||||
|
let inspection: LocalInspection
|
||||||
|
|
||||||
|
@State private var selectedAreaId: Int?
|
||||||
|
@State private var severity = "medium"
|
||||||
|
@State private var description = ""
|
||||||
|
@State private var selectedImage: UIImage?
|
||||||
|
@State private var photoLocalPath: String?
|
||||||
|
@State private var showImagePicker = false
|
||||||
|
|
||||||
|
private let severities = ["low", "medium", "high", "critical"]
|
||||||
|
|
||||||
|
private var areas: [LocalArea] {
|
||||||
|
let facilityId = inspection.facilityServerId
|
||||||
|
let results = try? context.fetch(
|
||||||
|
FetchDescriptor<LocalArea>(
|
||||||
|
predicate: #Predicate { $0.facilityServerId == facilityId },
|
||||||
|
sortBy: [SortDescriptor(\.name)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
selectedAreaId != nil && !description.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// ── Area ───────────────────────────────────────────────────
|
||||||
|
Section("Area") {
|
||||||
|
Picker("Area", selection: $selectedAreaId) {
|
||||||
|
Text("Select area…").tag(Optional<Int>(nil))
|
||||||
|
ForEach(areas) { area in
|
||||||
|
Text(area.name).tag(Optional(area.serverId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Severity ───────────────────────────────────────────────
|
||||||
|
Section("Severity") {
|
||||||
|
Picker("Severity", selection: $severity) {
|
||||||
|
ForEach(severities, id: \.self) { s in
|
||||||
|
Text(s.capitalized).tag(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Description ────────────────────────────────────────────
|
||||||
|
Section("Description") {
|
||||||
|
TextEditor(text: $description)
|
||||||
|
.frame(minHeight: 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo ──────────────────────────────────────────────────
|
||||||
|
Section("Photo (Optional)") {
|
||||||
|
if let img = selectedImage {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 160)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
showImagePicker = true
|
||||||
|
} label: {
|
||||||
|
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo",
|
||||||
|
systemImage: "camera")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Offline notice ─────────────────────────────────────────
|
||||||
|
if !sync.isOnline {
|
||||||
|
Section {
|
||||||
|
Label("You're offline — this issue will sync automatically.",
|
||||||
|
systemImage: "wifi.slash")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Flag Issue")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Submit") { submitIssue() }
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showImagePicker) {
|
||||||
|
ImagePickerView(image: $selectedImage) { img in
|
||||||
|
savePhoto(img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePhoto(_ img: UIImage) {
|
||||||
|
guard let data = img.jpegData(compressionQuality: 0.8) else { return }
|
||||||
|
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: photosDir,
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
let filename = "\(UUID().uuidString).jpg"
|
||||||
|
let fileURL = photosDir.appendingPathComponent(filename)
|
||||||
|
try? data.write(to: fileURL)
|
||||||
|
photoLocalPath = fileURL.path
|
||||||
|
selectedImage = img
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitIssue() {
|
||||||
|
guard let areaId = selectedAreaId else { return }
|
||||||
|
|
||||||
|
let issue = LocalIssue(
|
||||||
|
inspectionLocalId: inspection.localId,
|
||||||
|
areaServerId: areaId,
|
||||||
|
severity: severity,
|
||||||
|
description: description.trimmingCharacters(in: .whitespaces)
|
||||||
|
)
|
||||||
|
issue.photoLocalPath = photoLocalPath
|
||||||
|
issue.inspection = inspection
|
||||||
|
inspection.localIssues.append(issue)
|
||||||
|
context.insert(issue)
|
||||||
|
|
||||||
|
// Create PendingPhoto if a photo was attached
|
||||||
|
if let path = photoLocalPath {
|
||||||
|
let photo = PendingPhoto(
|
||||||
|
localFilePath: path,
|
||||||
|
entityType: "issue",
|
||||||
|
entityLocalId: issue.localId
|
||||||
|
)
|
||||||
|
context.insert(photo)
|
||||||
|
}
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
if sync.isOnline {
|
||||||
|
Task { await sync.triggerSync() }
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
// Views/Inspection/FormRenderer/FormFieldView.swift
|
||||||
|
// -------------------------------------------------
|
||||||
|
// Renders a single form field from the inspection template's form_schema.
|
||||||
|
// Supports all field types used by the JQC web app.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import PencilKit
|
||||||
|
|
||||||
|
// MARK: - FormFieldView
|
||||||
|
|
||||||
|
struct FormFieldView: View {
|
||||||
|
|
||||||
|
let field: [String: Any]
|
||||||
|
@Binding var value: String // All values stored as strings; lists as JSON
|
||||||
|
var onPhotoSelected: ((String) -> Void)? = nil // callback with local file path
|
||||||
|
|
||||||
|
private var fieldType: String { field["type"] as? String ?? "text" }
|
||||||
|
private var label: String { field["label"] as? String ?? "" }
|
||||||
|
private var required: Bool { field["required"] as? Bool ?? false }
|
||||||
|
private var placeholder: String { field["placeholder"] as? String ?? "" }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|
||||||
|
// Field label (skip for section/label types which render themselves)
|
||||||
|
if !["section", "label", "button_submit", "button_print", "button_email"]
|
||||||
|
.contains(fieldType), !label.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
if required {
|
||||||
|
Text("*")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field input
|
||||||
|
fieldInput
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var fieldInput: some View {
|
||||||
|
switch fieldType {
|
||||||
|
|
||||||
|
case "section":
|
||||||
|
Text(label)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
case "label":
|
||||||
|
let textContent = field["text_content"] as? String
|
||||||
|
?? field["text"] as? String
|
||||||
|
?? label
|
||||||
|
Text(textContent)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
case "text":
|
||||||
|
TextField(placeholder.isEmpty ? label : placeholder, text: $value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
TextEditor(text: $value)
|
||||||
|
.frame(minHeight: 80)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4)))
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
TextField(placeholder.isEmpty ? "0" : placeholder, text: $value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
|
||||||
|
case "email":
|
||||||
|
TextField(placeholder.isEmpty ? "email@example.com" : placeholder, text: $value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
DateFieldView(value: $value)
|
||||||
|
|
||||||
|
case "checkbox":
|
||||||
|
Toggle(label, isOn: Binding(
|
||||||
|
get: { value == "true" },
|
||||||
|
set: { value = $0 ? "true" : "false" }
|
||||||
|
))
|
||||||
|
|
||||||
|
case "checkbox_group":
|
||||||
|
CheckboxGroupView(field: field, value: $value)
|
||||||
|
|
||||||
|
case "radio":
|
||||||
|
RadioGroupView(field: field, value: $value)
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
SelectFieldView(field: field, value: $value)
|
||||||
|
|
||||||
|
case "rating":
|
||||||
|
RatingFieldView(
|
||||||
|
maxRating: field["max"] as? Int ?? 5,
|
||||||
|
value: Binding(
|
||||||
|
get: { Int(value) ?? 0 },
|
||||||
|
set: { value = String($0) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case "pass_fail":
|
||||||
|
PassFailFieldView(value: $value)
|
||||||
|
|
||||||
|
case "signature":
|
||||||
|
SignatureFieldView(value: $value)
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
ImageFieldView(
|
||||||
|
fieldId: field["id"] as? String ?? UUID().uuidString,
|
||||||
|
currentValue: value,
|
||||||
|
onPhotoSelected: onPhotoSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
case "table":
|
||||||
|
TableFieldView(field: field, value: $value)
|
||||||
|
|
||||||
|
default:
|
||||||
|
TextField(placeholder.isEmpty ? label : placeholder, text: $value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DateFieldView
|
||||||
|
|
||||||
|
struct DateFieldView: View {
|
||||||
|
@Binding var value: String
|
||||||
|
|
||||||
|
private var dateBinding: Binding<Date> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
return formatter.date(from: value) ?? Date()
|
||||||
|
},
|
||||||
|
set: {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
value = formatter.string(from: $0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
DatePicker("", selection: dateBinding, displayedComponents: .date)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CheckboxGroupView
|
||||||
|
|
||||||
|
struct CheckboxGroupView: View {
|
||||||
|
let field: [String: Any]
|
||||||
|
@Binding var value: String // JSON array of selected values
|
||||||
|
|
||||||
|
private var options: [String] {
|
||||||
|
field["options"] as? [String] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedValues: Set<String> {
|
||||||
|
guard let data = value.data(using: .utf8),
|
||||||
|
let array = try? JSONSerialization.jsonObject(with: data) as? [String]
|
||||||
|
else { return [] }
|
||||||
|
return Set(array)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggle(_ option: String) {
|
||||||
|
var current = selectedValues
|
||||||
|
if current.contains(option) {
|
||||||
|
current.remove(option)
|
||||||
|
} else {
|
||||||
|
current.insert(option)
|
||||||
|
}
|
||||||
|
let sorted = options.filter { current.contains($0) }
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: sorted),
|
||||||
|
let str = String(data: data, encoding: .utf8) {
|
||||||
|
value = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
Button {
|
||||||
|
toggle(option)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: selectedValues.contains(option)
|
||||||
|
? "checkmark.square.fill" : "square")
|
||||||
|
.foregroundStyle(selectedValues.contains(option) ? .blue : .secondary)
|
||||||
|
Text(option)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RadioGroupView
|
||||||
|
|
||||||
|
struct RadioGroupView: View {
|
||||||
|
let field: [String: Any]
|
||||||
|
@Binding var value: String
|
||||||
|
|
||||||
|
private var options: [String] {
|
||||||
|
field["options"] as? [String] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
Button {
|
||||||
|
value = option
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: value == option
|
||||||
|
? "largecircle.fill.circle" : "circle")
|
||||||
|
.foregroundStyle(value == option ? .blue : .secondary)
|
||||||
|
Text(option)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SelectFieldView
|
||||||
|
|
||||||
|
struct SelectFieldView: View {
|
||||||
|
let field: [String: Any]
|
||||||
|
@Binding var value: String
|
||||||
|
|
||||||
|
private var options: [String] {
|
||||||
|
field["options"] as? [String] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker("", selection: $value) {
|
||||||
|
Text("Select…").tag("")
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
Text(option).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RatingFieldView
|
||||||
|
|
||||||
|
struct RatingFieldView: View {
|
||||||
|
let maxRating: Int
|
||||||
|
@Binding var value: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(1...maxRating, id: \.self) { star in
|
||||||
|
Button {
|
||||||
|
value = (value == star) ? 0 : star // tap same star to clear
|
||||||
|
} label: {
|
||||||
|
Image(systemName: star <= value ? "star.fill" : "star")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(star <= value ? .yellow : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
if value > 0 {
|
||||||
|
Text("\(value)/\(maxRating)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PassFailFieldView
|
||||||
|
|
||||||
|
struct PassFailFieldView: View {
|
||||||
|
@Binding var value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
value = value == "pass" ? "" : "pass"
|
||||||
|
} label: {
|
||||||
|
Label("Pass", systemImage: "checkmark.circle.fill")
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(value == "pass" ? Color.green : Color(.systemGray5))
|
||||||
|
.foregroundStyle(value == "pass" ? .white : .primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
value = value == "fail" ? "" : "fail"
|
||||||
|
} label: {
|
||||||
|
Label("Fail", systemImage: "xmark.circle.fill")
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(value == "fail" ? Color.red : Color(.systemGray5))
|
||||||
|
.foregroundStyle(value == "fail" ? .white : .primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SignatureFieldView
|
||||||
|
|
||||||
|
struct SignatureFieldView: UIViewRepresentable {
|
||||||
|
@Binding var value: String // stored as base64 PNG data URL
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> PKCanvasView {
|
||||||
|
let canvas = PKCanvasView()
|
||||||
|
canvas.drawingPolicy = .anyInput
|
||||||
|
canvas.backgroundColor = UIColor.systemBackground
|
||||||
|
canvas.layer.borderColor = UIColor.systemGray4.cgColor
|
||||||
|
canvas.layer.borderWidth = 1
|
||||||
|
canvas.layer.cornerRadius = 6
|
||||||
|
canvas.delegate = context.coordinator
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ canvas: PKCanvasView, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(value: $value) }
|
||||||
|
|
||||||
|
class Coordinator: NSObject, PKCanvasViewDelegate {
|
||||||
|
var value: Binding<String>
|
||||||
|
init(value: Binding<String>) { self.value = value }
|
||||||
|
|
||||||
|
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
|
||||||
|
let image = canvasView.drawing.image(from: canvasView.bounds, scale: 1)
|
||||||
|
if let data = image.pngData() {
|
||||||
|
value.wrappedValue = "data:image/png;base64,\(data.base64EncodedString())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ImageFieldView
|
||||||
|
|
||||||
|
struct ImageFieldView: View {
|
||||||
|
let fieldId: String
|
||||||
|
let currentValue: String
|
||||||
|
var onPhotoSelected: ((String) -> Void)?
|
||||||
|
|
||||||
|
@State private var showPicker = false
|
||||||
|
@State private var selectedImage: UIImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Preview
|
||||||
|
if let img = selectedImage {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 200)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else if currentValue.hasPrefix("uploads/") {
|
||||||
|
// Already uploaded in a previous session — show a placeholder
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "photo.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Photo attached")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showPicker = true
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
selectedImage != nil || currentValue.hasPrefix("uploads/")
|
||||||
|
? "Replace Photo" : "Attach Photo",
|
||||||
|
systemImage: "camera"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPicker) {
|
||||||
|
ImagePickerView(image: $selectedImage) { img in
|
||||||
|
saveAndCallback(img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveAndCallback(_ img: UIImage) {
|
||||||
|
guard let data = img.jpegData(compressionQuality: 0.8) else { return }
|
||||||
|
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: photosDir,
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
let filename = "\(UUID().uuidString).jpg"
|
||||||
|
let fileURL = photosDir.appendingPathComponent(filename)
|
||||||
|
try? data.write(to: fileURL)
|
||||||
|
selectedImage = img
|
||||||
|
onPhotoSelected?(fileURL.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ImagePickerView
|
||||||
|
|
||||||
|
struct ImagePickerView: UIViewControllerRepresentable {
|
||||||
|
@Binding var image: UIImage?
|
||||||
|
var onSelected: (UIImage) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
|
||||||
|
? .camera : .photoLibrary
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ vc: UIImagePickerController, context: Context) {}
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
let parent: ImagePickerView
|
||||||
|
init(_ parent: ImagePickerView) { self.parent = parent }
|
||||||
|
|
||||||
|
func imagePickerController(
|
||||||
|
_ picker: UIImagePickerController,
|
||||||
|
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
||||||
|
) {
|
||||||
|
if let img = info[.originalImage] as? UIImage {
|
||||||
|
parent.image = img
|
||||||
|
parent.onSelected(img)
|
||||||
|
}
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TableFieldView
|
||||||
|
|
||||||
|
struct TableFieldView: View {
|
||||||
|
let field: [String: Any]
|
||||||
|
@Binding var value: String // JSON: [[String: String]]
|
||||||
|
|
||||||
|
private var columns: [String] {
|
||||||
|
field["col_headers"] as? [String] ?? ["Column 1"]
|
||||||
|
}
|
||||||
|
private var rowCount: Int {
|
||||||
|
field["table_rows"] as? Int ?? 3
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tableData: [[String: String]] {
|
||||||
|
get {
|
||||||
|
guard let data = value.data(using: .utf8),
|
||||||
|
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]]
|
||||||
|
else {
|
||||||
|
// Initialize empty table
|
||||||
|
return Array(repeating: Dictionary(uniqueKeysWithValues: columns.map { ($0, "") }),
|
||||||
|
count: rowCount)
|
||||||
|
}
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCell(row: Int, col: String, newValue: String) {
|
||||||
|
var table = tableData
|
||||||
|
while table.count <= row {
|
||||||
|
table.append(Dictionary(uniqueKeysWithValues: columns.map { ($0, "") }))
|
||||||
|
}
|
||||||
|
table[row][col] = newValue
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: table),
|
||||||
|
let str = String(data: data, encoding: .utf8) {
|
||||||
|
value = str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 4) {
|
||||||
|
// Header row
|
||||||
|
GridRow {
|
||||||
|
ForEach(columns, id: \.self) { col in
|
||||||
|
Text(col)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
ForEach(0..<rowCount, id: \.self) { rowIdx in
|
||||||
|
GridRow {
|
||||||
|
ForEach(columns, id: \.self) { col in
|
||||||
|
let cellValue = tableData.indices.contains(rowIdx)
|
||||||
|
? tableData[rowIdx][col] ?? "" : ""
|
||||||
|
TextField("", text: Binding(
|
||||||
|
get: { cellValue },
|
||||||
|
set: { updateCell(row: rowIdx, col: col, newValue: $0) }
|
||||||
|
))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(minWidth: 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Views/Inspection/StartInspectionView.swift
|
||||||
|
// ------------------------------------------
|
||||||
|
// Screen where the inspector chooses a template, facility, and optional area
|
||||||
|
// before starting a new inspection. Creates the LocalInspection record
|
||||||
|
// immediately so the form can be resumed if the app is backgrounded.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct StartInspectionView: View {
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
||||||
|
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
||||||
|
|
||||||
|
@State private var selectedTemplateId: Int?
|
||||||
|
@State private var selectedFacilityId: Int?
|
||||||
|
@State private var selectedAreaId: Int?
|
||||||
|
@State private var navigateToExecution = false
|
||||||
|
@State private var createdInspection: LocalInspection?
|
||||||
|
|
||||||
|
@EnvironmentObject private var auth: AuthManager
|
||||||
|
|
||||||
|
private var selectedFacility: LocalFacility? {
|
||||||
|
facilities.first { $0.serverId == selectedFacilityId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var areas: [LocalArea] {
|
||||||
|
selectedFacility?.areas.sorted { $0.name < $1.name } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canStart: Bool {
|
||||||
|
selectedTemplateId != nil && selectedFacilityId != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// ── Template picker ────────────────────────────────────────
|
||||||
|
Section("Inspection Template") {
|
||||||
|
if templates.isEmpty {
|
||||||
|
Text("No templates available. Sync required.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.callout)
|
||||||
|
} else {
|
||||||
|
Picker("Template", selection: $selectedTemplateId) {
|
||||||
|
Text("Select a template…").tag(Optional<Int>(nil))
|
||||||
|
ForEach(templates) { template in
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(template.name)
|
||||||
|
if !template.frequency.isEmpty {
|
||||||
|
Text(template.frequencyLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tag(Optional(template.serverId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Facility picker ────────────────────────────────────────
|
||||||
|
Section("Facility") {
|
||||||
|
if facilities.isEmpty {
|
||||||
|
Text("No facilities available. Sync required.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.callout)
|
||||||
|
} else {
|
||||||
|
Picker("Facility", selection: $selectedFacilityId) {
|
||||||
|
Text("Select a facility…").tag(Optional<Int>(nil))
|
||||||
|
ForEach(facilities) { facility in
|
||||||
|
Text(facility.name).tag(Optional(facility.serverId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
.onChange(of: selectedFacilityId) {
|
||||||
|
selectedAreaId = nil // reset area when facility changes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Area picker (optional) ─────────────────────────────────
|
||||||
|
if selectedFacilityId != nil {
|
||||||
|
Section("Area (Optional)") {
|
||||||
|
Picker("Area", selection: $selectedAreaId) {
|
||||||
|
Text("No specific area").tag(Optional<Int>(nil))
|
||||||
|
ForEach(areas) { area in
|
||||||
|
Text(area.name).tag(Optional(area.serverId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
.disabled(areas.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start button ───────────────────────────────────────────
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
startInspection()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Label("Start Inspection", systemImage: "play.circle.fill")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!canStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Inspection")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $navigateToExecution) {
|
||||||
|
if let inspection = createdInspection {
|
||||||
|
ExecuteInspectionView(inspection: inspection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startInspection() {
|
||||||
|
guard let templateId = selectedTemplateId,
|
||||||
|
let facilityId = selectedFacilityId
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let inspection = LocalInspection(
|
||||||
|
templateServerId: templateId,
|
||||||
|
facilityServerId: facilityId,
|
||||||
|
areaServerId: selectedAreaId,
|
||||||
|
inspectorUserId: auth.currentUserId
|
||||||
|
)
|
||||||
|
context.insert(inspection)
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
createdInspection = inspection
|
||||||
|
navigateToExecution = true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user