Jun 24 - Update Resolution Details upload photos section
This commit is contained in:
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user