Files
JQC_iOS_App/JanitorialQC/Models/LocalIssue.swift
T

206 lines
8.7 KiB
Swift

// 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 = "[]"
/// 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 = "[]"
// 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] = []
@Transient private var _cachedResultKey: String = ""
@Transient private var _cachedResultValue: [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
}
}
/// Decoded resolution photo server paths (issue_result_photos bucket).
/// Shown under "Resolution Details" — mirrors Issue.result_photos on the web.
var resultPhotoServerPaths: [String] {
get {
if _cachedResultKey == resultPhotoServerPathsJSON, !_cachedResultKey.isEmpty {
return _cachedResultValue
}
let decoded = (try? Self.jsonDecoder.decode([String].self,
from: Data(resultPhotoServerPathsJSON.utf8))) ?? []
_cachedResultKey = resultPhotoServerPathsJSON
_cachedResultValue = decoded
return decoded
}
set {
let encoded = (try? String(data: Self.jsonEncoder.encode(newValue),
encoding: .utf8)) ?? "[]"
resultPhotoServerPathsJSON = encoded
_cachedResultKey = encoded
_cachedResultValue = 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.resultPhotoServerPathsJSON = "[]"
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"
}
}
}