278 lines
12 KiB
Swift
278 lines
12 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)
|
||
@State private var photos: [(image: UIImage, path: String)] = []
|
||
|
||
@State private var showCamera = false
|
||
@State private var showLibrary = false
|
||
|
||
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)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("Cancel") { dismiss() }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Submit") { submitIssue() }
|
||
.disabled(!canSubmit)
|
||
.fontWeight(.semibold)
|
||
}
|
||
}
|
||
.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((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((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)
|
||
)
|
||
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
|
||
)
|
||
context.insert(pending)
|
||
}
|
||
|
||
try? context.save()
|
||
|
||
if sync.isOnline {
|
||
Task { await sync.triggerSync() }
|
||
}
|
||
|
||
dismiss()
|
||
}
|
||
}
|