05/02 Phase B
This commit is contained in:
@@ -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
|
||||
// JanitorialQC
|
||||
//
|
||||
// Created by Nguyen Ngo on 5/2/26.
|
||||
//
|
||||
// ContentView.swift
|
||||
// -----------------
|
||||
// Root view. Shows LoginView or DashboardView based on auth state.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var items: [Item]
|
||||
@EnvironmentObject private var auth: AuthManager
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||
} label: {
|
||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||
}
|
||||
Group {
|
||||
if auth.isLoading && !auth.isAuthenticated {
|
||||
// Splash / loading state on app launch
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.blue)
|
||||
ProgressView("Loading…")
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
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])
|
||||
} else if auth.isAuthenticated {
|
||||
DashboardView()
|
||||
} else {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: auth.isAuthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self, inMemory: true)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
//
|
||||
// JanitorialQCApp.swift
|
||||
// JanitorialQC
|
||||
//
|
||||
// Created by Nguyen Ngo on 5/2/26.
|
||||
//
|
||||
// JanitorialQC.swift
|
||||
// ------------
|
||||
// App entry point. Phase B adds LocalInspection, LocalIssue,
|
||||
// PendingPhoto, and SyncQueueEntry to the SwiftData model container.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct JanitorialQCApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Item.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
struct JanitorialQC: App {
|
||||
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
@StateObject private var auth = AuthManager.shared
|
||||
@StateObject private var sync = SyncManager.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
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