From 6aa3b74e614b4ef61ac9bfcc00f14f3e600dc1f0 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Thu, 21 May 2026 13:24:10 -0400 Subject: [PATCH] 05/21 Fix issue's photo problems --- JanitorialQC/API/APIClient.swift | 25 +- JanitorialQC/API/APIModels.swift | 30 +- JanitorialQC/Sync/SyncManager.swift | 35 +- JanitorialQC/Utils/Constants.swift | 50 +- JanitorialQC/Views/Auth/LoginView.swift | 23 + .../Views/Dashboard/DashboardView.swift | 504 ++++++++++++++++-- .../Views/Dashboard/StartInspectionView.swift | 9 +- .../Inspection/InspectionHistoryView.swift | 29 +- 8 files changed, 602 insertions(+), 103 deletions(-) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 30c5373..875deb4 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -210,15 +210,30 @@ actor APIClient { "description": issue.issueDescription, "mobile_local_id": issue.localId, ] - if let id = issue.inspection?.serverId { body["inspection_id"] = id } - // Send the first uploaded photo as photo_path (server Issue.photo_path is a single column) - if let firstPhoto = issue.photoServerPaths.first { body["photo_path"] = firstPhoto } + if let id = issue.inspection?.serverId { body["inspection_id"] = id } + // photo_path = primary photo. Additional photos are sent via a + // separate PATCH call in processIssueQueue after the issue is created, + // because the server create endpoint only stores a single photo_path. + if let first = issue.photoServerPaths.first { body["photo_path"] = first } struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool } let r: R = try await post("/api/v1/issues", body: body) return r.issueId } + // ── Attach additional photos to an existing issue ───────────────────── + // Called after submitIssue when the issue has more than one photo. + // PATCHes /api/v1/issues/{id}/photos with result_photos = [server paths beyond the first]. + // The create endpoint only stores photo_path (single); extras go here. + func updateIssuePhotos(issueId: Int, resultPhotos: [String]) async throws { + struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int } + let _: R = try await request( + "/api/v1/issues/\(issueId)/photos", + method: "PATCH", + body: ["result_photos": resultPhotos] + ) + } + // ── Fetch Issue Detail (status + assigned_to) ───────────────────────── func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail { @@ -270,7 +285,7 @@ actor APIClient { private func refreshAccessToken() async -> Bool { guard let token = KeychainHelper.get(Constants.Keychain.refreshToken), - let url = URL(string: Constants.baseURL + "/api/v1/auth/refresh") + let url = URL(string: ServerConfig.current + "/api/v1/auth/refresh") else { return false } var req = URLRequest(url: url) @@ -299,7 +314,7 @@ actor APIClient { // ── Private Helpers ─────────────────────────────────────────────────── private func buildURL(_ endpoint: String) throws -> URL { - guard let url = URL(string: Constants.baseURL + endpoint) else { + guard let url = URL(string: ServerConfig.current + endpoint) else { throw APIError.invalidURL } return url diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index bf2eb5f..07c60e8 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -484,27 +484,29 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable { let facilityName: String? let reportedAt: String? let mobileLocalId: String? - let photoPath: String? // primary issue photo (relative server path) - let resultPhotos: [String] // resolution photos (relative server paths) + let photoPath: String? // primary evidence photo + let mobilePhotoPaths: [String] // extra evidence photos from iPad + let resultPhotos: [String] // resolution photos added via web nonisolated init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - status = try c.decode(String.self, forKey: .status) - severity = try c.decode(String.self, forKey: .severity) - description = try c.decode(String.self, forKey: .description) - assignedTo = try? c.decode(Int.self, forKey: .assignedTo) - facilityId = try? c.decode(Int.self, forKey: .facilityId) - facilityName = try? c.decode(String.self, forKey: .facilityName) - reportedAt = try? c.decode(String.self, forKey: .reportedAt) - mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) - photoPath = try? c.decode(String.self, forKey: .photoPath) - resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] + id = try c.decode(Int.self, forKey: .id) + status = try c.decode(String.self, forKey: .status) + severity = try c.decode(String.self, forKey: .severity) + description = try c.decode(String.self, forKey: .description) + assignedTo = try? c.decode(Int.self, forKey: .assignedTo) + facilityId = try? c.decode(Int.self, forKey: .facilityId) + facilityName = try? c.decode(String.self, forKey: .facilityName) + reportedAt = try? c.decode(String.self, forKey: .reportedAt) + mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) + photoPath = try? c.decode(String.self, forKey: .photoPath) + mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? [] + resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] } private enum CodingKeys: String, CodingKey { case id, status, severity, description, assignedTo case facilityId, facilityName, reportedAt, mobileLocalId - case photoPath, resultPhotos + case photoPath, mobilePhotoPaths, resultPhotos } } diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 7393911..73807a5 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -309,8 +309,23 @@ class SyncManager: ObservableObject { let issueId = try await APIClient.shared.submitIssue(issue) issue.serverId = issueId issue.syncStatus = "synced" + // Photos are now represented by photoServerPaths on the server. + // Clear the local file paths so IssueDetailView doesn't render + // a duplicate "local photos" section alongside the server section. + issue.photoLocalPaths = [] try? context.save() + // If there are additional photos beyond the first (which was sent + // as photo_path on create), PATCH them to result_photos now. + // The server create endpoint only stores photo_path; result_photos + // must be set via a separate PATCH call. + let extras = Array(issue.photoServerPaths.dropFirst()) + if !extras.isEmpty { + try? await APIClient.shared.updateIssuePhotos( + issueId: issueId, resultPhotos: extras + ) + } + } catch { issue.syncRetryCount += 1 issue.syncErrorMessage = error.localizedDescription @@ -339,7 +354,17 @@ class SyncManager: ObservableObject { uniquingKeysWith: { a, _ in a } ) - for apiFacility in facilitiesData.facilities { + // Deduplicate the server response by id before upserting. + // The server may return the same facility id more than once + // (e.g. one row per contract assignment), which would insert + // duplicate LocalFacility records and show buildings twice in + // every picker. Keep only the first occurrence of each id. + var seenFacilityIds = Set() + let uniqueFacilities = facilitiesData.facilities.filter { + seenFacilityIds.insert($0.id).inserted + } + + for apiFacility in uniqueFacilities { if let existing = facilityMap[apiFacility.id] { existing.update(from: apiFacility) } else { @@ -446,9 +471,12 @@ class SyncManager: ObservableObject { existing.issueDescription = api.description if let fid = api.facilityId { existing.facilityServerId = fid } // Refresh photos in case they were added after first pull + // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). + // result_photos are resolution photos — shown separately on the web, + // not displayed on the iPad issues list. var serverPaths: [String] = [] if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } - serverPaths.append(contentsOf: api.resultPhotos) + serverPaths.append(contentsOf: api.mobilePhotoPaths) existing.photoServerPaths = serverPaths } else { // Insert new server-pulled issue @@ -462,9 +490,10 @@ class SyncManager: ObservableObject { local.issueStatus = api.status local.syncStatus = "synced" // never re-submit // Store server photos so IssueDetailView can show them + // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). var serverPaths: [String] = [] if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } - serverPaths.append(contentsOf: api.resultPhotos) + serverPaths.append(contentsOf: api.mobilePhotoPaths) local.photoServerPaths = serverPaths if let ts = api.reportedAt, let date = Self.isoFormatter.date(from: ts) { diff --git a/JanitorialQC/Utils/Constants.swift b/JanitorialQC/Utils/Constants.swift index f44eff3..1835743 100644 --- a/JanitorialQC/Utils/Constants.swift +++ b/JanitorialQC/Utils/Constants.swift @@ -1,16 +1,60 @@ // Utils/Constants.swift // --------------------- // Central place for app-wide constants. -// IMPORTANT: Replace baseURL with your actual server URL. import Foundation +// MARK: - Server selection + +/// The two known JQC servers the inspector can connect to. +nonisolated enum ServerOption: String, CaseIterable, Sendable { + case primary = "https://jqc.ltservicesinc.com" + case secondary = "https://jqc1.ltservicesinc.com" + + var displayName: String { + switch self { + case .primary: return "jqc (Primary)" + case .secondary: return "jqc1 (Secondary)" + } + } +} + +/// Runtime-mutable server selection backed by UserDefaults. +/// Read `ServerConfig.current` anywhere you would have used `Constants.baseURL`. +nonisolated enum ServerConfig { + + private static let defaultsKey = "com.jqc.selectedServer" + + /// The currently selected base URL. Reads UserDefaults on every call so + /// actor-isolated callers (e.g. APIClient) always get the latest value + /// without needing @MainActor access. + nonisolated static var current: String { + get { + let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? "" + return ServerOption(rawValue: raw)?.rawValue ?? ServerOption.primary.rawValue + } + } + + /// Persist the chosen server. Call from @MainActor UI code only. + @MainActor + static func select(_ option: ServerOption) { + UserDefaults.standard.set(option.rawValue, forKey: defaultsKey) + } + + /// The current selection as a `ServerOption` (for UI binding). + @MainActor + static var selectedOption: ServerOption { + let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? "" + return ServerOption(rawValue: raw) ?? .primary + } +} + +// MARK: - App-wide constants + // Explicitly not @MainActor — these constants must be readable from // any actor context including APIClient and KeychainHelper. nonisolated enum Constants { - static let baseURL = "https://jqc1.ltservicesinc.com" - nonisolated enum Keychain { static let accessToken = "com.jqc.accessToken" static let refreshToken = "com.jqc.refreshToken" diff --git a/JanitorialQC/Views/Auth/LoginView.swift b/JanitorialQC/Views/Auth/LoginView.swift index 875390e..394988c 100644 --- a/JanitorialQC/Views/Auth/LoginView.swift +++ b/JanitorialQC/Views/Auth/LoginView.swift @@ -10,6 +10,7 @@ struct LoginView: View { @State private var username = "" @State private var password = "" + @State private var selectedServer: ServerOption = ServerConfig.selectedOption @FocusState private var focusedField: Field? private enum Field { case username, password } @@ -40,6 +41,28 @@ struct LoginView: View { // ── Login Form ───────────────────────────────────────── VStack(spacing: 16) { + + // ── Server Picker ────────────────────────────────── + GroupBox { + VStack(alignment: .leading, spacing: 6) { + Label("Server", systemImage: "server.rack") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Server", selection: $selectedServer) { + ForEach(ServerOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + .pickerStyle(.segmented) + .onChange(of: selectedServer) { _, newValue in + ServerConfig.select(newValue) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 4) + } + .frame(maxWidth: 400) + GroupBox { VStack(spacing: 0) { HStack { diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 8e64501..9bb420c 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -39,8 +39,6 @@ struct DashboardView: View { ) private var myInspections: [LocalInspection] @State private var selectedTab: SidebarTab = .myInspections - @State private var showNewInspection = false - /// Each sidebar tap refreshes the UUID for that tab, forcing its /// NavigationStack to be destroyed and recreated — even when the tab /// hasn't changed (user is already on it but deep inside a detail view). @@ -136,13 +134,6 @@ struct DashboardView: View { } .navigationTitle("JQC Inspector") .listStyle(.sidebar) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { showNewInspection = true } label: { - Image(systemName: "plus") - } - } - } .safeAreaInset(edge: .bottom) { syncStatusFooter } } detail: { @@ -180,9 +171,6 @@ struct DashboardView: View { NavigationStack { SettingsView() } } } - .sheet(isPresented: $showNewInspection) { - StartInspectionView() - } .task { if sync.isOnline { await sync.triggerSync() @@ -235,6 +223,8 @@ struct MyInspectionsView: View { @Environment(\.modelContext) private var context + @State private var showNewInspection = false + // Deletion confirmation state @State private var pendingDelete: LocalInspection? @State private var showDeleteAlert = false @@ -274,6 +264,16 @@ struct MyInspectionsView: View { } message: { inspection in Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.") } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showNewInspection = true } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showNewInspection) { + StartInspectionView() + } } private func draftName(_ inspection: LocalInspection) -> String { @@ -670,6 +670,7 @@ struct IssuesListView: View { ) private var issues: [LocalIssue] @Environment(\.modelContext) private var context + @State private var showNewIssue = false var body: some View { Group { @@ -677,7 +678,7 @@ struct IssuesListView: View { ContentUnavailableView( "No Issues", systemImage: "exclamationmark.triangle", - description: Text("Issues you flag during inspections will appear here.") + description: Text("Tap + to log a new issue, or flag one during an inspection.") ) } else { List(issues) { issue in @@ -688,6 +689,16 @@ struct IssuesListView: View { } } .navigationTitle("Issues (\(issues.count))") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showNewIssue = true } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showNewIssue) { + StandaloneIssueView() + } } } @@ -860,46 +871,40 @@ struct IssueDetailView: View { } } - if !issue.photoLocalPaths.isEmpty { - Section("Photos (\(issue.photoLocalPaths.count))") { - ForEach(issue.photoLocalPaths, id: \.self) { path in - if let img = UIImage(contentsOfFile: path) { - Image(uiImage: img) - .resizable() - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - Label("Photo pending upload", systemImage: "photo") - .foregroundStyle(.secondary) - } - } - } - } - - if !issue.photoServerPaths.isEmpty { - Section("Photos (\(issue.photoServerPaths.count))") { - ForEach(issue.photoServerPaths, id: \.self) { relativePath in - AsyncImage(url: URL(string: Constants.baseURL + "/" + relativePath)) { phase in - switch phase { - case .success(let image): - image + // ── Photo display logic ──────────────────────────────────────── + // While the issue is pending (not yet submitted to the server), + // show only local photos from disk — photoServerPaths may be + // partially populated from mid-sync photo uploads, causing a mix + // of working and broken images. Once synced, photoLocalPaths is + // cleared and only the server paths section renders. + if issue.syncStatus != "synced" { + // Pending / failed: show local files only + if !issue.photoLocalPaths.isEmpty { + Section("Photos (\(issue.photoLocalPaths.count))") { + ForEach(issue.photoLocalPaths, id: \.self) { path in + if let img = UIImage(contentsOfFile: path) { + Image(uiImage: img) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) - case .failure: - Label("Photo unavailable", systemImage: "photo.slash") + } else { + Label("Photo pending upload", systemImage: "photo") .foregroundStyle(.secondary) - case .empty: - HStack(spacing: 8) { - ProgressView() - Text("Loading…").font(.caption).foregroundStyle(.secondary) - } - @unknown default: - EmptyView() } } } } + } else { + // Synced: show server photos only + if !issue.photoServerPaths.isEmpty { + Section("Photos (\(issue.photoServerPaths.count))") { + ForEach(issue.photoServerPaths, id: \.self) { relativePath in + RetryablePhotoView( + url: URL(string: ServerConfig.current + "/static/" + relativePath) + ) + } + } + } } } .navigationTitle("Issue Detail") @@ -944,6 +949,354 @@ struct IssueDetailView: View { } } +// MARK: - Standalone Issue Creation +// Allows inspectors to log an issue directly from the Issues page, +// without being inside an active inspection. The issue is created with +// inspectionLocalId == "" and synced to the server via processIssueQueue. + +struct StandaloneIssueView: View { + + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var sync: SyncManager + + @Query(sort: \LocalFacility.name) private var facilities: [LocalFacility] + + // ── Contract → Facility cascade (mirrors StartInspectionView) ───────── + @State private var selectedProjectId: Int? = nil + @State private var selectedFacilityId: Int? = nil + + @State private var severity = "medium" + @State private var description = "" + @State private var photos: [(image: UIImage, path: String)] = [] + @State private var showCamera = false + @State private var showLibrary = false + @State private var showBanner = false + + private let maxPhotos = 5 + private let severities = ["low", "medium", "high", "critical"] + + private var cameraAvailable: Bool { + UIImagePickerController.isSourceTypeAvailable(.camera) + } + private var remainingSlots: Int { maxPhotos - photos.count } + + /// Unique contracts derived from cached facilities, sorted by name. + private var contracts: [(id: Int, name: String)] { + var seen = Set() + var result: [(id: Int, name: String)] = [] + for f in facilities { + if seen.insert(f.projectId).inserted { + result.append((id: f.projectId, name: f.projectName)) + } + } + return result.sorted { $0.name < $1.name } + } + + /// Facilities belonging to the selected contract. + /// Facilities for the selected contract, deduplicated by serverId. + private var filteredFacilities: [LocalFacility] { + guard let pid = selectedProjectId else { return [] } + var seen = Set() + return facilities + .filter { $0.projectId == pid } + .filter { seen.insert($0.serverId).inserted } + } + + private var canSubmit: Bool { + selectedFacilityId != nil && + !description.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + NavigationStack { + Form { + + // ── Contract picker ──────────────────────────────────────── + Section("Contract") { + if contracts.isEmpty { + Text("No contracts available. Sync required.") + .foregroundStyle(.secondary).font(.callout) + } else { + Picker("Contract", selection: $selectedProjectId) { + Text("Select a contract…").tag(Optional(nil)) + ForEach(contracts, id: \.id) { contract in + Text(contract.name).tag(Optional(contract.id)) + } + } + .pickerStyle(.navigationLink) + .onChange(of: selectedProjectId) { + // Reset facility when contract changes + let facilityBelongsToContract = facilities.contains { + $0.serverId == selectedFacilityId && + $0.projectId == selectedProjectId + } + if !facilityBelongsToContract { + selectedFacilityId = nil + } + } + } + } + + // ── Facility picker (gated on contract selection) ────────── + if selectedProjectId != nil { + Section("Facility") { + if filteredFacilities.isEmpty { + Text("No facilities in this contract.") + .foregroundStyle(.secondary).font(.callout) + } else { + Picker("Facility", selection: $selectedFacilityId) { + Text("Select a facility…").tag(Optional(nil)) + ForEach(filteredFacilities) { facility in + Text(facility.name).tag(Optional(facility.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) + } + + // ── Photos ───────────────────────────────────────────────── + Section { + if !photos.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(photos.indices, id: \.self) { i in + ZStack(alignment: .topTrailing) { + Image(uiImage: photos[i].image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Button { removePhoto(at: i) } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black.opacity(0.7)) + } + .offset(x: 6, y: -6) + } + } + } + .padding(.vertical, 6) + } + } + if remainingSlots > 0 { + let countLabel = photos.isEmpty + ? "Up to \(maxPhotos) photos" + : "\(photos.count)/\(maxPhotos) — \(remainingSlots) remaining" + Text(countLabel).font(.caption).foregroundStyle(.secondary) + if cameraAvailable { + Button { showCamera = true } label: { + HStack { + Image(systemName: "camera.fill").font(.title3).frame(width: 36) + Text("Take Photo") + Spacer() + } + .padding(.vertical, 10).contentShape(Rectangle()) + } + .foregroundStyle(.primary) + } + Button { showLibrary = true } label: { + HStack { + Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36) + Text("Choose from Library") + Spacer() + } + .padding(.vertical, 10).contentShape(Rectangle()) + } + .foregroundStyle(.primary) + } + } header: { + Text("Photos (Optional)") + } footer: { + if !photos.isEmpty { Text("Tap × on a photo to remove it.").font(.caption) } + } + + // ── Offline notice ───────────────────────────────────────── + if !sync.isOnline { + Section { + Label("You\'re offline — this issue will sync automatically.", + systemImage: "wifi.slash") + .font(.callout).foregroundStyle(.orange) + } + } + } + .navigationTitle("New Issue") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Submit") { submitIssue() } + .disabled(!canSubmit) + .fontWeight(.semibold) + } + } + .overlay(alignment: .top) { + if showBanner { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.title2).foregroundStyle(.green) + VStack(alignment: .leading, spacing: 2) { + Text("Issue Logged").font(.headline) + Text(sync.isOnline ? "Submitted to server." : "Saved — will sync when online.") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.1), radius: 8, y: 4) + .padding(.horizontal, 24).padding(.top, 8) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(10) + } + } + .animation(.spring(duration: 0.35), value: showBanner) + .fullScreenCover(isPresented: $showCamera) { + CameraPickerView(image: .constant(nil), onSelected: appendPhoto) + .ignoresSafeArea() + } + .sheet(isPresented: $showLibrary) { + MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos) + } + } + } + + // ── Photo helpers ───────────────────────────────────────────────────── + + private func appendPhoto(_ img: UIImage) { + guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { return } + photos.append((image: img, path: path)) + } + + private func appendPhotos(_ images: [UIImage]) { + for img in images { + guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { break } + photos.append((image: img, path: path)) + } + } + + private func removePhoto(at index: Int) { + guard index < photos.count else { return } + try? FileManager.default.removeItem(atPath: photos[index].path) + photos.remove(at: index) + } + + private func savePhotoToDisk(_ img: UIImage) -> String? { + guard let data = img.jpegData(compressionQuality: 0.8) else { return nil } + 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 fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") + try? data.write(to: fileURL) + return fileURL.path + } + + // ── Submit ──────────────────────────────────────────────────────────── + + private func submitIssue() { + guard let facilityId = selectedFacilityId else { return } + let issue = LocalIssue( + inspectionLocalId: "", // standalone — not tied to any inspection + facilityServerId: facilityId, + severity: severity, + description: description.trimmingCharacters(in: .whitespaces) + ) + issue.photoLocalPaths = photos.map(\.path) + context.insert(issue) + + for photo in photos { + let pending = PendingPhoto( + localFilePath: photo.path, + entityType: "issue", + entityLocalId: issue.localId + ) + context.insert(pending) + } + + try? context.save() + + if sync.isOnline { Task { await sync.triggerSync() } } + + withAnimation { showBanner = true } + Task { + try? await Task.sleep(for: .seconds(2)) + dismiss() + } + } +} + +// MARK: - Retryable Photo + +/// Loads a server photo via AsyncImage with a tap-to-retry failure state. +/// 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. +struct RetryablePhotoView: View { + let url: URL? + @State private var reloadToken = UUID() + + var body: some View { + AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in + switch phase { + case .success(let image): + image + .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) + } + .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() + } + } + .id(reloadToken) + } +} + // MARK: - Templates struct TemplatesListView: View { @@ -989,6 +1342,9 @@ struct SettingsView: View { @State private var showClearCacheAlert = false @State private var cacheCleared = false + @State private var settingsServer: ServerOption = ServerConfig.selectedOption + @State private var pendingServer: ServerOption? = nil + @State private var showServerSwitchAlert = false var body: some View { List { @@ -1048,9 +1404,30 @@ struct SettingsView: View { } } + Section { + Picker("Server", selection: $settingsServer) { + ForEach(ServerOption.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + .pickerStyle(.segmented) + .onChange(of: settingsServer) { _, newValue in + // Don't commit yet — ask user to confirm logout first. + // Revert the picker visually until confirmed. + pendingServer = newValue + settingsServer = ServerConfig.selectedOption // snap back + showServerSwitchAlert = true + } + } header: { + Text("Server") + } footer: { + Text(ServerConfig.current) + .font(.caption2) + .foregroundStyle(.tertiary) + } + Section("App Info") { LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))") - LabeledContent("Server", value: Constants.baseURL) } } .navigationTitle("Settings") @@ -1060,6 +1437,27 @@ struct SettingsView: View { } message: { Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.") } + .alert("Switch Server?", isPresented: $showServerSwitchAlert) { + Button("Switch & Log Out", role: .destructive) { + if let chosen = pendingServer { + ServerConfig.select(chosen) + settingsServer = chosen + pendingServer = nil + Task { + clearServerPulledData() + sync.resetNotificationPoller() + await auth.logout() + } + } + } + Button("Cancel", role: .cancel) { + pendingServer = nil + } + } message: { + if let chosen = pendingServer { + Text("Switching to \(chosen.displayName) will log you out. All cached server data will be cleared. You will need to log in again.") + } + } } private func clearCache() { @@ -1081,15 +1479,17 @@ struct SettingsView: View { } } - /// Delete all server-pulled LocalIssue records (syncStatus == "synced" and - /// inspectionLocalId == ""). These are issues fetched from the server and - /// reconciled by pullAssignedIssues — they must be cleared on logout so - /// stale records from a previous server domain or user session don't persist. - /// Device-created issues (inspectionLocalId != "") are never touched. + /// Delete every LocalIssue that has ever been assigned a serverId. + /// This covers two categories: + /// 1. Server-pulled assigned issues (inspectionLocalId == "", syncStatus == "synced") + /// 2. Inspector-created issues that already synced (inspectionLocalId != "", serverId != nil) + /// — their serverIds are meaningless on a different server, so they must go too. + /// The only records preserved are truly pending device-created issues + /// (serverId == nil, syncStatus == "pending") that have never reached any server. private func clearServerPulledData() { let allIssues = (try? context.fetch(FetchDescriptor())) ?? [] allIssues - .filter { $0.syncStatus == "synced" && $0.inspectionLocalId == "" } + .filter { $0.serverId != nil } .forEach { context.delete($0) } try? context.save() } diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index ce7bf45..e119c33 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -46,10 +46,15 @@ struct StartInspectionView: View { return result.sorted { $0.name < $1.name } } - /// Facilities that belong to the selected contract. + /// Facilities that belong to the selected contract, deduplicated by serverId. + /// Guards against duplicate LocalFacility records if the server ever returns + /// the same facility id more than once in the /api/v1/facilities response. private var filteredFacilities: [LocalFacility] { guard let pid = selectedProjectId else { return [] } - return facilities.filter { $0.projectId == pid } + var seen = Set() + return facilities + .filter { $0.projectId == pid } + .filter { seen.insert($0.serverId).inserted } } private var selectedFacility: LocalFacility? { diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index a67452c..cb6b712 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -619,34 +619,15 @@ struct ReadOnlyCellView: View { .clipShape(RoundedRectangle(cornerRadius: 5)) } else { // Local file cleaned up — show placeholder - Label("Photo no longer on device", systemImage: "photo.badge.exclamationmark") + Label("Photo no longer on device", systemImage: "exclamationmark.triangle") .font(.system(size: 11)) .foregroundStyle(.secondary) } } else if value.hasPrefix("uploads/") { - // Photo synced to server — load via AsyncImage - let url = URL(string: "\(Constants.baseURL)/static/\(value)") - AsyncImage(url: url) { phase in - switch phase { - case .success(let img): - img.resizable() - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 5)) - case .failure: - Label("Could not load photo", systemImage: "photo.badge.exclamationmark") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - case .empty: - HStack(spacing: 6) { - ProgressView().scaleEffect(0.7) - Text("Loading photo…") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - @unknown default: - EmptyView() - } - } + // Photo synced to server — load with retry support + RetryablePhotoView( + url: URL(string: "\(ServerConfig.current)/static/\(value)") + ) } else if !value.isEmpty { // Unknown path format — generic indicator Label("Photo attached", systemImage: "photo")