Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/FlagIssueView.swift
T

322 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
}
}
}