diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 5cacc73..52cab9e 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -115,13 +115,22 @@ actor APIClient { func fetchInspectionHistory( limit: Int = 50, offset: Int = 0, - facilityId: Int? = nil + facilityId: Int? = nil, + fromDate: Date? = nil, + toDate: Date? = nil ) async throws -> InspectionHistoryResponseData { var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed" if let fid = facilityId { ep += "&facility_id=\(fid)" } + if let d = fromDate { ep += "&from_date=\(Self.apiDateFmt.string(from: d))" } + if let d = toDate { ep += "&to_date=\(Self.apiDateFmt.string(from: d))" } return try await request(ep) } + private static let apiDateFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + // ── Photo Upload ────────────────────────────────────────────────────── func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String { diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 7b9ff2c..1fe537e 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -226,6 +226,11 @@ class SyncManager: ObservableObject { // Fetch dashboard KPIs — best-effort, non-fatal on failure. await fetchDashboardStats() + // Periodically remove local photo files that are no longer needed. + // Runs at most once per hour to avoid repeated FileManager calls on + // every 60-second sync cycle. + cleanupOrphanedPhotos(context: context) + updatePendingCount(context: context) lastSyncAt = Date() } @@ -512,6 +517,71 @@ class SyncManager: ObservableObject { // ── Pending Count ───────────────────────────────────────────────────── + // ── Orphaned Photo Cleanup ──────────────────────────────────────────── + // Removes local JPEG files from Documents/JQCPhotos/ that are no longer + // referenced by any LocalInspection, LocalIssue, or PendingPhoto record. + // Once an inspection or issue is fully synced its local photos are no + // longer needed — the server holds the canonical copies. Without this, + // weeks of inspections accumulate hundreds of MBs of orphaned files. + // + // Throttled to once per hour via UserDefaults to avoid redundant + // FileManager enumeration on every 60-second sync cycle. + + private static let lastCleanupKey = "jqc.photoCleanup.lastRunAt" + private static let cleanupInterval: TimeInterval = 3600 // 1 hour + + private func cleanupOrphanedPhotos(context: ModelContext) { + let last = UserDefaults.standard.object(forKey: Self.lastCleanupKey) as? Date + guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return } + UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey) + + let fm = FileManager.default + guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + let photosDir = docsDir.appendingPathComponent("JQCPhotos") + guard let diskFiles = try? fm.contentsOfDirectory( + at: photosDir, includingPropertiesForKeys: nil + ) else { return } + + // Collect all local paths that are still in use. + var referencedPaths = Set() + + // PendingPhoto — not yet uploaded + if let pendingPhotos = try? context.fetch(FetchDescriptor()) { + for p in pendingPhotos where p.uploadStatus != "uploaded" { + referencedPaths.insert(p.localFilePath) + } + } + // LocalInspection — draft photos (formData values starting with "local://") + if let inspections = try? context.fetch(FetchDescriptor()) { + for insp in inspections where insp.status == "draft" { + for val in insp.formData.values { + if let s = val as? String, s.hasPrefix("local://") { + referencedPaths.insert(String(s.dropFirst("local://".count))) + } + } + } + } + // LocalIssue — unsync'd issue photos + if let issues = try? context.fetch(FetchDescriptor()) { + for issue in issues where issue.syncStatus != "synced" { + for path in issue.photoLocalPaths { referencedPaths.insert(path) } + } + } + + // Delete any disk file not in referencedPaths + var deletedCount = 0 + for fileURL in diskFiles { + let path = fileURL.path + if !referencedPaths.contains(path) { + try? fm.removeItem(at: fileURL) + deletedCount += 1 + } + } + if deletedCount > 0 { + print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)") + } + } + func updatePendingCount(context: ModelContext) { let inspCount = (try? context.fetch(FetchDescriptor()))? .filter { $0.syncStatus == "pending" }.count ?? 0 diff --git a/JanitorialQC/Utils/InspectionPDFGenerator.swift b/JanitorialQC/Utils/InspectionPDFGenerator.swift index c226af0..2bd9661 100644 --- a/JanitorialQC/Utils/InspectionPDFGenerator.swift +++ b/JanitorialQC/Utils/InspectionPDFGenerator.swift @@ -38,7 +38,8 @@ private let kImgJPEGQuality: CGFloat = 0.55 /// kImgJPEGQuality, returning a fresh UIImage built from the compressed /// bytes. Applied to every photo before it's embedded in the PDF — this is /// the main lever for keeping file size minimal. -private func compress(_ image: UIImage) -> UIImage? { +/// nonisolated: called from inside a TaskGroup (concurrent), not @MainActor. +private nonisolated func compress(_ image: UIImage) -> UIImage? { let size = image.size guard size.width > 0, size.height > 0 else { return nil } let scale = min(kImgMaxPx / size.width, kImgMaxPx / size.height, 1.0) diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index c111c2c..40de00c 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -256,10 +256,25 @@ struct DashboardStatsView: View { @EnvironmentObject private var sync: SyncManager + // Draft inspections — shown as a resume banner at the top of the dashboard + // so the inspector never has to hunt through My Inspections to find an + // in-progress form they left open. + @Query( + filter: #Predicate { $0.status == "draft" }, + sort: \LocalInspection.lastModifiedAt, + order: .reverse + ) private var draftInspections: [LocalInspection] + @Environment(\.modelContext) private var context + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { + // ── Draft Resume Banner ──────────────────────────────────── + if !draftInspections.isEmpty { + DraftResumeBanner(drafts: draftInspections, context: context) + } + if let stats = sync.dashboardStats { // ── Today ────────────────────────────────────────────── statsSection(title: "Today") { @@ -446,6 +461,83 @@ struct DashboardStatsView: View { } } +// MARK: - Draft Resume Banner +// Shown on the dashboard when the inspector has one or more in-progress +// (draft) inspections. Tapping a draft opens ExecuteInspectionView as a +// full-screen sheet — avoids cross-NavigationStack linking since the +// dashboard and My Inspections stacks are independent. + +struct DraftResumeBanner: View { + let drafts: [LocalInspection] + let context: ModelContext + @State private var selectedDraft: LocalInspection? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(drafts.count == 1 + ? "Inspection in progress" + : "\(drafts.count) inspections in progress", + systemImage: "pencil.and.list.clipboard") + .font(.subheadline.bold()) + .foregroundStyle(.white) + + ForEach(drafts) { draft in + Button { + selectedDraft = draft + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(templateName(for: draft)) + .font(.callout.bold()) + .foregroundStyle(.white) + Text(facilityName(for: draft)) + .font(.caption) + .foregroundStyle(.white.opacity(0.85)) + Text("Last saved \(draft.lastModifiedAt.formatted(.relative(presentation: .named)))") + .font(.caption2) + .foregroundStyle(.white.opacity(0.70)) + } + Spacer() + Label("Resume", systemImage: "play.fill") + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Color.white.opacity(0.25)) + .clipShape(Capsule()) + } + .padding(10) + .background(Color.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + .padding(14) + .background(Color.blue.gradient) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .fullScreenCover(item: $selectedDraft) { draft in + // Wrap in NavigationStack so ExecuteInspectionView's toolbar + // and dismiss work correctly when presented as a sheet. + NavigationStack { + ExecuteInspectionView(inspection: draft) + } + } + } + + private func templateName(for inspection: LocalInspection) -> String { + let id = inspection.templateServerId + return (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Inspection" + } + + private func facilityName(for inspection: LocalInspection) -> String { + let id = inspection.facilityServerId + let all = (try? context.fetch(FetchDescriptor())) ?? [] + return all.first(where: { $0.serverId == id })?.name ?? "Unknown Facility" + } +} + // MARK: - My Inspections struct MyInspectionsView: View { diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 25b6c0b..9b5a1f5 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -23,12 +23,13 @@ struct ExecuteInspectionView: View { let inspection: LocalInspection @State private var formValues: [String: String] = [:] - @State private var showFlagIssue = false - @State private var showSubmitAlert = false - @State private var showNoGPSAlert = false + @State private var showFlagIssue = false + @State private var showSubmitAlert = false + @State private var showNoGPSAlert = false @State private var showValidationAlert = false @State private var missingFields: [String] = [] - @State private var isSaving = false + @State private var isSaving = false + @State private var autoSavedAt: Date? = nil // drives the auto-save toast @State private var isSubmitting = false @State private var submitResult: SubmitResult? @@ -100,6 +101,10 @@ struct ExecuteInspectionView: View { while !Task.isCancelled { try? await Task.sleep(for: .seconds(autoSaveInterval)) saveDraft() + // Show "Draft saved" toast and dismiss after 2 seconds + withAnimation { autoSavedAt = Date() } + try? await Task.sleep(for: .seconds(2)) + withAnimation { autoSavedAt = nil } } } .sheet(isPresented: $showFlagIssue) { @@ -149,7 +154,25 @@ struct ExecuteInspectionView: View { .zIndex(10) } } + // Auto-save toast — bottom of screen, fades out after 2 seconds + .overlay(alignment: .bottom) { + if autoSavedAt != nil { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Draft saved") + .font(.subheadline.bold()) + } + .padding(.horizontal, 16).padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + .shadow(color: .black.opacity(0.12), radius: 6, y: 2) + .padding(.bottom, 24) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(9) + } + } .animation(.spring(duration: 0.35), value: submitResult != nil) + .animation(.easeInOut(duration: 0.25), value: autoSavedAt) } // ── Form Content ────────────────────────────────────────────────────── diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index d5dca14..aaaf87e 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -18,6 +18,16 @@ struct InspectionHistoryView: View { private let limit = 30 @State private var searchText = "" + @State private var showFilterSheet = false + @State private var filterFromDate: Date? = nil + @State private var filterToDate: Date? = nil + // Draft values edited in the sheet before applying + @State private var draftFromDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() + @State private var draftToDate: Date = Date() + @State private var draftFromEnabled = false + @State private var draftToEnabled = false + + private var hasActiveFilter: Bool { filterFromDate != nil || filterToDate != nil } /// Date formatter for search matching — formats to e.g. "Jun 19, 2026 4:55 PM" /// so partial strings like "jun", "2026", "19" all match. @@ -121,6 +131,39 @@ struct InspectionHistoryView: View { .navigationTitle("Inspection History") .searchable(text: $searchText, prompt: "Search ID, date, facility, area…") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showFilterSheet = true + } label: { + Image(systemName: hasActiveFilter + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + .foregroundStyle(hasActiveFilter ? .orange : .primary) + } + } + } + .sheet(isPresented: $showFilterSheet) { + DateFilterSheet( + fromEnabled: $draftFromEnabled, + fromDate: $draftFromDate, + toEnabled: $draftToEnabled, + toDate: $draftToDate + ) { + // Apply — update active filters and reload from server + filterFromDate = draftFromEnabled ? draftFromDate : nil + filterToDate = draftToEnabled ? draftToDate : nil + showFilterSheet = false + Task { await load(reset: true) } + } onClear: { + draftFromEnabled = false + draftToEnabled = false + filterFromDate = nil + filterToDate = nil + showFilterSheet = false + Task { await load(reset: true) } + } + } .task { if sync.isOnline { await load(reset: true) @@ -141,7 +184,10 @@ struct InspectionHistoryView: View { do { let result = try await APIClient.shared.fetchInspectionHistory( - limit: limit, offset: reset ? 0 : offset + limit: limit, + offset: reset ? 0 : offset, + fromDate: filterFromDate, + toDate: filterToDate ) if reset { inspections = result.inspections @@ -160,6 +206,55 @@ struct InspectionHistoryView: View { } } +// MARK: - Date Filter Sheet + +struct DateFilterSheet: View { + @Binding var fromEnabled: Bool + @Binding var fromDate: Date + @Binding var toEnabled: Bool + @Binding var toDate: Date + let onApply: () -> Void + let onClear: () -> Void + + var body: some View { + NavigationStack { + Form { + Section("From Date") { + Toggle("Filter by start date", isOn: $fromEnabled) + if fromEnabled { + DatePicker("From", selection: $fromDate, + displayedComponents: .date) + .datePickerStyle(.compact) + } + } + Section("To Date") { + Toggle("Filter by end date", isOn: $toEnabled) + if toEnabled { + DatePicker("To", selection: $toDate, + displayedComponents: .date) + .datePickerStyle(.compact) + } + } + Section { + Button("Clear Filters", role: .destructive, action: onClear) + } + } + .navigationTitle("Date Filter") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { onClear() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Apply", action: onApply) + .bold() + } + } + } + .presentationDetents([.medium]) + } +} + // MARK: - History Row struct HistoryRowView: View {