diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 5fb7e7f..c61bf61 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -400,11 +400,14 @@ DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -413,6 +416,10 @@ PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; @@ -432,11 +439,14 @@ DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -445,6 +455,10 @@ PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift new file mode 100644 index 0000000..28abbb8 --- /dev/null +++ b/JanitorialQC/API/APIClient.swift @@ -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( + _ 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.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(_ 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.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.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 + } +} diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift new file mode 100644 index 0000000..890e678 --- /dev/null +++ b/JanitorialQC/API/APIModels.swift @@ -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: 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() + } +} diff --git a/JanitorialQC/Auth/AuthManager.swift b/JanitorialQC/Auth/AuthManager.swift new file mode 100644 index 0000000..676b863 --- /dev/null +++ b/JanitorialQC/Auth/AuthManager.swift @@ -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 {} diff --git a/JanitorialQC/Auth/KeychainHelper.swift b/JanitorialQC/Auth/KeychainHelper.swift new file mode 100644 index 0000000..00d80a7 --- /dev/null +++ b/JanitorialQC/Auth/KeychainHelper.swift @@ -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) } + } +} diff --git a/JanitorialQC/ContentView.swift b/JanitorialQC/ContentView.swift index 0fd4c1b..1c691a6 100644 --- a/JanitorialQC/ContentView.swift +++ b/JanitorialQC/ContentView.swift @@ -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) -} diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index 7e5cb27..0d92da8 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -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) } } diff --git a/JanitorialQC/Models/LocalArea.swift b/JanitorialQC/Models/LocalArea.swift new file mode 100644 index 0000000..86008dd --- /dev/null +++ b/JanitorialQC/Models/LocalArea.swift @@ -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" + } + } +} diff --git a/JanitorialQC/Models/LocalFacility.swift b/JanitorialQC/Models/LocalFacility.swift new file mode 100644 index 0000000..788ce55 --- /dev/null +++ b/JanitorialQC/Models/LocalFacility.swift @@ -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() + } +} diff --git a/JanitorialQC/Models/LocalInspection.swift b/JanitorialQC/Models/LocalInspection.swift new file mode 100644 index 0000000..d165b01 --- /dev/null +++ b/JanitorialQC/Models/LocalInspection.swift @@ -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 + } +} diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift new file mode 100644 index 0000000..4fda946 --- /dev/null +++ b/JanitorialQC/Models/LocalIssue.swift @@ -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" + } + } +} diff --git a/JanitorialQC/Models/LocalTemplate.swift b/JanitorialQC/Models/LocalTemplate.swift new file mode 100644 index 0000000..73e0309 --- /dev/null +++ b/JanitorialQC/Models/LocalTemplate.swift @@ -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 + } + } +} diff --git a/JanitorialQC/Models/PendingPhoto.swift b/JanitorialQC/Models/PendingPhoto.swift new file mode 100644 index 0000000..0923441 --- /dev/null +++ b/JanitorialQC/Models/PendingPhoto.swift @@ -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() + } +} diff --git a/JanitorialQC/Models/SyncQueueEntry.swift b/JanitorialQC/Models/SyncQueueEntry.swift new file mode 100644 index 0000000..a93a8d1 --- /dev/null +++ b/JanitorialQC/Models/SyncQueueEntry.swift @@ -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 + } +} diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift new file mode 100644 index 0000000..cea58b8 --- /dev/null +++ b/JanitorialQC/Sync/SyncManager.swift @@ -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()) 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( + 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( + 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()) 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()) 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()) + 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()) + 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()))? + .filter { $0.syncStatus == "pending" }.count ?? 0 + let issueCount = (try? context.fetch(FetchDescriptor()))? + .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()))? + .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()))? + .first { $0.serverId == id }? + .updateSchema(from: detailData.template) + } + } +} diff --git a/JanitorialQC/Utils/Constants.swift b/JanitorialQC/Utils/Constants.swift new file mode 100644 index 0000000..d602bf0 --- /dev/null +++ b/JanitorialQC/Utils/Constants.swift @@ -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 +} diff --git a/JanitorialQC/Views/Auth/LoginView.swift b/JanitorialQC/Views/Auth/LoginView.swift new file mode 100644 index 0000000..316fa9b --- /dev/null +++ b/JanitorialQC/Views/Auth/LoginView.swift @@ -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) +} diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..70b04e5 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -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 { $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 { $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(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Unknown Facility" + } + + private var templateName: String { + let id = inspection.templateServerId + return (try? context.fetch( + FetchDescriptor(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(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 { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, + sort: \LocalInspection.createdAt + ) private var pendingInspections: [LocalInspection] + + @Query( + filter: #Predicate { $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") + } +} diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift new file mode 100644 index 0000000..862e683 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -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(predicate: #Predicate { $0.serverId == id }) + ).first + } + + private var facility: LocalFacility? { + let id = inspection.facilityServerId + return try? context.fetch( + FetchDescriptor(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) + } + } +} diff --git a/JanitorialQC/Views/Dashboard/FlagIssueView.swift b/JanitorialQC/Views/Dashboard/FlagIssueView.swift new file mode 100644 index 0000000..7dc0ef0 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/FlagIssueView.swift @@ -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( + 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(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() + } +} diff --git a/JanitorialQC/Views/Dashboard/FormFieldView.swift b/JanitorialQC/Views/Dashboard/FormFieldView.swift new file mode 100644 index 0000000..b70508d --- /dev/null +++ b/JanitorialQC/Views/Dashboard/FormFieldView.swift @@ -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 { + 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 { + 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 + init(value: Binding) { 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..(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(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(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 + } +}