// Views/Dashboard/FlagIssueView.swift // ------------------------------------ // Sheet for flagging an issue during an inspection. // Saves locally immediately; syncs to server when online. // // CHANGED: Multi-photo support (up to 5). // - Inline camera / library buttons replace the small confirmationDialog. // - Thumbnail grid shows all attached photos with per-photo remove buttons. // - Each photo creates its own PendingPhoto record for upload. import SwiftUI import SwiftData import PhotosUI struct FlagIssueView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @EnvironmentObject private var sync: SyncManager let inspection: LocalInspection @State private var severity = "medium" @State private var description = "" // Each entry: (UIImage for display, local file path for storage) // CapturedPhoto records the capture moment + GPS fix at shutter time; the // `image` / `path` members match the tuple this replaced. @State private var photos: [CapturedPhoto] = [] @State private var showCamera = false @State private var showLibrary = false @State private var showBanner = false // success confirmation banner private let maxPhotos = 5 private var cameraAvailable: Bool { UIImagePickerController.isSourceTypeAvailable(.camera) } private var remainingSlots: Int { maxPhotos - photos.count } private let severities = ["low", "medium", "high", "critical"] private var facility: LocalFacility? { let id = inspection.facilityServerId return try? context.fetch( FetchDescriptor( predicate: #Predicate { $0.serverId == id } ) ).first } private var canSubmit: Bool { !description.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { NavigationStack { Form { // ── Facility (read-only) ─────────────────────────────────── Section { HStack(spacing: 10) { Image(systemName: "building.2") .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { Text("Facility") .font(.caption) .foregroundStyle(.secondary) Text(facility?.name ?? "—") .font(.body) } } .padding(.vertical, 2) } header: { Text("Inspection Context") } footer: { Text("Issue will be logged against this facility.") .font(.caption) } // ── 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 { // Thumbnail grid 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)) // Remove button 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) } } // Add photo buttons — shown only while slots remain if remainingSlots > 0 { let countLabel = photos.isEmpty ? "Up to \(maxPhotos) photos" : "\(photos.count)/\(maxPhotos) — \(remainingSlots) remaining" Text(countLabel) .font(.caption) .foregroundStyle(.secondary) // Camera button if cameraAvailable { Button { showCamera = true } label: { HStack { Image(systemName: "camera.fill") .font(.title3) .frame(width: 36) Text("Take Photo") .font(.body) Spacer() } .padding(.vertical, 10) .contentShape(Rectangle()) } .foregroundStyle(.primary) } // Library button Button { showLibrary = true } label: { HStack { Image(systemName: "photo.on.rectangle.angled") .font(.title3) .frame(width: 36) Text("Choose from Library") .font(.body) 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("Flag Issue") .navigationBarTitleDisplayMode(.inline) // Warm up GPS so a fix exists the instant a photo is taken; the // coordinates are burned into the photo server-side. .onAppear { PhotoLocationProvider.shared.start() } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Submit") { submitIssue() } .disabled(!canSubmit) .fontWeight(.semibold) } } // ── Submission confirmation banner ───────────────────────────── .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 else { return } guard let path = savePhotoToDisk(img) else { return } photos.append(CapturedPhoto(image: img, path: path)) } private func appendPhotos(_ images: [UIImage]) { for img in images { guard photos.count < maxPhotos else { break } guard let path = savePhotoToDisk(img) else { continue } photos.append(CapturedPhoto(image: img, path: path)) } } private func removePhoto(at index: Int) { guard index < photos.count else { return } // Delete the local file 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() { let issue = LocalIssue( inspectionLocalId: inspection.localId, facilityServerId: inspection.facilityServerId, severity: severity, description: description.trimmingCharacters(in: .whitespaces) ) // Carry area context forward so the server can link the issue to the // correct area. areaServerId is nil when the inspection has no area. issue.areaServerId = inspection.areaServerId issue.photoLocalPaths = photos.map(\.path) issue.inspection = inspection inspection.localIssues.append(issue) context.insert(issue) // Create one PendingPhoto per photo so they all upload independently for photo in photos { let pending = PendingPhoto( localFilePath: photo.path, entityType: "issue", entityLocalId: issue.localId, capturedAt: photo.capturedAt, captureLatitude: photo.latitude, captureLongitude: photo.longitude ) context.insert(pending) } try? context.save() if sync.isOnline { Task { await sync.triggerSync() } } // Show confirmation banner for 2 seconds then dismiss. withAnimation { showBanner = true } Task { try? await Task.sleep(for: .seconds(2)) dismiss() } } }