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).
let formDataRaw: [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
let followUpRequired: Bool
let followUpNote: String?
@@ -323,6 +325,20 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
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? {
guard let str = inspectionDate else { return nil }
// 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)
inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? ""
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)) ?? []
followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false
followUpNote = try? c.decode(String.self, forKey: .followUpNote)
@@ -357,6 +374,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
case areaId, areaName, status, overallScore
case inspectionDate, completedAt, mobileLocalId, inspectorNotes
case formData, formSchema
case formMedia
case followUpRequired, followUpNote, parentInspectionId
}
@@ -405,6 +423,7 @@ struct APIIssueDetail: Decodable, Sendable {
let reportedByName: String?
// Resolution photos uploaded via web or mobile resolve flow
let resultPhotos: [String]
let resultPhotoUrls: [String] // absolute display URLs (presigned R2 / static)
// Phase E area and assignee context
let areaName: String?
let assignedToName: String?
@@ -434,6 +453,7 @@ struct APIIssueDetail: Decodable, Sendable {
verificationNote = try? c.decode(String.self, forKey: .verificationNote)
reportedByName = try? c.decode(String.self, forKey: .reportedByName)
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
resultPhotoUrls = (try? c.decode([String].self, forKey: .resultPhotoUrls)) ?? []
areaName = try? c.decode(String.self, forKey: .areaName)
assignedToName = try? c.decode(String.self, forKey: .assignedToName)
handlerType = try? c.decode(String.self, forKey: .handlerType)
@@ -449,6 +469,7 @@ struct APIIssueDetail: Decodable, Sendable {
case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, resolvedAt
case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
case resultPhotoUrls
case areaName, assignedToName
case handlerType, handlerLabel
case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes
@@ -541,6 +562,10 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
let photoPath: String? // primary evidence photo
let mobilePhotoPaths: [String] // extra evidence photos from iPad
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
let resultNotes: String?
let verifiedAt: String?
@@ -573,6 +598,8 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
photoPath = try? c.decode(String.self, forKey: .photoPath)
mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? []
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)
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
verificationNote = try? c.decode(String.self, forKey: .verificationNote)
@@ -592,6 +619,7 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, mobileLocalId
case photoPath, mobilePhotoPaths, resultPhotos
case photoUrls, resultPhotoUrls
case resultNotes, verifiedAt, verificationNote, reportedByName
case areaName, assignedToName
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`. |
| 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. |
| 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).
/// Mirrors Issue.result_photos on the server shown under "Resolution Details".
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
// 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 syncStatus: String // "pending" | "synced" | "failed"
var syncRetryCount: Int
@@ -193,6 +214,8 @@ final class LocalIssue {
self.photoLocalPathsJSON = "[]"
self.photoServerPathsJSON = "[]"
self.resultPhotoServerPathsJSON = "[]"
self.photoServerUrlsJSON = "[]"
self.resultPhotoServerUrlsJSON = "[]"
self.createdAt = Date()
self.syncStatus = "pending"
self.syncRetryCount = 0
@@ -9,8 +9,9 @@
// facility + template preselected; the schedule lifecycle (fulfil / roll-forward)
// stays server-driven.
//
// All non-optional stored properties carry explicit inline defaults so SwiftData
// lightweight migration can add the new table without a migration plan.
// Follows the same pattern as LocalFacility / LocalArea: a `.unique` serverId
// 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 SwiftData
@@ -19,29 +20,66 @@ import SwiftData
final class LocalScheduledInspection {
/// 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 facilityName: String = ""
var templateServerId: Int = 0
var templateName: String = ""
var inspectorId: Int? = nil
var facilityServerId: Int
var facilityName: String
var templateServerId: Int
var templateName: String
var inspectorId: Int?
var frequency: String = "once" // once | daily | weekly | monthly
var frequencyLabel: String = "" // human-readable label from server
var frequency: String // once | daily | weekly | monthly
var frequencyLabel: String
/// Raw ISO date string "YYYY-MM-DD" from the server (display fallback).
var dueDateString: String = ""
/// Parsed due date used for @Query sorting. Nil if the string was absent.
var nextDue: Date? = nil
/// Raw server date string "YYYY-MM-DD" sortable (ISO strings sort
/// chronologically) and the source for the parsed `nextDue`.
var dueDateString: String
var isOverdue: Bool = false
var notes: String? = nil
var isOverdue: Bool
var notes: String?
/// Last time this row was refreshed from the server pull.
var updatedAt: Date = Date()
var updatedAt: Date
init(serverId: Int) {
self.serverId = serverId
/// Parsed due date for display. Computed properties are not persisted by
/// 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
}()
/// 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()
private init() {}
@@ -761,6 +753,9 @@ class SyncManager: ObservableObject {
serverPaths.append(contentsOf: api.mobilePhotoPaths)
existing.photoServerPaths = serverPaths
existing.resultPhotoServerPaths = api.resultPhotos
// Absolute display URLs (presigned R2 / static) parallel arrays.
existing.photoServerUrls = api.photoUrls
existing.resultPhotoServerUrls = api.resultPhotoUrls
} else {
// Insert new server-pulled issue
let local = LocalIssue(
@@ -800,6 +795,9 @@ class SyncManager: ObservableObject {
serverPaths.append(contentsOf: api.mobilePhotoPaths)
local.photoServerPaths = serverPaths
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,
let date = Self.isoFormatter.date(from: ts) {
local.createdAt = date
@@ -852,23 +850,11 @@ class SyncManager: ObservableObject {
for row in allLocal { byServerId[row.serverId] = row }
for api in apiRows {
let row = byServerId[api.id] ?? {
let r = LocalScheduledInspection(serverId: api.id)
context.insert(r)
return r
}()
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()
if let existing = byServerId[api.id] {
existing.update(from: api)
} else {
context.insert(LocalScheduledInspection(from: api))
}
}
// Delete rows the server no longer returns.
+10
View File
@@ -47,6 +47,16 @@ nonisolated enum ServerConfig {
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
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
+13 -4
View File
@@ -497,9 +497,13 @@ struct IssueDetailView: View {
// Resolution Photos (server-side, read display)
if !issue.resultPhotoServerPaths.isEmpty {
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(
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
if !issue.photoServerPaths.isEmpty {
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(
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
if !detail.resultPhotos.isEmpty {
issue.resultPhotoServerPaths = detail.resultPhotos
issue.resultPhotoServerUrls = detail.resultPhotoUrls
}
try? context.save()
} catch {
@@ -452,6 +452,7 @@ struct HistoryDetailView: View {
schema: formSchema,
formValues: savedValues
)
.environment(\.mediaURLByPath, inspection.mediaURLByPath)
.padding(.horizontal, 24)
}
}
@@ -928,6 +929,7 @@ struct ReadOnlyCellView: View {
struct PhotoThumbnailView: View {
let value: String
@Environment(\.mediaURLByPath) private var mediaURLByPath
@State private var showLightbox = false
var body: some View {
@@ -954,7 +956,7 @@ struct PhotoThumbnailView: View {
.font(.system(size: 11)).foregroundStyle(.secondary)
}
} else if value.hasPrefix("uploads/") {
let url = URL(string: "\(ServerConfig.current)/static/\(value)")
let url = ServerConfig.mediaURL(absolute: mediaURLByPath[value], path: value)
thumbnailButton {
AsyncImage(url: url) { phase in
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 }
}
}