Jul 14 - Using CDN - update
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user