Jul 14 - Using CDN - update

This commit is contained in:
Nguyen Ngo
2026-07-14 15:40:16 -04:00
parent 6fbd3d1437
commit 2a0b264a5c
8 changed files with 162 additions and 49 deletions
+28
View File
@@ -296,6 +296,8 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
// detail view works without a local SwiftData copy (e.g. after reinstall). // detail view works without a local SwiftData copy (e.g. after reinstall).
let formDataRaw: [String: JSONValue] let formDataRaw: [String: JSONValue]
let formSchemaRaw: [[String: JSONValue]] let formSchemaRaw: [[String: JSONValue]]
/// {field_id: absolute_url} for image fields (presigned R2 / absolute static).
let formMedia: [String: String]
// Follow-up / re-inspection // Follow-up / re-inspection
let followUpRequired: Bool let followUpRequired: Bool
let followUpNote: String? let followUpNote: String?
@@ -323,6 +325,20 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
formSchemaRaw.map { dict in dict.mapValues { $0.anyValue } } formSchemaRaw.map { dict in dict.mapValues { $0.anyValue } }
} }
/// {relative_path: absolute_url} for image fields, derived by joining
/// formMedia (fieldId -> url) with the form values (fieldId -> path). Lets
/// the image renderer resolve a presigned URL from just the stored path.
var mediaURLByPath: [String: String] {
var out: [String: String] = [:]
let values = formValues
for (fid, url) in formMedia {
if let path = values[fid], !path.isEmpty {
out[path] = url
}
}
return out
}
var inspectionDateParsed: Date? { var inspectionDateParsed: Date? {
guard let str = inspectionDate else { return nil } guard let str = inspectionDate else { return nil }
// Server sends "yyyy-MM-dd'T'HH:mm:ss" with no timezone suffix. // Server sends "yyyy-MM-dd'T'HH:mm:ss" with no timezone suffix.
@@ -347,6 +363,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId)
inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? "" inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? ""
formDataRaw = (try? c.decode([String: JSONValue].self, forKey: .formData)) ?? [:] formDataRaw = (try? c.decode([String: JSONValue].self, forKey: .formData)) ?? [:]
formMedia = (try? c.decode([String: String].self, forKey: .formMedia)) ?? [:]
formSchemaRaw = (try? c.decode([[String: JSONValue]].self, forKey: .formSchema)) ?? [] formSchemaRaw = (try? c.decode([[String: JSONValue]].self, forKey: .formSchema)) ?? []
followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false
followUpNote = try? c.decode(String.self, forKey: .followUpNote) followUpNote = try? c.decode(String.self, forKey: .followUpNote)
@@ -357,6 +374,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
case areaId, areaName, status, overallScore case areaId, areaName, status, overallScore
case inspectionDate, completedAt, mobileLocalId, inspectorNotes case inspectionDate, completedAt, mobileLocalId, inspectorNotes
case formData, formSchema case formData, formSchema
case formMedia
case followUpRequired, followUpNote, parentInspectionId case followUpRequired, followUpNote, parentInspectionId
} }
@@ -405,6 +423,7 @@ struct APIIssueDetail: Decodable, Sendable {
let reportedByName: String? let reportedByName: String?
// Resolution photos uploaded via web or mobile resolve flow // Resolution photos uploaded via web or mobile resolve flow
let resultPhotos: [String] let resultPhotos: [String]
let resultPhotoUrls: [String] // absolute display URLs (presigned R2 / static)
// Phase E area and assignee context // Phase E area and assignee context
let areaName: String? let areaName: String?
let assignedToName: String? let assignedToName: String?
@@ -434,6 +453,7 @@ struct APIIssueDetail: Decodable, Sendable {
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)) ?? [] resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
resultPhotoUrls = (try? c.decode([String].self, forKey: .resultPhotoUrls)) ?? []
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)
handlerType = try? c.decode(String.self, forKey: .handlerType) handlerType = try? c.decode(String.self, forKey: .handlerType)
@@ -449,6 +469,7 @@ struct APIIssueDetail: Decodable, Sendable {
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, resultPhotos case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
case resultPhotoUrls
case areaName, assignedToName case areaName, assignedToName
case handlerType, handlerLabel case handlerType, handlerLabel
case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes
@@ -541,6 +562,10 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
let photoPath: String? // primary evidence photo let photoPath: String? // primary evidence photo
let mobilePhotoPaths: [String] // extra evidence photos from iPad let mobilePhotoPaths: [String] // extra evidence photos from iPad
let resultPhotos: [String] // resolution photos added via web let resultPhotos: [String] // resolution photos added via web
// Absolute display URLs (presigned R2 / absolute static). photoUrls order
// mirrors the evidence merge: [photoPath] + mobilePhotoPaths.
let photoUrls: [String]
let resultPhotoUrls: [String]
// Phase A resolution details from web // Phase A resolution details from web
let resultNotes: String? let resultNotes: String?
let verifiedAt: String? let verifiedAt: String?
@@ -573,6 +598,8 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
photoPath = try? c.decode(String.self, forKey: .photoPath) photoPath = try? c.decode(String.self, forKey: .photoPath)
mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? [] mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? []
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
photoUrls = (try? c.decode([String].self, forKey: .photoUrls)) ?? []
resultPhotoUrls = (try? c.decode([String].self, forKey: .resultPhotoUrls)) ?? []
resultNotes = try? c.decode(String.self, forKey: .resultNotes) resultNotes = try? c.decode(String.self, forKey: .resultNotes)
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)
@@ -592,6 +619,7 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
case id, status, severity, description, assignedTo case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, mobileLocalId case facilityId, facilityName, reportedAt, mobileLocalId
case photoPath, mobilePhotoPaths, resultPhotos case photoPath, mobilePhotoPaths, resultPhotos
case photoUrls, resultPhotoUrls
case resultNotes, verifiedAt, verificationNote, reportedByName case resultNotes, verifiedAt, verificationNote, reportedByName
case areaName, assignedToName case areaName, assignedToName
case handlerType, handlerLabel case handlerType, handlerLabel
+1
View File
@@ -679,6 +679,7 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack` root instead. The dashboard scheduled card puts its cover on the VStack; `MyInspectionsView` puts the scheduled "Start" cover on the `List`. | | 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack` root instead. The dashboard scheduled card puts its cover on the VStack; `MyInspectionsView` puts the scheduled "Start" cover on the `List`. |
| 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name``facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). | | 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name``facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). |
| 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. | | 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. |
| 67 | **Render server photos through `ServerConfig.mediaURL(absolute:path:)`, never by hand-building `current + "/static/" + path`** | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): `photo_urls`/`result_photo_urls` on issues (`APIAssignedIssue`/`APIIssueDetail``LocalIssue.photoServerUrls`/`resultPhotoServerUrls`, parallel to the path arrays), and `form_media` `{fieldId: url}` on inspection detail (`APIInspectionSummary.mediaURLByPath`, injected into the read-only grid via the `\.mediaURLByPath` environment for `PhotoThumbnailView`). The resolver prefers the absolute URL and falls back to `/static/` for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. |
--- ---
+23
View File
@@ -26,6 +26,11 @@ final class LocalIssue {
/// JSON-encoded array of resolution photo server paths (issue_result_photos bucket). /// JSON-encoded array of resolution photo server paths (issue_result_photos bucket).
/// Mirrors Issue.result_photos on the server shown under "Resolution Details". /// Mirrors Issue.result_photos on the server shown under "Resolution Details".
var resultPhotoServerPathsJSON: String = "[]" var resultPhotoServerPathsJSON: String = "[]"
/// JSON-encoded absolute display URLs (presigned R2 / absolute static),
/// parallel to photoServerPaths / resultPhotoServerPaths (same order).
/// Empty ("[]") when talking to an older server that omits the *_url fields.
var photoServerUrlsJSON: String = "[]"
var resultPhotoServerUrlsJSON: 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
@@ -115,6 +120,22 @@ final class LocalIssue {
} }
} }
/// Absolute display URLs parallel to photoServerPaths (same order).
var photoServerUrls: [String] {
get { (try? Self.jsonDecoder.decode([String].self,
from: Data(photoServerUrlsJSON.utf8))) ?? [] }
set { photoServerUrlsJSON = (try? String(data: Self.jsonEncoder.encode(newValue),
encoding: .utf8)) ?? "[]" }
}
/// Absolute display URLs parallel to resultPhotoServerPaths (same order).
var resultPhotoServerUrls: [String] {
get { (try? Self.jsonDecoder.decode([String].self,
from: Data(resultPhotoServerUrlsJSON.utf8))) ?? [] }
set { resultPhotoServerUrlsJSON = (try? String(data: Self.jsonEncoder.encode(newValue),
encoding: .utf8)) ?? "[]" }
}
var createdAt: Date var createdAt: Date
var syncStatus: String // "pending" | "synced" | "failed" var syncStatus: String // "pending" | "synced" | "failed"
var syncRetryCount: Int var syncRetryCount: Int
@@ -193,6 +214,8 @@ final class LocalIssue {
self.photoLocalPathsJSON = "[]" self.photoLocalPathsJSON = "[]"
self.photoServerPathsJSON = "[]" self.photoServerPathsJSON = "[]"
self.resultPhotoServerPathsJSON = "[]" self.resultPhotoServerPathsJSON = "[]"
self.photoServerUrlsJSON = "[]"
self.resultPhotoServerUrlsJSON = "[]"
self.createdAt = Date() self.createdAt = Date()
self.syncStatus = "pending" self.syncStatus = "pending"
self.syncRetryCount = 0 self.syncRetryCount = 0
@@ -9,8 +9,9 @@
// facility + template preselected; the schedule lifecycle (fulfil / roll-forward) // facility + template preselected; the schedule lifecycle (fulfil / roll-forward)
// stays server-driven. // stays server-driven.
// //
// All non-optional stored properties carry explicit inline defaults so SwiftData // Follows the same pattern as LocalFacility / LocalArea: a `.unique` serverId
// lightweight migration can add the new table without a migration plan. // WITHOUT an inline default (a default on the unique key breaks @Model's
// PersistentModel conformance) and a full init(from:)/update(from:) pair.
import Foundation import Foundation
import SwiftData import SwiftData
@@ -19,29 +20,66 @@ import SwiftData
final class LocalScheduledInspection { final class LocalScheduledInspection {
/// Server ID of the ScheduledInspection row stable unique identity. /// Server ID of the ScheduledInspection row stable unique identity.
@Attribute(.unique) var serverId: Int = 0 @Attribute(.unique) var serverId: Int
var facilityServerId: Int = 0 var facilityServerId: Int
var facilityName: String = "" var facilityName: String
var templateServerId: Int = 0 var templateServerId: Int
var templateName: String = "" var templateName: String
var inspectorId: Int? = nil var inspectorId: Int?
var frequency: String = "once" // once | daily | weekly | monthly var frequency: String // once | daily | weekly | monthly
var frequencyLabel: String = "" // human-readable label from server var frequencyLabel: String
/// Raw ISO date string "YYYY-MM-DD" from the server (display fallback). /// Raw server date string "YYYY-MM-DD" sortable (ISO strings sort
var dueDateString: String = "" /// chronologically) and the source for the parsed `nextDue`.
/// Parsed due date used for @Query sorting. Nil if the string was absent. var dueDateString: String
var nextDue: Date? = nil
var isOverdue: Bool = false var isOverdue: Bool
var notes: String? = nil var notes: String?
/// Last time this row was refreshed from the server pull. /// Last time this row was refreshed from the server pull.
var updatedAt: Date = Date() var updatedAt: Date
init(serverId: Int) { /// Parsed due date for display. Computed properties are not persisted by
self.serverId = serverId /// SwiftData; sort on `dueDateString` (not this) in @Query.
var nextDue: Date? {
Self.dateOnlyFormatter.date(from: dueDateString)
}
private static let dateOnlyFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
return f
}()
init(from api: APIScheduledInspection) {
self.serverId = api.id
self.facilityServerId = api.facilityId
self.facilityName = api.facilityName ?? ""
self.templateServerId = api.templateId
self.templateName = api.templateName ?? ""
self.inspectorId = api.inspectorId
self.frequency = api.frequency
self.frequencyLabel = api.frequencyLabel ?? ""
self.dueDateString = api.nextDueDate ?? ""
self.isOverdue = api.isOverdue
self.notes = api.notes
self.updatedAt = Date()
}
func update(from api: APIScheduledInspection) {
self.facilityServerId = api.facilityId
self.facilityName = api.facilityName ?? ""
self.templateServerId = api.templateId
self.templateName = api.templateName ?? ""
self.inspectorId = api.inspectorId
self.frequency = api.frequency
self.frequencyLabel = api.frequencyLabel ?? ""
self.dueDateString = api.nextDueDate ?? ""
self.isOverdue = api.isOverdue
self.notes = api.notes
self.updatedAt = Date()
} }
} }
+11 -25
View File
@@ -60,14 +60,6 @@ class SyncManager: ObservableObject {
return f return f
}() }()
/// Parses server date-only strings ("YYYY-MM-DD"), e.g. scheduled due dates.
nonisolated static let dateOnlyFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
return f
}()
static let shared = SyncManager() static let shared = SyncManager()
private init() {} private init() {}
@@ -761,6 +753,9 @@ class SyncManager: ObservableObject {
serverPaths.append(contentsOf: api.mobilePhotoPaths) serverPaths.append(contentsOf: api.mobilePhotoPaths)
existing.photoServerPaths = serverPaths existing.photoServerPaths = serverPaths
existing.resultPhotoServerPaths = api.resultPhotos existing.resultPhotoServerPaths = api.resultPhotos
// Absolute display URLs (presigned R2 / static) parallel arrays.
existing.photoServerUrls = api.photoUrls
existing.resultPhotoServerUrls = api.resultPhotoUrls
} else { } else {
// Insert new server-pulled issue // Insert new server-pulled issue
let local = LocalIssue( let local = LocalIssue(
@@ -800,6 +795,9 @@ class SyncManager: ObservableObject {
serverPaths.append(contentsOf: api.mobilePhotoPaths) serverPaths.append(contentsOf: api.mobilePhotoPaths)
local.photoServerPaths = serverPaths local.photoServerPaths = serverPaths
local.resultPhotoServerPaths = api.resultPhotos local.resultPhotoServerPaths = api.resultPhotos
// Absolute display URLs (presigned R2 / static) parallel arrays.
local.photoServerUrls = api.photoUrls
local.resultPhotoServerUrls = api.resultPhotoUrls
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
@@ -852,23 +850,11 @@ class SyncManager: ObservableObject {
for row in allLocal { byServerId[row.serverId] = row } for row in allLocal { byServerId[row.serverId] = row }
for api in apiRows { for api in apiRows {
let row = byServerId[api.id] ?? { if let existing = byServerId[api.id] {
let r = LocalScheduledInspection(serverId: api.id) existing.update(from: api)
context.insert(r) } else {
return r context.insert(LocalScheduledInspection(from: api))
}() }
row.facilityServerId = api.facilityId
row.facilityName = api.facilityName ?? ""
row.templateServerId = api.templateId
row.templateName = api.templateName ?? ""
row.inspectorId = api.inspectorId
row.frequency = api.frequency
row.frequencyLabel = api.frequencyLabel ?? ""
row.dueDateString = api.nextDueDate ?? ""
row.nextDue = api.nextDueDate.flatMap { Self.dateOnlyFormatter.date(from: $0) }
row.isOverdue = api.isOverdue
row.notes = api.notes
row.updatedAt = Date()
} }
// Delete rows the server no longer returns. // Delete rows the server no longer returns.
+10
View File
@@ -47,6 +47,16 @@ nonisolated enum ServerConfig {
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? "" let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
return ServerOption(rawValue: raw) ?? .primary return ServerOption(rawValue: raw) ?? .primary
} }
/// Resolve a photo URL, preferring an absolute server-provided URL
/// (presigned R2 on the s3 backend, absolute-static on local) and falling
/// back to building one from the relative 'uploads/...' key for older
/// servers that don't send the *_url fields.
nonisolated static func mediaURL(absolute: String?, path: String) -> URL? {
if let a = absolute, !a.isEmpty { return URL(string: a) }
guard !path.isEmpty else { return nil }
return URL(string: current + "/static/" + path)
}
} }
// MARK: - App-wide constants // MARK: - App-wide constants
+13 -4
View File
@@ -497,9 +497,13 @@ struct IssueDetailView: View {
// Resolution Photos (server-side, read display) // Resolution Photos (server-side, read display)
if !issue.resultPhotoServerPaths.isEmpty { if !issue.resultPhotoServerPaths.isEmpty {
Section("Resolution Photos (\(issue.resultPhotoServerPaths.count))") { Section("Resolution Photos (\(issue.resultPhotoServerPaths.count))") {
ForEach(issue.resultPhotoServerPaths, id: \.self) { relativePath in let paths = issue.resultPhotoServerPaths
let urls = issue.resultPhotoServerUrls
ForEach(paths.indices, id: \.self) { idx in
RetryablePhotoView( RetryablePhotoView(
url: URL(string: ServerConfig.current + "/static/" + relativePath) url: ServerConfig.mediaURL(
absolute: idx < urls.count ? urls[idx] : nil,
path: paths[idx])
) )
} }
} }
@@ -571,9 +575,13 @@ struct IssueDetailView: View {
// Synced: show server photos only // Synced: show server photos only
if !issue.photoServerPaths.isEmpty { if !issue.photoServerPaths.isEmpty {
Section("Photos (\(issue.photoServerPaths.count))") { Section("Photos (\(issue.photoServerPaths.count))") {
ForEach(issue.photoServerPaths, id: \.self) { relativePath in let paths = issue.photoServerPaths
let urls = issue.photoServerUrls
ForEach(paths.indices, id: \.self) { idx in
RetryablePhotoView( RetryablePhotoView(
url: URL(string: ServerConfig.current + "/static/" + relativePath) url: ServerConfig.mediaURL(
absolute: idx < urls.count ? urls[idx] : nil,
path: paths[idx])
) )
} }
} }
@@ -748,6 +756,7 @@ struct IssueDetailView: View {
// Refresh resolution photos from server // Refresh resolution photos from server
if !detail.resultPhotos.isEmpty { if !detail.resultPhotos.isEmpty {
issue.resultPhotoServerPaths = detail.resultPhotos issue.resultPhotoServerPaths = detail.resultPhotos
issue.resultPhotoServerUrls = detail.resultPhotoUrls
} }
try? context.save() try? context.save()
} catch { } catch {
@@ -452,6 +452,7 @@ struct HistoryDetailView: View {
schema: formSchema, schema: formSchema,
formValues: savedValues formValues: savedValues
) )
.environment(\.mediaURLByPath, inspection.mediaURLByPath)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
} }
@@ -928,6 +929,7 @@ struct ReadOnlyCellView: View {
struct PhotoThumbnailView: View { struct PhotoThumbnailView: View {
let value: String let value: String
@Environment(\.mediaURLByPath) private var mediaURLByPath
@State private var showLightbox = false @State private var showLightbox = false
var body: some View { var body: some View {
@@ -954,7 +956,7 @@ struct PhotoThumbnailView: View {
.font(.system(size: 11)).foregroundStyle(.secondary) .font(.system(size: 11)).foregroundStyle(.secondary)
} }
} else if value.hasPrefix("uploads/") { } else if value.hasPrefix("uploads/") {
let url = URL(string: "\(ServerConfig.current)/static/\(value)") let url = ServerConfig.mediaURL(absolute: mediaURLByPath[value], path: value)
thumbnailButton { thumbnailButton {
AsyncImage(url: url) { phase in AsyncImage(url: url) { phase in
switch phase { switch phase {
@@ -1042,3 +1044,19 @@ struct MailComposeView: UIViewControllerRepresentable {
} }
} }
} }
// MARK: - Media URL environment
// Injects a {relative_path: absolute_url} map (from APIInspectionSummary.mediaURLByPath)
// so image cells deep inside the read-only grid can resolve presigned R2 URLs
// without threading field IDs through every layer. Empty map the resolver
// falls back to building a /static/ URL from the relative path.
private struct MediaURLByPathKey: EnvironmentKey {
static let defaultValue: [String: String] = [:]
}
extension EnvironmentValues {
var mediaURLByPath: [String: String] {
get { self[MediaURLByPathKey.self] }
set { self[MediaURLByPathKey.self] = newValue }
}
}