Jun 24 - Update Resolution Details upload photos section
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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