Jun 24 - Update Resolution Details upload photos section

This commit is contained in:
Nguyen Ngo
2026-06-24 16:34:05 -04:00
parent fc571b3faa
commit e6de1f01b6
6 changed files with 297 additions and 6 deletions
+204 -1
View File
@@ -239,6 +239,14 @@ struct IssueDetailView: View {
@State private var isUpdatingStatus = false
@State private var statusError: String?
@State private var showStatusPicker = false
// Resolution Photos
@State private var resultPhotos: [(image: UIImage, path: String)] = []
@State private var showResultCamera = false
@State private var showResultLibrary = false
@State private var isUploadingResultPhotos = false
@State private var resultPhotoError: String?
@State private var resultPhotoSuccess = false
private let maxResultPhotos = 5
// Comments
@State private var comments: [APIIssueComment] = []
@State private var isLoadingComments = false
@@ -355,6 +363,118 @@ struct IssueDetailView: View {
}
}
// Upload Resolution Photos
// Shown when the issue is resolved, online, and synced.
// Lets the inspector attach up to 5 photos showing the fix
// identical to the "Result Photos" upload on the web update form.
if issue.issueStatus == "resolved",
sync.isOnline,
issue.serverId != nil {
Section {
// Thumbnail strip for staged photos
if !resultPhotos.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(resultPhotos.indices, id: \.self) { i in
ZStack(alignment: .topTrailing) {
Image(uiImage: resultPhotos[i].image)
.resizable()
.scaledToFill()
.frame(width: 90, height: 90)
.clipShape(RoundedRectangle(cornerRadius: 8))
Button { removeResultPhoto(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)
}
}
let remaining = maxResultPhotos - resultPhotos.count
if remaining > 0 {
let countLabel = resultPhotos.isEmpty
? "Up to \(maxResultPhotos) photos"
: "\(resultPhotos.count)/\(maxResultPhotos)\(remaining) remaining"
Text(countLabel).font(.caption).foregroundStyle(.secondary)
if UIImagePickerController.isSourceTypeAvailable(.camera) {
Button { showResultCamera = true } label: {
HStack {
Image(systemName: "camera.fill").font(.title3).frame(width: 36)
Text("Take Photo")
Spacer()
}
.padding(.vertical, 8).contentShape(Rectangle())
}
.foregroundStyle(.primary)
}
Button { showResultLibrary = true } label: {
HStack {
Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36)
Text("Choose from Library")
Spacer()
}
.padding(.vertical, 8).contentShape(Rectangle())
}
.foregroundStyle(.primary)
}
if let err = resultPhotoError {
Text(err).font(.caption).foregroundStyle(.red)
}
if resultPhotoSuccess {
Label("Photos uploaded successfully.", systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.green)
}
if !resultPhotos.isEmpty {
Button {
Task { await uploadAndAttachResultPhotos() }
} label: {
if isUploadingResultPhotos {
HStack { ProgressView(); Text("Uploading…") }
} else {
Label("Upload Resolution Photos", systemImage: "arrow.up.circle.fill")
.fontWeight(.semibold)
}
}
.disabled(isUploadingResultPhotos)
.buttonStyle(.borderedProminent)
}
} header: {
Text("Add Resolution Photos")
} footer: {
if resultPhotos.isEmpty {
Text("Attach photos showing the resolution (up to \(maxResultPhotos)).")
.font(.caption)
} else {
Text("Tap × on a photo to remove it before uploading.")
.font(.caption)
}
}
}
// Resolution Photos (server-side, read display)
if !issue.resultPhotoServerPaths.isEmpty {
Section("Resolution Photos (\(issue.resultPhotoServerPaths.count))") {
ForEach(issue.resultPhotoServerPaths, id: \.self) { relativePath in
RetryablePhotoView(
url: URL(string: ServerConfig.current + "/static/" + relativePath)
)
}
}
}
Section("Description") {
Text(issue.issueDescription)
.font(.callout)
@@ -508,6 +628,16 @@ struct IssueDetailView: View {
)
}
}
.fullScreenCover(isPresented: $showResultCamera) {
CameraPickerView(image: .constant(nil), onSelected: appendResultPhoto)
.ignoresSafeArea()
}
.sheet(isPresented: $showResultLibrary) {
MultiLibraryPickerView(
selectionLimit: maxResultPhotos - resultPhotos.count,
onSelected: appendResultPhotos
)
}
.task {
await refreshStatusFromServer()
await loadComments()
@@ -573,6 +703,10 @@ struct IssueDetailView: View {
}
if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area }
if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee }
// Refresh resolution photos from server
if !detail.resultPhotos.isEmpty {
issue.resultPhotoServerPaths = detail.resultPhotos
}
try? context.save()
} catch {
// Non-fatal show cached values silently
@@ -628,6 +762,76 @@ struct IssueDetailView: View {
statusError = error.localizedDescription
}
}
// Resolution photo helpers
private func appendResultPhoto(_ img: UIImage) {
guard resultPhotos.count < maxResultPhotos,
let path = saveResultPhotoToDisk(img) else { return }
resultPhotos.append((image: img, path: path))
}
private func appendResultPhotos(_ images: [UIImage]) {
for img in images {
guard resultPhotos.count < maxResultPhotos,
let path = saveResultPhotoToDisk(img) else { break }
resultPhotos.append((image: img, path: path))
}
}
private func removeResultPhoto(at index: Int) {
guard index < resultPhotos.count else { return }
try? FileManager.default.removeItem(atPath: resultPhotos[index].path)
resultPhotos.remove(at: index)
}
private func saveResultPhotoToDisk(_ 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/ResultPhotos", 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
}
// Upload resolution photos and PATCH to server
// 1. Uploads each staged photo via /api/v1/photos/upload (entity_type=issue_result)
// 2. PATCHes /api/v1/issues/<id>/result_photos with the returned server paths
// 3. Appends to issue.resultPhotoServerPaths so the display section updates
// 4. Clears the staged resultPhotos array and deletes temp files
private func uploadAndAttachResultPhotos() async {
guard let sid = issue.serverId, !resultPhotos.isEmpty else { return }
isUploadingResultPhotos = true
resultPhotoError = nil
resultPhotoSuccess = false
defer { isUploadingResultPhotos = false }
do {
var serverPaths: [String] = []
for photo in resultPhotos {
let path = try await APIClient.shared.uploadResultPhoto(localPath: photo.path)
serverPaths.append(path)
}
try await APIClient.shared.updateIssueResultPhotos(issueId: sid, resultPhotos: serverPaths)
// Append to local cache so display section updates immediately
issue.resultPhotoServerPaths = issue.resultPhotoServerPaths + serverPaths
try? context.save()
// Clean up temp files and clear staging
for photo in resultPhotos {
try? FileManager.default.removeItem(atPath: photo.path)
}
resultPhotos = []
resultPhotoSuccess = true
} catch {
resultPhotoError = error.localizedDescription
}
}
}
// MARK: - Standalone Issue Creation
@@ -926,4 +1130,3 @@ struct StandaloneIssueView: View {
}
}
}