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).
|
||||
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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user