05/11 Update issue image upload
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,16 +860,18 @@ 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
|
||||||
Image(uiImage: img)
|
if let img = UIImage(contentsOfFile: path) {
|
||||||
.resizable()
|
Image(uiImage: img)
|
||||||
.scaledToFit()
|
.resizable()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.scaledToFit()
|
||||||
} else {
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
Label("Photo pending upload", systemImage: "photo")
|
} else {
|
||||||
.foregroundStyle(.secondary)
|
Label("Photo pending upload", systemImage: "photo")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 showLibrary = false
|
@State private var showCamera = 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 {
|
||||||
.resizable()
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
.scaledToFit()
|
HStack(spacing: 10) {
|
||||||
.frame(maxHeight: 160)
|
ForEach(photos.indices, id: \.self) { i in
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
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 }
|
// Add photo buttons — shown only while slots remain
|
||||||
} label: {
|
if remainingSlots > 0 {
|
||||||
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo",
|
let countLabel = photos.isEmpty
|
||||||
systemImage: "camera")
|
? "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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user