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_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = "";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.4; MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -468,13 +469,14 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = "";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.4; MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; 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) // Fetch Issue Detail (status + assigned_to)
func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail { func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail {
+4 -1
View File
@@ -403,6 +403,8 @@ struct APIIssueDetail: Decodable, Sendable {
let verifiedAt: String? let verifiedAt: String?
let verificationNote: String? let verificationNote: String?
let reportedByName: String? let reportedByName: String?
// Resolution photos uploaded via web or mobile resolve flow
let resultPhotos: [String]
// Phase E area and assignee context // Phase E area and assignee context
let areaName: String? let areaName: String?
let assignedToName: String? let assignedToName: String?
@@ -422,13 +424,14 @@ struct APIIssueDetail: Decodable, Sendable {
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt) verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
verificationNote = try? c.decode(String.self, forKey: .verificationNote) verificationNote = try? c.decode(String.self, forKey: .verificationNote)
reportedByName = try? c.decode(String.self, forKey: .reportedByName) reportedByName = try? c.decode(String.self, forKey: .reportedByName)
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
areaName = try? c.decode(String.self, forKey: .areaName) areaName = try? c.decode(String.self, forKey: .areaName)
assignedToName = try? c.decode(String.self, forKey: .assignedToName) assignedToName = try? c.decode(String.self, forKey: .assignedToName)
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id, status, severity, description, assignedTo case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, resolvedAt case facilityId, facilityName, reportedAt, resolvedAt
case resultNotes, verifiedAt, verificationNote, reportedByName case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
case areaName, assignedToName case areaName, assignedToName
} }
} }
+28
View File
@@ -23,6 +23,9 @@ final class LocalIssue {
var photoLocalPathsJSON: String = "[]" var photoLocalPathsJSON: String = "[]"
/// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...] /// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...]
var photoServerPathsJSON: String = "[]" 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 // Shared coders JSONDecoder/Encoder init is expensive (parses locale and
// calendar info). Allocating them inside computed property getters means // calendar info). Allocating them inside computed property getters means
@@ -45,6 +48,8 @@ final class LocalIssue {
@Transient private var _cachedLocalValue: [String] = [] @Transient private var _cachedLocalValue: [String] = []
@Transient private var _cachedServerKey: String = "" @Transient private var _cachedServerKey: String = ""
@Transient private var _cachedServerValue: [String] = [] @Transient private var _cachedServerValue: [String] = []
@Transient private var _cachedResultKey: String = ""
@Transient private var _cachedResultValue: [String] = []
/// Decoded local photo paths (up to 5) /// Decoded local photo paths (up to 5)
var photoLocalPaths: [String] { 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 createdAt: Date
var syncStatus: String // "pending" | "synced" | "failed" var syncStatus: String // "pending" | "synced" | "failed"
var syncRetryCount: Int var syncRetryCount: Int
@@ -151,6 +178,7 @@ final class LocalIssue {
self.issueStatus = "open" self.issueStatus = "open"
self.photoLocalPathsJSON = "[]" self.photoLocalPathsJSON = "[]"
self.photoServerPathsJSON = "[]" self.photoServerPathsJSON = "[]"
self.resultPhotoServerPathsJSON = "[]"
self.createdAt = Date() self.createdAt = Date()
self.syncStatus = "pending" self.syncStatus = "pending"
self.syncRetryCount = 0 self.syncRetryCount = 0
+4 -2
View File
@@ -736,12 +736,13 @@ class SyncManager: ObservableObject {
} }
// Refresh photos in case they were added after first pull // Refresh photos in case they were added after first pull
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
// result_photos are resolution photos shown separately on the web, // result_photos are resolution photos shown separately under
// not displayed on the iPad issues list. // "Resolution Details" in resultPhotoServerPaths.
var serverPaths: [String] = [] var serverPaths: [String] = []
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
serverPaths.append(contentsOf: api.mobilePhotoPaths) serverPaths.append(contentsOf: api.mobilePhotoPaths)
existing.photoServerPaths = serverPaths existing.photoServerPaths = serverPaths
existing.resultPhotoServerPaths = api.resultPhotos
} else { } else {
// Insert new server-pulled issue // Insert new server-pulled issue
let local = LocalIssue( let local = LocalIssue(
@@ -771,6 +772,7 @@ class SyncManager: ObservableObject {
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
serverPaths.append(contentsOf: api.mobilePhotoPaths) serverPaths.append(contentsOf: api.mobilePhotoPaths)
local.photoServerPaths = serverPaths local.photoServerPaths = serverPaths
local.resultPhotoServerPaths = api.resultPhotos
if let ts = api.reportedAt, if let ts = api.reportedAt,
let date = Self.isoFormatter.date(from: ts) { let date = Self.isoFormatter.date(from: ts) {
local.createdAt = date local.createdAt = date
+204 -1
View File
@@ -239,6 +239,14 @@ struct IssueDetailView: View {
@State private var isUpdatingStatus = false @State private var isUpdatingStatus = false
@State private var statusError: String? @State private var statusError: String?
@State private var showStatusPicker = false @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 // Comments
@State private var comments: [APIIssueComment] = [] @State private var comments: [APIIssueComment] = []
@State private var isLoadingComments = false @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") { Section("Description") {
Text(issue.issueDescription) Text(issue.issueDescription)
.font(.callout) .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 { .task {
await refreshStatusFromServer() await refreshStatusFromServer()
await loadComments() await loadComments()
@@ -573,6 +703,10 @@ struct IssueDetailView: View {
} }
if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area } if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area }
if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee } 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() try? context.save()
} catch { } catch {
// Non-fatal show cached values silently // Non-fatal show cached values silently
@@ -628,6 +762,76 @@ struct IssueDetailView: View {
statusError = error.localizedDescription 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 // MARK: - Standalone Issue Creation
@@ -926,4 +1130,3 @@ struct StandaloneIssueView: View {
} }
} }
} }