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
+4 -2
View File
@@ -425,13 +425,14 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = "";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -468,13 +469,14 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = "";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
+53
View File
@@ -258,6 +258,59 @@ actor APIClient {
)
}
// Upload a resolution photo (entity_type = issue_result)
// Saves to issue_result_photos subfolder on the server same bucket
// as photos uploaded via the web update form.
func uploadResultPhoto(localPath: String, retrying: Bool = false) async throws -> String {
let url = try buildURL("/api/v1/photos/upload")
guard let imageData = FileManager.default.contents(atPath: localPath) else {
throw APIError.networkError("Could not read photo: \(localPath)")
}
let boundary = "Boundary-\(UUID().uuidString)"
let filename = URL(fileURLWithPath: localPath).lastPathComponent
let ext = (filename as NSString).pathExtension.lowercased()
let mime = ext == "png" ? "image/png" : "image/jpeg"
var body = Data()
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\nissue_result\r\n".data(using: .utf8)!)
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
injectToken(&req)
req.httpBody = body
let (data, response) = try await performRequest(req)
if shouldRefresh(response, retrying: retrying) {
let refreshed = await refreshAccessToken()
if refreshed { return try await uploadResultPhoto(localPath: localPath, retrying: true) }
throw APIError.notAuthenticated
}
struct PhotoResult: Decodable, Sendable { let serverPath: String }
if let env = try? decoder.decode(_Envelope<PhotoResult>.self, from: data),
env.ok, let r = env.data { return r.serverPath }
throw APIError.serverError("Result photo upload failed")
}
// Attach resolution photos to an existing issue
// PATCHes /api/v1/issues/{id}/result_photos writes to Issue.result_photos
// (Resolution Details on web), not mobile_photo_paths (Photo Evidence).
func updateIssueResultPhotos(issueId: Int, resultPhotos: [String]) async throws {
struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int }
let _: R = try await request(
"/api/v1/issues/\(issueId)/result_photos",
method: "PATCH",
body: ["result_photos": resultPhotos]
)
}
// Fetch Issue Detail (status + assigned_to)
func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail {
+4 -1
View File
@@ -403,6 +403,8 @@ struct APIIssueDetail: Decodable, Sendable {
let verifiedAt: String?
let verificationNote: String?
let reportedByName: String?
// Resolution photos uploaded via web or mobile resolve flow
let resultPhotos: [String]
// Phase E area and assignee context
let areaName: String?
let assignedToName: String?
@@ -422,13 +424,14 @@ struct APIIssueDetail: Decodable, Sendable {
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
verificationNote = try? c.decode(String.self, forKey: .verificationNote)
reportedByName = try? c.decode(String.self, forKey: .reportedByName)
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
areaName = try? c.decode(String.self, forKey: .areaName)
assignedToName = try? c.decode(String.self, forKey: .assignedToName)
}
private enum CodingKeys: String, CodingKey {
case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, resolvedAt
case resultNotes, verifiedAt, verificationNote, reportedByName
case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
case areaName, assignedToName
}
}
+28
View File
@@ -23,6 +23,9 @@ final class LocalIssue {
var photoLocalPathsJSON: String = "[]"
/// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...]
var photoServerPathsJSON: String = "[]"
/// JSON-encoded array of resolution photo server paths (issue_result_photos bucket).
/// Mirrors Issue.result_photos on the server shown under "Resolution Details".
var resultPhotoServerPathsJSON: String = "[]"
// Shared coders JSONDecoder/Encoder init is expensive (parses locale and
// calendar info). Allocating them inside computed property getters means
@@ -45,6 +48,8 @@ final class LocalIssue {
@Transient private var _cachedLocalValue: [String] = []
@Transient private var _cachedServerKey: String = ""
@Transient private var _cachedServerValue: [String] = []
@Transient private var _cachedResultKey: String = ""
@Transient private var _cachedResultValue: [String] = []
/// Decoded local photo paths (up to 5)
var photoLocalPaths: [String] {
@@ -88,6 +93,28 @@ final class LocalIssue {
}
}
/// Decoded resolution photo server paths (issue_result_photos bucket).
/// Shown under "Resolution Details" mirrors Issue.result_photos on the web.
var resultPhotoServerPaths: [String] {
get {
if _cachedResultKey == resultPhotoServerPathsJSON, !_cachedResultKey.isEmpty {
return _cachedResultValue
}
let decoded = (try? Self.jsonDecoder.decode([String].self,
from: Data(resultPhotoServerPathsJSON.utf8))) ?? []
_cachedResultKey = resultPhotoServerPathsJSON
_cachedResultValue = decoded
return decoded
}
set {
let encoded = (try? String(data: Self.jsonEncoder.encode(newValue),
encoding: .utf8)) ?? "[]"
resultPhotoServerPathsJSON = encoded
_cachedResultKey = encoded
_cachedResultValue = newValue
}
}
var createdAt: Date
var syncStatus: String // "pending" | "synced" | "failed"
var syncRetryCount: Int
@@ -151,6 +178,7 @@ final class LocalIssue {
self.issueStatus = "open"
self.photoLocalPathsJSON = "[]"
self.photoServerPathsJSON = "[]"
self.resultPhotoServerPathsJSON = "[]"
self.createdAt = Date()
self.syncStatus = "pending"
self.syncRetryCount = 0
+4 -2
View File
@@ -736,12 +736,13 @@ class SyncManager: ObservableObject {
}
// Refresh photos in case they were added after first pull
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
// result_photos are resolution photos shown separately on the web,
// not displayed on the iPad issues list.
// result_photos are resolution photos shown separately under
// "Resolution Details" in resultPhotoServerPaths.
var serverPaths: [String] = []
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
serverPaths.append(contentsOf: api.mobilePhotoPaths)
existing.photoServerPaths = serverPaths
existing.resultPhotoServerPaths = api.resultPhotos
} else {
// Insert new server-pulled issue
let local = LocalIssue(
@@ -771,6 +772,7 @@ class SyncManager: ObservableObject {
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
serverPaths.append(contentsOf: api.mobilePhotoPaths)
local.photoServerPaths = serverPaths
local.resultPhotoServerPaths = api.resultPhotos
if let ts = api.reportedAt,
let date = Self.isoFormatter.date(from: ts) {
local.createdAt = date
+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 {
}
}
}