05/11 Update issue image upload

This commit is contained in:
Nguyen Ngo
2026-05-11 17:31:21 -04:00
parent 6367dac1e6
commit 9fd3f5b829
6 changed files with 223 additions and 67 deletions
+136 -44
View File
@@ -3,13 +3,14 @@
// Sheet for flagging an issue during an inspection.
// Saves locally immediately; syncs to server when online.
//
// CHANGED: Area picker removed. Facility is derived directly from the
// inspection (inspection.facilityServerId) and displayed as read-only info,
// matching the web app's flag_issue.html behaviour where facility_id is
// a hidden field populated from the inspection context.
// 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 {
@@ -21,16 +22,21 @@ struct FlagIssueView: View {
@State private var severity = "medium"
@State private var description = ""
@State private var selectedImage: UIImage?
@State private var photoLocalPath: String?
@State private var showChoice = false
@State private var showCamera = false
@State private var showLibrary = false
// 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? {
@@ -49,7 +55,7 @@ struct FlagIssueView: View {
var body: some View {
NavigationStack {
Form {
// Facility (read-only) matches web alert banner
// Facility (read-only)
Section {
HStack(spacing: 10) {
Image(systemName: "building.2")
@@ -86,20 +92,88 @@ struct FlagIssueView: View {
.frame(minHeight: 100)
}
// Photo
Section("Photo (Optional)") {
if let img = selectedImage {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxHeight: 160)
.clipShape(RoundedRectangle(cornerRadius: 8))
// 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)
}
}
Button {
if cameraAvailable { showChoice = true } else { showLibrary = true }
} label: {
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo",
systemImage: "camera")
// 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)
}
}
@@ -125,34 +199,51 @@ struct FlagIssueView: View {
.fontWeight(.semibold)
}
}
.confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) {
Button("Take Photo") { showCamera = true }
Button("Photo Library") { showLibrary = true }
Button("Cancel", role: .cancel) {}
}
.fullScreenCover(isPresented: $showCamera) {
CameraPickerView(image: $selectedImage, onSelected: savePhoto)
CameraPickerView(image: .constant(nil), onSelected: appendPhoto)
.ignoresSafeArea()
}
.sheet(isPresented: $showLibrary) {
LibraryPickerView(image: $selectedImage, onSelected: savePhoto)
MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos)
}
}
}
private func savePhoto(_ img: UIImage) {
guard let data = img.jpegData(compressionQuality: 0.8) else { return }
// 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 filename = "\(UUID().uuidString).jpg"
let fileURL = photosDir.appendingPathComponent(filename)
try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true)
let fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg")
try? data.write(to: fileURL)
photoLocalPath = fileURL.path
selectedImage = img
return fileURL.path
}
// Submit
private func submitIssue() {
let issue = LocalIssue(
inspectionLocalId: inspection.localId,
@@ -160,18 +251,19 @@ struct FlagIssueView: View {
severity: severity,
description: description.trimmingCharacters(in: .whitespaces)
)
issue.photoLocalPath = photoLocalPath
issue.inspection = inspection
issue.photoLocalPaths = photos.map(\.path)
issue.inspection = inspection
inspection.localIssues.append(issue)
context.insert(issue)
if let path = photoLocalPath {
let photo = PendingPhoto(
localFilePath: path,
// 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(photo)
context.insert(pending)
}
try? context.save()