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
+2 -1
View File
@@ -194,7 +194,8 @@ actor APIClient {
"mobile_local_id": issue.localId, "mobile_local_id": issue.localId,
] ]
if let id = issue.inspection?.serverId { body["inspection_id"] = id } if let id = issue.inspection?.serverId { body["inspection_id"] = id }
if let path = issue.photoServerPath { body["photo_path"] = path } // Send the first uploaded photo as photo_path (server Issue.photo_path is a single column)
if let firstPhoto = issue.photoServerPaths.first { body["photo_path"] = firstPhoto }
struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool } struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
let r: R = try await post("/api/v1/issues", body: body) let r: R = try await post("/api/v1/issues", body: body)
+18 -4
View File
@@ -16,8 +16,22 @@ final class LocalIssue {
var severity: String // "low" | "medium" | "high" | "critical" var severity: String // "low" | "medium" | "high" | "critical"
var issueDescription: String var issueDescription: String
var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification" var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification"
var photoLocalPath: String? // local file path before upload /// JSON-encoded array of absolute local file paths, e.g. ["/var/.../photo1.jpg", ...]
var photoServerPath: String? // server path after upload var photoLocalPathsJSON: String = "[]"
/// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...]
var photoServerPathsJSON: String = "[]"
/// Decoded local photo paths (up to 5)
var photoLocalPaths: [String] {
get { (try? JSONDecoder().decode([String].self, from: Data(photoLocalPathsJSON.utf8))) ?? [] }
set { photoLocalPathsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
}
/// Decoded server photo paths
var photoServerPaths: [String] {
get { (try? JSONDecoder().decode([String].self, from: Data(photoServerPathsJSON.utf8))) ?? [] }
set { photoServerPathsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
}
var createdAt: Date var createdAt: Date
var syncStatus: String // "pending" | "synced" | "failed" var syncStatus: String // "pending" | "synced" | "failed"
@@ -39,8 +53,8 @@ final class LocalIssue {
self.severity = severity self.severity = severity
self.issueDescription = description self.issueDescription = description
self.issueStatus = "open" self.issueStatus = "open"
self.photoLocalPath = nil self.photoLocalPathsJSON = "[]"
self.photoServerPath = nil self.photoServerPathsJSON = "[]"
self.createdAt = Date() self.createdAt = Date()
self.syncStatus = "pending" self.syncStatus = "pending"
self.syncRetryCount = 0 self.syncRetryCount = 0
+7 -7
View File
@@ -95,15 +95,15 @@ class SyncManager: ObservableObject {
inspections?.first?.setValue(serverPath, forFieldId: fieldId) inspections?.first?.setValue(serverPath, forFieldId: fieldId)
} }
// Update parent issue photo path // Update parent issue photo paths array
if photo.entityType == "issue" { if photo.entityType == "issue" {
let entityId = photo.entityLocalId let entityId = photo.entityLocalId
let issues = try? context.fetch( let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
FetchDescriptor<LocalIssue>( if let issue = allIssues.first(where: { $0.localId == entityId }) {
predicate: #Predicate { $0.localId == entityId } var paths = issue.photoServerPaths
) if !paths.contains(serverPath) { paths.append(serverPath) }
) issue.photoServerPaths = paths
issues?.first?.photoServerPath = serverPath }
} }
try? context.save() try? context.save()
@@ -293,7 +293,7 @@ struct MyInspectionsView: View {
} }
// Delete associated local issues // Delete associated local issues
for issue in inspection.localIssues { for issue in inspection.localIssues {
if let path = issue.photoLocalPath { for path in issue.photoLocalPaths {
try? FileManager.default.removeItem(atPath: path) try? FileManager.default.removeItem(atPath: path)
} }
context.delete(issue) context.delete(issue)
@@ -860,9 +860,10 @@ struct IssueDetailView: View {
} }
} }
if let photoPath = issue.photoLocalPath { if !issue.photoLocalPaths.isEmpty {
Section("Photo") { Section("Photos (\(issue.photoLocalPaths.count))") {
if let img = UIImage(contentsOfFile: photoPath) { ForEach(issue.photoLocalPaths, id: \.self) { path in
if let img = UIImage(contentsOfFile: path) {
Image(uiImage: img) Image(uiImage: img)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
@@ -874,6 +875,7 @@ struct IssueDetailView: View {
} }
} }
} }
}
.navigationTitle("Issue Detail") .navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .task {
+131 -39
View File
@@ -3,13 +3,14 @@
// Sheet for flagging an issue during an inspection. // Sheet for flagging an issue during an inspection.
// Saves locally immediately; syncs to server when online. // Saves locally immediately; syncs to server when online.
// //
// CHANGED: Area picker removed. Facility is derived directly from the // CHANGED: Multi-photo support (up to 5).
// inspection (inspection.facilityServerId) and displayed as read-only info, // - Inline camera / library buttons replace the small confirmationDialog.
// matching the web app's flag_issue.html behaviour where facility_id is // - Thumbnail grid shows all attached photos with per-photo remove buttons.
// a hidden field populated from the inspection context. // - Each photo creates its own PendingPhoto record for upload.
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import PhotosUI
struct FlagIssueView: View { struct FlagIssueView: View {
@@ -21,16 +22,21 @@ struct FlagIssueView: View {
@State private var severity = "medium" @State private var severity = "medium"
@State private var description = "" @State private var description = ""
@State private var selectedImage: UIImage?
@State private var photoLocalPath: String? // Each entry: (UIImage for display, local file path for storage)
@State private var showChoice = false @State private var photos: [(image: UIImage, path: String)] = []
@State private var showCamera = false @State private var showCamera = false
@State private var showLibrary = false @State private var showLibrary = false
private let maxPhotos = 5
private var cameraAvailable: Bool { private var cameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera) UIImagePickerController.isSourceTypeAvailable(.camera)
} }
private var remainingSlots: Int { maxPhotos - photos.count }
private let severities = ["low", "medium", "high", "critical"] private let severities = ["low", "medium", "high", "critical"]
private var facility: LocalFacility? { private var facility: LocalFacility? {
@@ -49,7 +55,7 @@ struct FlagIssueView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
// Facility (read-only) matches web alert banner // Facility (read-only)
Section { Section {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: "building.2") Image(systemName: "building.2")
@@ -86,20 +92,88 @@ struct FlagIssueView: View {
.frame(minHeight: 100) .frame(minHeight: 100)
} }
// Photo // Photos
Section("Photo (Optional)") { Section {
if let img = selectedImage { // Thumbnail grid
Image(uiImage: img) 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() .resizable()
.scaledToFit() .scaledToFill()
.frame(maxHeight: 160) .frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 10))
} // Remove button
Button { Button {
if cameraAvailable { showChoice = true } else { showLibrary = true } removePhoto(at: i)
} label: { } label: {
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo", Image(systemName: "xmark.circle.fill")
systemImage: "camera") .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)
} }
} }
@@ -125,34 +199,51 @@ struct FlagIssueView: View {
.fontWeight(.semibold) .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) { .fullScreenCover(isPresented: $showCamera) {
CameraPickerView(image: $selectedImage, onSelected: savePhoto) CameraPickerView(image: .constant(nil), onSelected: appendPhoto)
.ignoresSafeArea() .ignoresSafeArea()
} }
.sheet(isPresented: $showLibrary) { .sheet(isPresented: $showLibrary) {
LibraryPickerView(image: $selectedImage, onSelected: savePhoto) MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos)
} }
} }
} }
private func savePhoto(_ img: UIImage) { // Photo helpers
guard let data = img.jpegData(compressionQuality: 0.8) else { return }
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 docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true) let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true)
try? FileManager.default.createDirectory(at: photosDir, try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true)
withIntermediateDirectories: true) let fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg")
let filename = "\(UUID().uuidString).jpg"
let fileURL = photosDir.appendingPathComponent(filename)
try? data.write(to: fileURL) try? data.write(to: fileURL)
photoLocalPath = fileURL.path return fileURL.path
selectedImage = img
} }
// Submit
private func submitIssue() { private func submitIssue() {
let issue = LocalIssue( let issue = LocalIssue(
inspectionLocalId: inspection.localId, inspectionLocalId: inspection.localId,
@@ -160,18 +251,19 @@ struct FlagIssueView: View {
severity: severity, severity: severity,
description: description.trimmingCharacters(in: .whitespaces) description: description.trimmingCharacters(in: .whitespaces)
) )
issue.photoLocalPath = photoLocalPath issue.photoLocalPaths = photos.map(\.path)
issue.inspection = inspection issue.inspection = inspection
inspection.localIssues.append(issue) inspection.localIssues.append(issue)
context.insert(issue) context.insert(issue)
if let path = photoLocalPath { // Create one PendingPhoto per photo so they all upload independently
let photo = PendingPhoto( for photo in photos {
localFilePath: path, let pending = PendingPhoto(
localFilePath: photo.path,
entityType: "issue", entityType: "issue",
entityLocalId: issue.localId entityLocalId: issue.localId
) )
context.insert(photo) context.insert(pending)
} }
try? context.save() try? context.save()
@@ -578,6 +578,53 @@ struct LibraryPickerView: UIViewControllerRepresentable {
} }
} }
// Multi-image Library Picker PHPickerViewController with configurable limit
struct MultiLibraryPickerView: UIViewControllerRepresentable {
/// Maximum number of images the user may select in this session.
var selectionLimit: Int
var onSelected: ([UIImage]) -> Void
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = selectionLimit
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ vc: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: MultiLibraryPickerView
init(_ parent: MultiLibraryPickerView) { self.parent = parent }
func picker(_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
var images: [UIImage] = []
let group = DispatchGroup()
for result in results {
guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else { continue }
group.enter()
result.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in
if let img = object as? UIImage { images.append(img) }
group.leave()
}
}
group.notify(queue: .main) {
self.parent.onSelected(images)
}
}
}
}
// MARK: - TableFieldView // MARK: - TableFieldView
struct TableFieldView: View { struct TableFieldView: View {