diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 28abbb8..7892d5f 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -1,7 +1,7 @@ // API/APIClient.swift // ------------------- // Central HTTP client for all JQC API calls. -// Phase B adds: uploadPhoto(), submitInspection(), submitIssue() +// Phase C adds: fetchInspectionHistory() import Foundation import Combine @@ -99,10 +99,24 @@ actor APIClient { return try await request(endpoint, method: "POST", body: body) } + // ── Inspection History (Phase C) ────────────────────────────────────── + + /// Fetch the inspector's synced inspection history from the server. + /// Returns up to `limit` records starting at `offset`. + func fetchInspectionHistory( + limit: Int = 50, + offset: Int = 0, + facilityId: Int? = nil + ) async throws -> InspectionHistoryResponseData { + var endpoint = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed" + if let fid = facilityId { + endpoint += "&facility_id=\(fid)" + } + return try await request(endpoint) + } + // ── 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 @@ -115,12 +129,10 @@ actor APIClient { 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" @@ -171,8 +183,6 @@ actor APIClient { // ── 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, @@ -183,12 +193,8 @@ actor APIClient { "overall_score": inspection.overallScore as Any, ] - if let areaId = inspection.areaServerId { - body["area_id"] = areaId - } - if !inspection.inspectorNotes.isEmpty { - body["notes"] = inspection.inspectorNotes - } + 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) @@ -207,8 +213,6 @@ actor APIClient { // ── 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, @@ -217,12 +221,9 @@ actor APIClient { "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) { + if let inspServerId = issue.inspection?.serverId { body["inspection_id"] = inspServerId } - if let serverPhotoPath = issue.photoServerPath { body["photo_path"] = serverPhotoPath } diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index 890e678..01dbf29 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -40,11 +40,6 @@ struct APIUser: Decodable { 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 } } @@ -102,18 +97,44 @@ struct APITemplate: Decodable, Identifiable { let name: String let description: String let frequency: String - let formSchema: [[String: AnyDecodable]] // Dynamic JSON fields + let formSchema: [[String: AnyDecodable]] +} + +// ── Inspections (Phase C — history) ────────────────────────────────────────── + +struct InspectionHistoryResponseData: Decodable { + let inspections: [APIInspectionSummary] + let total: Int + let limit: Int + let offset: Int +} + +struct APIInspectionSummary: Decodable, Identifiable { + let id: Int + let templateId: Int + let templateName: String + let facilityId: Int + let facilityName: String + let areaId: Int? + let areaName: String? + let status: String + let overallScore: Double? + let inspectionDate: String? + let completedAt: String? + let mobileLocalId: String? + + var inspectionDateParsed: Date? { + guard let str = inspectionDate else { return nil } + return ISO8601DateFormatter().date(from: str) + } } // ── 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(_ value: Any) { self.value = value } init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() diff --git a/JanitorialQC/Info.plist b/JanitorialQC/Info.plist new file mode 100644 index 0000000..e2e8562 --- /dev/null +++ b/JanitorialQC/Info.plist @@ -0,0 +1,23 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + com.jqc.sync + + Background Modes + + CFBundleDocumentTypes + + + LSHandlerRank + Default + + + UIBackgroundModes + + processing + + + diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index 0d92da8..43bb301 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -1,17 +1,21 @@ -// JanitorialQC.swift +// JQCApp.swift // ------------ -// App entry point. Phase B adds LocalInspection, LocalIssue, -// PendingPhoto, and SyncQueueEntry to the SwiftData model container. +// App entry point. Phase C adds BGTaskScheduler for background sync. import SwiftUI import SwiftData +import BackgroundTasks @main -struct JanitorialQC: App { +struct JanitorialQCApp: App { @StateObject private var auth = AuthManager.shared @StateObject private var sync = SyncManager.shared + init() { + registerBackgroundTasks() + } + var body: some Scene { WindowGroup { ContentView() @@ -44,4 +48,49 @@ struct JanitorialQC: App { } } } + + // ── Background Tasks ────────────────────────────────────────────────── + + /// Register the background sync task with the OS. + /// The identifier must match BGTaskSchedulerPermittedIdentifiers in Info.plist. + private func registerBackgroundTasks() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.jqc.sync", + using: nil + ) { task in + guard let processingTask = task as? BGProcessingTask else { + task.setTaskCompleted(success: false) + return + } + handleBackgroundSync(task: processingTask) + } + } + + private func handleBackgroundSync(task: BGProcessingTask) { + // Schedule the next background run immediately + scheduleBackgroundSync() + + let syncTask = Task { + await SyncManager.shared.triggerSync() + } + + // If the OS needs to cancel early, cancel our work + task.expirationHandler = { + syncTask.cancel() + } + + Task { + await syncTask.value + task.setTaskCompleted(success: !syncTask.isCancelled) + } + } +} + +/// Schedule a background processing task. +/// Call this from sceneDidEnterBackground or after each foreground sync. +func scheduleBackgroundSync() { + let request = BGProcessingTaskRequest(identifier: "com.jqc.sync") + request.requiresNetworkConnectivity = true + request.requiresExternalPower = false + try? BGTaskScheduler.shared.submit(request) } diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 70b04e5..63d2ce8 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -1,6 +1,7 @@ // Views/Dashboard/DashboardView.swift // ------------------------------------ -// Phase B: adds My Inspections list and Pending Sync status to the sidebar. +// Phase C: adds Inspection History tab, polished Settings with cache clear, +// and schedules background sync on scene enter background. import SwiftUI import SwiftData @@ -11,9 +12,8 @@ struct DashboardView: View { @EnvironmentObject private var auth: AuthManager @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context + @Environment(\.scenePhase) private var scenePhase - @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, @@ -25,8 +25,8 @@ struct DashboardView: View { var body: some View { NavigationSplitView { - // ── Sidebar ──────────────────────────────────────────────────── List { + // My Inspections Button { selectedTab = 0 } label: { HStack { Label("My Inspections", systemImage: "checklist") @@ -35,8 +35,7 @@ struct DashboardView: View { if !myInspections.isEmpty { Text("\(myInspections.count)") .font(.caption2) - .padding(.horizontal, 6) - .padding(.vertical, 2) + .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.blue.opacity(0.15)) .clipShape(Capsule()) } @@ -44,63 +43,70 @@ struct DashboardView: View { } .listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear) + // History Button { selectedTab = 1 } label: { - Label("Facilities", systemImage: "building.2") + Label("History", systemImage: "clock.arrow.circlepath") .foregroundStyle(selectedTab == 1 ? .blue : .primary) } .listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear) + // Facilities Button { selectedTab = 2 } label: { - Label("Templates", systemImage: "doc.text") + Label("Facilities", systemImage: "building.2") .foregroundStyle(selectedTab == 2 ? .blue : .primary) } .listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear) + // Templates Button { selectedTab = 3 } label: { + Label("Templates", systemImage: "doc.text") + .foregroundStyle(selectedTab == 3 ? .blue : .primary) + } + .listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear) + + // Pending Sync + Button { selectedTab = 4 } label: { HStack { Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") - .foregroundStyle(selectedTab == 3 ? .blue : .primary) + .foregroundStyle(selectedTab == 4 ? .blue : .primary) Spacer() if sync.pendingCount > 0 { Text("\(sync.pendingCount)") .font(.caption2) - .padding(.horizontal, 6) - .padding(.vertical, 2) + .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) + + // Settings + Button { selectedTab = 5 } label: { + Label("Settings", systemImage: "gear") + .foregroundStyle(selectedTab == 5 ? .blue : .primary) + } + .listRowBackground(selectedTab == 5 ? Color.blue.opacity(0.1) : Color.clear) } .navigationTitle("JQC Inspector") .listStyle(.sidebar) .toolbar { ToolbarItem(placement: .primaryAction) { - Button { - showNewInspection = true - } label: { + Button { showNewInspection = true } label: { Image(systemName: "plus") } } } - .safeAreaInset(edge: .bottom) { - syncStatusFooter - } + .safeAreaInset(edge: .bottom) { syncStatusFooter } } detail: { switch selectedTab { case 0: MyInspectionsView() - case 1: FacilitiesListView() - case 2: TemplatesListView() - case 3: SyncStatusView() + case 1: InspectionHistoryView() + case 2: FacilitiesListView() + case 3: TemplatesListView() + case 4: SyncStatusView() default: SettingsView() } } @@ -114,10 +120,14 @@ struct DashboardView: View { sync.updatePendingCount(context: context) } } + // Schedule background sync when app is backgrounded + .onChange(of: scenePhase) { + if scenePhase == .background { + scheduleBackgroundSync() + } + } } - // ── Sync Status Footer ───────────────────────────────────────────────── - private var syncStatusFooter: some View { VStack(spacing: 0) { Divider() @@ -130,8 +140,7 @@ struct DashboardView: View { .foregroundStyle(.secondary) Spacer() if sync.isSyncing { - ProgressView() - .scaleEffect(0.7) + ProgressView().scaleEffect(0.7) } else if let lastSync = sync.lastSyncAt { Text("Synced \(lastSync.formatted(.relative(presentation: .named)))") .font(.caption2) @@ -203,23 +212,18 @@ struct InspectionRowView: View { var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { - Text(templateName) - .font(.headline) + Text(templateName).font(.headline) Spacer() StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) } - Text(facilityName) - .font(.callout) - .foregroundStyle(.secondary) + Text(facilityName).font(.callout).foregroundStyle(.secondary) HStack { Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2) - .foregroundStyle(.tertiary) + .font(.caption2).foregroundStyle(.tertiary) if let score = inspection.overallScore { Spacer() Text(String(format: "%.1f%%", score)) - .font(.caption) - .fontWeight(.medium) + .font(.caption).fontWeight(.medium) .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) } } @@ -234,10 +238,10 @@ struct StatusBadge: View { 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 + case "draft": return "Draft" + case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed" + case "failed": return "Sync Failed" + default: return status.capitalized } } @@ -245,7 +249,7 @@ struct StatusBadge: View { switch status { case "draft": return .blue case "completed": return syncStatus == "pending" ? .orange : .green - case "sync_failed": return .red + case "failed": return .red default: return .secondary } } @@ -253,15 +257,14 @@ struct StatusBadge: View { var body: some View { Text(label) .font(.caption2) - .padding(.horizontal, 8) - .padding(.vertical, 3) + .padding(.horizontal, 8).padding(.vertical, 3) .background(color.opacity(0.15)) .foregroundStyle(color) .clipShape(Capsule()) } } -// MARK: - Completed Inspection (read-only view) +// MARK: - Completed Inspection (read-only) struct CompletedInspectionView: View { let inspection: LocalInspection @@ -277,14 +280,11 @@ struct CompletedInspectionView: View { 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) + Text("Overall Score").font(.subheadline).foregroundStyle(.secondary) Spacer() Text(String(format: "%.1f%%", score)) .font(.title2.bold()) @@ -293,51 +293,39 @@ struct CompletedInspectionView: View { } if let completedAt = inspection.completedAt { HStack { - Text("Completed") - .font(.subheadline) - .foregroundStyle(.secondary) + Text("Completed").font(.subheadline).foregroundStyle(.secondary) Spacer() Text(completedAt.formatted(date: .abbreviated, time: .shortened)) .font(.callout) } } HStack { - Text("Sync Status") - .font(.subheadline) - .foregroundStyle(.secondary) + Text("Sync Status").font(.subheadline).foregroundStyle(.secondary) Spacer() - StatusBadge(status: inspection.status, - syncStatus: inspection.syncStatus) + StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) } if let error = inspection.syncErrorMessage { - Text("Error: \(error)") - .font(.caption) - .foregroundStyle(.red) + 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) + .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) + .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) + .font(.caption.bold()).foregroundStyle(.secondary) + Text(issue.issueDescription).font(.callout) } } .padding(.horizontal) @@ -355,7 +343,6 @@ struct CompletedInspectionView: View { // MARK: - Sync Status View struct SyncStatusView: View { - @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context @@ -373,8 +360,7 @@ struct SyncStatusView: View { List { Section("Status") { HStack { - Circle() - .fill(sync.isOnline ? Color.green : Color.orange) + Circle().fill(sync.isOnline ? Color.green : Color.orange) .frame(width: 8, height: 8) Text(sync.isOnline ? "Online" : "Offline") } @@ -385,8 +371,7 @@ struct SyncStatusView: View { if sync.isSyncing { HStack { ProgressView() - Text("Syncing…") - .foregroundStyle(.secondary) + Text("Syncing…").foregroundStyle(.secondary) } } if let error = sync.syncError { @@ -402,14 +387,10 @@ struct SyncStatusView: View { 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 - ) + ForEach(pendingInspections) { insp in + SyncRowView(title: "Inspection", status: insp.syncStatus, + retryCount: insp.syncRetryCount, + error: insp.syncErrorMessage, date: insp.createdAt) } } } @@ -417,13 +398,9 @@ struct SyncStatusView: View { 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 - ) + SyncRowView(title: "\(issue.severity.capitalized) Issue", + status: issue.syncStatus, retryCount: issue.syncRetryCount, + error: issue.syncErrorMessage, date: issue.createdAt) } } } @@ -451,30 +428,24 @@ struct SyncRowView: View { HStack { Text(title).font(.callout) Spacer() - Text(status.capitalized) - .font(.caption2) + Text(status.capitalized).font(.caption2) .foregroundStyle(status == "failed" ? .red : .orange) } Text(date.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2) - .foregroundStyle(.tertiary) + .font(.caption2).foregroundStyle(.tertiary) if let err = error { - Text(err) - .font(.caption2) - .foregroundStyle(.red) - .lineLimit(2) + Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2) } if retryCount > 0 { Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")") - .font(.caption2) - .foregroundStyle(.secondary) + .font(.caption2).foregroundStyle(.secondary) } } .padding(.vertical, 2) } } -// MARK: - Facilities, Templates, Settings (unchanged from Phase A) +// MARK: - Facilities struct FacilitiesListView: View { @Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility] @@ -482,11 +453,8 @@ struct FacilitiesListView: View { 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.") - ) + 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) { @@ -508,17 +476,16 @@ struct FacilitiesListView: View { } } +// MARK: - Templates + 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.") - ) + 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) { @@ -545,9 +512,15 @@ struct TemplatesListView: View { } } +// MARK: - Settings + struct SettingsView: View { @EnvironmentObject private var auth: AuthManager @EnvironmentObject private var sync: SyncManager + @Environment(\.modelContext) private var context + + @State private var showClearCacheAlert = false + @State private var cacheCleared = false var body: some View { List { @@ -555,15 +528,43 @@ struct SettingsView: View { LabeledContent("Username", value: auth.currentUsername) LabeledContent("Role", value: auth.currentUserRole.capitalized) } + Section("Sync") { - Button { Task { await sync.triggerSync() } } label: { + 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) } + + if let lastSync = sync.lastSyncAt { + LabeledContent("Last Sync", + value: lastSync.formatted(date: .abbreviated, time: .shortened)) + } } + + Section("Cache") { + Button { + showClearCacheAlert = true + } label: { + Label("Clear Reference Cache", systemImage: "trash") + .foregroundStyle(.orange) + } + Text("Clears locally cached facilities, areas, and templates. Your pending inspections are not affected. Data will re-sync on the next connection.") + .font(.caption) + .foregroundStyle(.secondary) + + if cacheCleared { + Label("Cache cleared.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.callout) + } + } + Section { Button(role: .destructive) { Task { await auth.logout() } @@ -571,11 +572,37 @@ struct SettingsView: View { Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right") } } + Section("App Info") { - LabeledContent("Version", value: "Phase B") + LabeledContent("Version", value: "Phase C") LabeledContent("Server", value: Constants.baseURL) } } .navigationTitle("Settings") + .alert("Clear Reference Cache?", isPresented: $showClearCacheAlert) { + Button("Clear", role: .destructive) { clearCache() } + Button("Cancel", role: .cancel) {} + } message: { + Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.") + } + } + + private func clearCache() { + // Delete only reference data — never touch LocalInspection, LocalIssue, PendingPhoto + let facilities = (try? context.fetch(FetchDescriptor())) ?? [] + let templates = (try? context.fetch(FetchDescriptor())) ?? [] + let areas = (try? context.fetch(FetchDescriptor())) ?? [] + + facilities.forEach { context.delete($0) } + templates.forEach { context.delete($0) } + areas.forEach { context.delete($0) } + + try? context.save() + cacheCleared = true + + // Re-pull immediately if online + if sync.isOnline { + Task { await sync.pullReferenceData() } + } } } diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift new file mode 100644 index 0000000..c32f269 --- /dev/null +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -0,0 +1,183 @@ +// Views/Inspection/InspectionHistoryView.swift +// -------------------------------------------- +// Shows the inspector's synced inspection history fetched from the server. +// Only available when online. Displays score, facility, template, and date. + +import SwiftUI + +struct InspectionHistoryView: View { + + @EnvironmentObject private var sync: SyncManager + @State private var inspections: [APIInspectionSummary] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var total = 0 + @State private var offset = 0 + private let limit = 30 + + var body: some View { + Group { + if !sync.isOnline && inspections.isEmpty { + ContentUnavailableView( + "Offline", + systemImage: "wifi.slash", + description: Text("Inspection history requires an internet connection.") + ) + } else if isLoading && inspections.isEmpty { + ProgressView("Loading history…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = errorMessage, inspections.isEmpty { + ContentUnavailableView( + "Could Not Load", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else if inspections.isEmpty { + ContentUnavailableView( + "No History", + systemImage: "clock.arrow.circlepath", + description: Text("Completed inspections will appear here after syncing.") + ) + } else { + List { + ForEach(inspections) { inspection in + HistoryRowView(inspection: inspection) + } + + // Load more + if inspections.count < total { + HStack { + Spacer() + Button("Load More") { + Task { await loadMore() } + } + .disabled(isLoading) + Spacer() + } + .listRowSeparator(.hidden) + } + + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .refreshable { + await load(reset: true) + } + } + } + .navigationTitle("Inspection History") + .task { + if sync.isOnline { + await load(reset: true) + } + } + .onChange(of: sync.isOnline) { + if sync.isOnline && inspections.isEmpty { + Task { await load(reset: true) } + } + } + } + + private func load(reset: Bool) async { + if reset { offset = 0 } + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let result = try await APIClient.shared.fetchInspectionHistory( + limit: limit, offset: reset ? 0 : offset + ) + if reset { + inspections = result.inspections + } else { + inspections.append(contentsOf: result.inspections) + } + total = result.total + offset = inspections.count + } catch { + errorMessage = error.localizedDescription + } + } + + private func loadMore() async { + await load(reset: false) + } +} + +// MARK: - History Row + +struct HistoryRowView: View { + let inspection: APIInspectionSummary + + private var scoreColor: Color { + guard let score = inspection.overallScore else { return .secondary } + return score >= 80 ? .green : score >= 60 ? .orange : .red + } + + private var dateText: String { + guard let date = inspection.inspectionDateParsed else { + return inspection.inspectionDate ?? "" + } + return date.formatted(date: .abbreviated, time: .shortened) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(inspection.templateName) + .font(.headline) + .lineLimit(1) + Text(inspection.facilityName) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + if let area = inspection.areaName { + Text(area) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + Spacer() + if let score = inspection.overallScore { + VStack(alignment: .trailing, spacing: 2) { + Text(String(format: "%.1f%%", score)) + .font(.title3.bold()) + .foregroundStyle(scoreColor) + Text("Score") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + HStack { + Image(systemName: "calendar") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(dateText) + .font(.caption2) + .foregroundStyle(.tertiary) + Spacer() + // Sync origin badge + if inspection.mobileLocalId != nil { + Label("Mobile", systemImage: "ipad") + .font(.caption2) + .foregroundStyle(.blue) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.1)) + .clipShape(Capsule()) + } + } + } + .padding(.vertical, 4) + } +}