diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 1a22f1a..7edcaf3 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -411,7 +411,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -431,7 +431,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -454,7 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = SB7DNYC9TY; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -474,7 +474,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 6f8a1a2..c111c2c 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -773,6 +773,10 @@ struct SyncStatusView: View { sort: \LocalIssue.createdAt ) private var pendingIssues: [LocalIssue] + private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } } + private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } } + private var hasFailedItems: Bool { !failedInspections.isEmpty || !failedIssues.isEmpty } + var body: some View { List { Section("Status") { @@ -800,6 +804,22 @@ struct SyncStatusView: View { Label("Sync Now", systemImage: "arrow.clockwise") } .disabled(!sync.isOnline || sync.isSyncing) + + // Retry Failed Items — resets syncStatus back to "pending" so + // the next triggerSync() will re-attempt them. Once an item + // reaches syncStatus = "failed" (after 5 consecutive errors) + // triggerSync() stops picking it up — this is the only way + // to re-queue it without manual server intervention. + if hasFailedItems { + Button { + retryAllFailed() + } label: { + Label("Retry Failed Items (\(failedInspections.count + failedIssues.count))", + systemImage: "exclamationmark.arrow.circlepath") + .foregroundStyle(.orange) + } + .disabled(!sync.isOnline || sync.isSyncing) + } } if !pendingInspections.isEmpty { @@ -831,6 +851,25 @@ struct SyncStatusView: View { } .navigationTitle("Pending Sync") } + + /// Reset all failed items back to pending so the next sync pass picks them up. + /// Also clears syncRetryCount and syncErrorMessage so the retry counter + /// starts fresh — prevents them immediately hitting the 5-retry cap again + /// without any actual new attempt. + private func retryAllFailed() { + for insp in failedInspections { + insp.syncStatus = "pending" + insp.syncRetryCount = 0 + insp.syncErrorMessage = nil + } + for issue in failedIssues { + issue.syncStatus = "pending" + issue.syncRetryCount = 0 + issue.syncErrorMessage = nil + } + try? context.save() + Task { await sync.triggerSync() } + } } struct SyncRowView: View { @@ -907,21 +946,64 @@ struct IssuesListView: View { order: .reverse ) private var allIssues: [LocalIssue] - private var issues: [LocalIssue] { - allIssues.filter { $0.issueStatus != "resolved" } + @Environment(\.modelContext) private var context + @State private var showNewIssue = false + @State private var searchText = "" + @State private var severityFilter: String? = nil // nil = all + + private let severities = ["critical", "high", "medium", "low"] + + /// Date formatter shared for search matching. + /// Formats to e.g. "Jun 19, 2026 4:55 PM" so inspectors can type + /// partial strings: "jun", "2026", "19", "4:55" all match. + private static let searchDateFmt: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + + private func dateString(for issue: LocalIssue) -> String { + let d = issue.serverReportedAt ?? issue.createdAt + return Self.searchDateFmt.string(from: d) } - @Environment(\.modelContext) private var context - @State private var showNewIssue = false + private var issues: [LocalIssue] { + var list = allIssues.filter { $0.issueStatus != "resolved" } + + if let sev = severityFilter { + list = list.filter { $0.severity == sev } + } + + if !searchText.isEmpty { + let q = searchText.lowercased() + list = list.filter { + // ID — match "#58" or bare "58" + let idStr = $0.serverId.map { String($0) } ?? "" + let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "") + + return idMatch + || $0.issueDescription.lowercased().contains(q) + || ($0.facilityNameCache?.lowercased().contains(q) ?? false) + || ($0.areaNameCache?.lowercased().contains(q) ?? false) + || ($0.assignedToName?.lowercased().contains(q) ?? false) + || dateString(for: $0).lowercased().contains(q) + } + } + + return list + } var body: some View { Group { - if issues.isEmpty { + if allIssues.filter({ $0.issueStatus != "resolved" }).isEmpty { ContentUnavailableView( "No Issues", systemImage: "exclamationmark.triangle", description: Text("Tap + to log a new issue, or flag one during an inspection.") ) + } else if issues.isEmpty { + ContentUnavailableView.search(text: searchText) } else { List(issues) { issue in NavigationLink(value: issue) { @@ -931,11 +1013,42 @@ struct IssuesListView: View { } } .navigationTitle("Issues (\(issues.count))") + .searchable(text: $searchText, + prompt: "Search ID, date, description, facility…") .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { showNewIssue = true } label: { - Image(systemName: "plus") + ToolbarItemGroup(placement: .primaryAction) { + // Severity filter + Menu { + Button { + severityFilter = nil + } label: { + Label("All Severities", + systemImage: severityFilter == nil ? "checkmark" : "line.3.horizontal.decrease") + } + Divider() + ForEach(severities, id: \.self) { sev in + Button { + severityFilter = (severityFilter == sev) ? nil : sev + } label: { + Label(sev.capitalized, + systemImage: severityFilter == sev ? "checkmark" : "circle") + } + } + } label: { + Image(systemName: severityFilter != nil + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle") + .foregroundStyle(severityFilter != nil ? .orange : .primary) } + + // New Issue — borderedProminent so it stands out clearly + // from the filter icon and is easy to find at a glance. + Button { + showNewIssue = true + } label: { + Label("New Issue", systemImage: "plus") + } + .buttonStyle(.borderedProminent) } } .sheet(isPresented: $showNewIssue) { @@ -1803,48 +1916,94 @@ struct NotificationsView: View { /// AsyncImage has no built-in retry — once it enters .failure it stays there /// for the view's lifetime. Toggling the `id` forces SwiftUI to destroy and /// recreate the AsyncImage, triggering a fresh network load. +// MARK: - PhotoCache +// Simple NSCache-backed in-memory image cache keyed by URL string. +// Prevents RetryablePhotoView from re-downloading the same photo on every +// view appearance (AsyncImage only caches within a single URLSession load; +// revisiting IssueDetailView or scrolling the inspection history starts a +// fresh download). Cache entries are evicted automatically by the OS under +// memory pressure — no manual lifetime management needed. + +final class PhotoCache { + static let shared = PhotoCache() + private let cache = NSCache() + + private init() { + cache.countLimit = 150 // max images in memory + cache.totalCostLimit = 80_000_000 // ~80 MB total + } + + func get(_ url: URL) -> UIImage? { cache.object(forKey: url.absoluteString as NSString) } + func set(_ image: UIImage, for url: URL) { cache.setObject(image, forKey: url.absoluteString as NSString, + cost: Int(image.size.width * image.size.height * 4)) } +} + struct RetryablePhotoView: View { let url: URL? @State private var reloadToken = UUID() + @State private var cached: UIImage? = nil var body: some View { - AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in - switch phase { - case .success(let image): - image + Group { + if let img = cached { + // Cache hit — instant display, no spinner, no network + Image(uiImage: img) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) - case .failure: - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.secondary) - Text("Photo unavailable") - .font(.caption) - .foregroundStyle(.secondary) - Button { - reloadToken = UUID() - } label: { - Label("Retry", systemImage: "arrow.clockwise") - .font(.caption) + } else { + AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onAppear { + // Store into cache so next appearance is instant + if let url, let ui = ImageRenderer(content: image).uiImage { + PhotoCache.shared.set(ui, for: url) + cached = ui + } + } + case .failure: + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + Text("Photo unavailable") + .font(.caption) + .foregroundStyle(.secondary) + Button { + reloadToken = UUID() + } label: { + Label("Retry", systemImage: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + case .empty: + HStack(spacing: 8) { + ProgressView() + Text("Loading…").font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + @unknown default: + EmptyView() } - .buttonStyle(.bordered) - .controlSize(.small) } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - case .empty: - HStack(spacing: 8) { - ProgressView() - Text("Loading…").font(.caption).foregroundStyle(.secondary) + .id(reloadToken) + .onAppear { + // Check cache before AsyncImage fires a network request + if let url, let img = PhotoCache.shared.get(url) { + cached = img + } } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - @unknown default: - EmptyView() } } - .id(reloadToken) } } diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 5cc7dd6..25b6c0b 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -23,10 +23,12 @@ 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 isSaving = 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 isSubmitting = false @State private var submitResult: SubmitResult? @@ -125,6 +127,15 @@ struct ExecuteInspectionView: View { } message: { Text("This inspection's location could not be recorded — Location permission may be denied, or no GPS signal is available right now. You can submit without it, or wait a moment and try again.") } + .alert("Required Fields Missing", isPresented: $showValidationAlert) { + Button("OK", role: .cancel) {} + } message: { + let list = missingFields.prefix(5).joined(separator: "\n• ") + let suffix = missingFields.count > 5 + ? "\n…and \(missingFields.count - 5) more." + : "" + Text("Please fill in the following required fields before submitting:\n\n• \(list)\(suffix)") + } .onChange(of: showSubmitAlert) { _, showing in // Begin acquiring a GPS fix the moment the confirm dialog appears // so a location is likely ready by the time the inspector taps Submit. @@ -284,7 +295,16 @@ struct ExecuteInspectionView: View { .disabled(isSaving) Button { - showSubmitAlert = true + // Validate required fields before showing the confirm dialog. + // Collects every unfilled required field's label so the alert + // can name them specifically rather than just saying "missing fields". + let missing = missingRequiredFields() + if missing.isEmpty { + showSubmitAlert = true + } else { + missingFields = missing + showValidationAlert = true + } } label: { Group { if isSubmitting { @@ -380,6 +400,43 @@ struct ExecuteInspectionView: View { // ── Submit (async — shows result, then dismisses) ───────────────────── + /// Returns the labels of required fields that have no value in formValues. + /// Skips structural fields (section, label, buttons) and image/signature + /// fields since those have complex "filled" semantics (local:// counts as + /// filled — the photo exists even if not yet uploaded to the server). + private func missingRequiredFields() -> [String] { + let skipTypes: Set = ["section", "label", + "button_submit", "button_print", "button_email"] + var missing: [String] = [] + for field in formSchema { + guard let ftype = field["type"] as? String, + !skipTypes.contains(ftype), + field["required"] as? Bool == true + else { continue } + + let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? "" + let val = formValues[fid] ?? "" + + let isFilled: Bool + switch ftype { + case "rating": + isFilled = (Int(val) ?? 0) > 0 + case "image", "signature": + isFilled = !val.isEmpty // local:// or uploads/ both count + case "checkbox": + isFilled = val == "true" + default: + isFilled = !val.trimmingCharacters(in: .whitespaces).isEmpty + } + + if !isFilled { + let label = field["label"] as? String ?? "Field \(fid)" + missing.append(label.isEmpty ? "Field \(fid)" : label) + } + } + return missing + } + private func submitInspection() async { isSubmitting = true diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index 6429a5b..d5dca14 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -17,6 +17,46 @@ struct InspectionHistoryView: View { @State private var offset = 0 private let limit = 30 + @State private var searchText = "" + + /// Date formatter for search matching — formats to e.g. "Jun 19, 2026 4:55 PM" + /// so partial strings like "jun", "2026", "19" all match. + private static let searchDateFmt: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + + private func dateString(for insp: APIInspectionSummary) -> String { + guard let d = insp.inspectionDateParsed else { return insp.inspectionDate ?? "" } + return Self.searchDateFmt.string(from: d) + } + + /// Client-side filter over the already-fetched page. + /// Matches: ID (exact or #-prefixed), date string (partial), facility + /// name, area name (together = "location"). Load More still fetches more + /// server pages so the inspector isn't limited to the first 30 results + /// when searching by date or location. + private var filteredInspections: [APIInspectionSummary] { + guard !searchText.isEmpty else { return inspections } + let q = searchText.lowercased() + return inspections.filter { + // ID — match "#12" or bare "12" + let idStr = String($0.id) + let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "") + + // Location = facility name + area name + let locationMatch = $0.facilityName.lowercased().contains(q) + || ($0.areaName?.lowercased().contains(q) ?? false) + + return idMatch + || locationMatch + || dateString(for: $0).lowercased().contains(q) + || $0.templateName.lowercased().contains(q) + } + } + var body: some View { Group { if !sync.isOnline && inspections.isEmpty { @@ -40,15 +80,18 @@ struct InspectionHistoryView: View { systemImage: "clock.arrow.circlepath", description: Text("Completed inspections will appear here after syncing.") ) + } else if filteredInspections.isEmpty { + ContentUnavailableView.search(text: searchText) } else { List { - ForEach(inspections) { inspection in + ForEach(filteredInspections) { inspection in NavigationLink(value: inspection) { HistoryRowView(inspection: inspection) } } - // Load more + // Load More — still available while searching so the + // inspector can expand beyond the current page. if inspections.count < total { HStack { Spacer() @@ -76,6 +119,8 @@ struct InspectionHistoryView: View { } } .navigationTitle("Inspection History") + .searchable(text: $searchText, + prompt: "Search ID, date, facility, area…") .task { if sync.isOnline { await load(reset: true)