// Models/LocalIssue.swift // ----------------------- // SwiftData model for issues flagged during an offline inspection. import Foundation import SwiftData @Model final class LocalIssue { @Attribute(.unique) var localId: String var serverId: Int? var inspectionLocalId: String // references LocalInspection.localId var facilityServerId: Int // facility this issue belongs to /// Server ID of the area this issue was flagged in. Set when flagged during /// an inspection that has an area selected. Nil for standalone issues. var areaServerId: Int? var severity: String // "low" | "medium" | "high" | "critical" var issueDescription: String var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification" /// JSON-encoded array of absolute local file paths, e.g. ["/var/.../photo1.jpg", ...] var photoLocalPathsJSON: String = "[]" /// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...] var photoServerPathsJSON: String = "[]" // Shared coders — JSONDecoder/Encoder init is expensive (parses locale and // calendar info). Allocating them inside computed property getters means // a new instance per access; on a list showing 50 issues each with two // JSON-backed arrays that's 200 allocations per render pass. Static // instances are created once and reused for the lifetime of the app. private static let jsonDecoder = JSONDecoder() private static let jsonEncoder = JSONEncoder() // Lightweight decode cache — avoids re-parsing identical JSON strings. // SwiftData may call the getter multiple times per render pass (once for // isEmpty, once for count, once for ForEach). Caching the last-decoded // value by JSON string identity means the JSON parse only happens when // the underlying data actually changes. // @Transient tells SwiftData not to persist these — they're in-memory only. // Lightweight decode cache — split into key+value pairs because SwiftData's // @Transient macro does not support tuple types. Two separate @Transient // properties per cache entry achieve the same result with no schema impact. @Transient private var _cachedLocalKey: String = "" @Transient private var _cachedLocalValue: [String] = [] @Transient private var _cachedServerKey: String = "" @Transient private var _cachedServerValue: [String] = [] /// Decoded local photo paths (up to 5) var photoLocalPaths: [String] { get { if _cachedLocalKey == photoLocalPathsJSON, !_cachedLocalKey.isEmpty { return _cachedLocalValue } let decoded = (try? Self.jsonDecoder.decode([String].self, from: Data(photoLocalPathsJSON.utf8))) ?? [] _cachedLocalKey = photoLocalPathsJSON _cachedLocalValue = decoded return decoded } set { let encoded = (try? String(data: Self.jsonEncoder.encode(newValue), encoding: .utf8)) ?? "[]" photoLocalPathsJSON = encoded _cachedLocalKey = encoded _cachedLocalValue = newValue } } /// Decoded server photo paths var photoServerPaths: [String] { get { if _cachedServerKey == photoServerPathsJSON, !_cachedServerKey.isEmpty { return _cachedServerValue } let decoded = (try? Self.jsonDecoder.decode([String].self, from: Data(photoServerPathsJSON.utf8))) ?? [] _cachedServerKey = photoServerPathsJSON _cachedServerValue = decoded return decoded } set { let encoded = (try? String(data: Self.jsonEncoder.encode(newValue), encoding: .utf8)) ?? "[]" photoServerPathsJSON = encoded _cachedServerKey = encoded _cachedServerValue = newValue } } var createdAt: Date var syncStatus: String // "pending" | "synced" | "failed" var syncRetryCount: Int var syncErrorMessage: String? // ── Phase A additions — persisted from server response ──────────────── // All new String?/Date? fields default to nil; SwiftData lightweight migration // supports nil-default optional properties without a migration plan. /// Facility display name cached from the server response. Used when the /// local facility reference cache has been cleared (Settings → Clear Cache). var facilityNameCache: String? /// Server-side reported_at timestamp. More accurate than createdAt for /// server-pulled issues because createdAt falls back to device time when /// the issue was created offline. var serverReportedAt: Date? /// Resolution notes added by web staff after fixing the issue. var resultNotes: String? /// Timestamp when a director/admin verified the fix. var verifiedAt: Date? /// Note left by the verifier. var verificationNote: String? /// Display name of the user who originally reported this issue. var reportedByName: String? /// Name of the area this issue was flagged in (e.g. "Main Lobby"). /// Set from server response; nil for standalone issues without area context. var areaNameCache: String? /// Display name of the user currently assigned to this issue. /// Nil when unassigned. Updated on every pullAssignedIssues(). var assignedToName: String? // Explicit inverse declared so SwiftData has an unambiguous relationship // graph at schema-build time. Without it the relationship is implicit, // which can cause migration warnings or incorrect cascade behaviour on some // SwiftData versions. The deleteRule is .nullify (default) — deleting the // parent inspection cascades via LocalInspection.localIssues; this side // only nullifies the back-pointer. @Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues) var inspection: LocalInspection? init( inspectionLocalId: String, facilityServerId: Int, severity: String, description: String ) { self.localId = UUID().uuidString self.serverId = nil self.inspectionLocalId = inspectionLocalId self.facilityServerId = facilityServerId self.areaServerId = nil self.severity = severity self.issueDescription = description self.issueStatus = "open" self.photoLocalPathsJSON = "[]" self.photoServerPathsJSON = "[]" self.createdAt = Date() self.syncStatus = "pending" self.syncRetryCount = 0 self.syncErrorMessage = nil // Phase A fields — nil by default self.facilityNameCache = nil self.serverReportedAt = nil self.resultNotes = nil self.verifiedAt = nil self.verificationNote = nil self.reportedByName = nil self.areaNameCache = nil self.assignedToName = nil } var severityColor: String { switch severity { case "critical": return "red" case "high": return "orange" case "medium": return "yellow" default: return "blue" } } }