322 lines
14 KiB
Swift
322 lines
14 KiB
Swift
// 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<LocalFacility>(
|
||
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()
|
||
}
|
||
}
|
||
}
|