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