05/02 Phase B
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// Models/LocalArea.swift
|
||||
// ----------------------
|
||||
// SwiftData model for locally cached facility areas.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class LocalArea {
|
||||
@Attribute(.unique) var serverId: Int
|
||||
var facilityServerId: Int
|
||||
var name: String
|
||||
var areaType: String
|
||||
var lastSyncedAt: Date
|
||||
|
||||
/// Back-reference to parent facility
|
||||
var facility: LocalFacility?
|
||||
|
||||
init(from api: APIArea) {
|
||||
self.serverId = api.id
|
||||
self.facilityServerId = api.facilityId
|
||||
self.name = api.name
|
||||
self.areaType = api.areaType
|
||||
self.lastSyncedAt = Date()
|
||||
}
|
||||
|
||||
func update(from api: APIArea) {
|
||||
self.name = api.name
|
||||
self.areaType = api.areaType
|
||||
self.lastSyncedAt = Date()
|
||||
}
|
||||
|
||||
/// Human-readable type label
|
||||
var areaTypeLabel: String {
|
||||
switch areaType {
|
||||
case "restroom": return "Restroom"
|
||||
case "lobby": return "Lobby"
|
||||
case "hallway": return "Hallway"
|
||||
case "office": return "Office"
|
||||
case "kitchen": return "Kitchen"
|
||||
case "storage": return "Storage"
|
||||
case "floor": return "Floor"
|
||||
case "outdoor": return "Outdoor"
|
||||
default: return "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Models/LocalFacility.swift
|
||||
// --------------------------
|
||||
// SwiftData model for locally cached facilities.
|
||||
// Populated by SyncManager.pullReferenceData() and never written by the inspector.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class LocalFacility {
|
||||
/// The server's primary key — used to match server records to local ones
|
||||
@Attribute(.unique) var serverId: Int
|
||||
var name: String
|
||||
var address: String
|
||||
var contactPerson: String
|
||||
var projectId: Int
|
||||
var projectName: String
|
||||
var isActive: Bool
|
||||
var lastSyncedAt: Date
|
||||
|
||||
/// Areas are stored as a separate model, linked by facilityServerId
|
||||
@Relationship(deleteRule: .cascade) var areas: [LocalArea]
|
||||
|
||||
init(from api: APIFacility) {
|
||||
self.serverId = api.id
|
||||
self.name = api.name
|
||||
self.address = api.address
|
||||
self.contactPerson = api.contactPerson
|
||||
self.projectId = api.projectId ?? 0
|
||||
self.projectName = api.projectName ?? "No Contract"
|
||||
self.isActive = api.isActive
|
||||
self.lastSyncedAt = Date()
|
||||
self.areas = []
|
||||
}
|
||||
|
||||
func update(from api: APIFacility) {
|
||||
self.name = api.name
|
||||
self.address = api.address
|
||||
self.contactPerson = api.contactPerson
|
||||
self.projectId = api.projectId ?? 0
|
||||
self.projectName = api.projectName ?? "No Contract"
|
||||
self.isActive = api.isActive
|
||||
self.lastSyncedAt = Date()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Models/LocalInspection.swift
|
||||
// ----------------------------
|
||||
// SwiftData model for locally stored inspections.
|
||||
// Created immediately when the inspector starts a new inspection.
|
||||
// Written entirely offline; synced to server when connectivity returns.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class LocalInspection {
|
||||
|
||||
// ── Identity ──────────────────────────────────────────────────────────
|
||||
/// UUID generated on device — stable local identity and idempotency key
|
||||
@Attribute(.unique) var localId: String
|
||||
/// Set by server after successful sync; nil until then
|
||||
var serverId: Int?
|
||||
|
||||
// ── Foreign keys (server IDs, from cached reference data) ─────────────
|
||||
var templateServerId: Int
|
||||
var facilityServerId: Int
|
||||
var areaServerId: Int?
|
||||
var inspectorUserId: Int
|
||||
|
||||
// ── Inspection data ────────────────────────────────────────────────────
|
||||
/// "draft" | "completed" | "synced" | "sync_failed"
|
||||
var status: String
|
||||
/// JSON dict of { field_id: value } — same shape as server form_data
|
||||
var formDataJSON: String
|
||||
var inspectorNotes: String
|
||||
var overallScore: Double?
|
||||
var inspectionDate: Date
|
||||
var completedAt: Date?
|
||||
var createdAt: Date
|
||||
var lastModifiedAt: Date
|
||||
|
||||
// ── Sync ──────────────────────────────────────────────────────────────
|
||||
var syncStatus: String // "pending" | "synced" | "failed"
|
||||
var syncErrorMessage: String?
|
||||
var syncRetryCount: Int
|
||||
|
||||
// ── Relationships ──────────────────────────────────────────────────────
|
||||
@Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto]
|
||||
@Relationship(deleteRule: .cascade) var localIssues: [LocalIssue]
|
||||
|
||||
init(
|
||||
templateServerId: Int,
|
||||
facilityServerId: Int,
|
||||
areaServerId: Int?,
|
||||
inspectorUserId: Int
|
||||
) {
|
||||
self.localId = UUID().uuidString
|
||||
self.serverId = nil
|
||||
self.templateServerId = templateServerId
|
||||
self.facilityServerId = facilityServerId
|
||||
self.areaServerId = areaServerId
|
||||
self.inspectorUserId = inspectorUserId
|
||||
self.status = "draft"
|
||||
self.formDataJSON = "{}"
|
||||
self.inspectorNotes = ""
|
||||
self.overallScore = nil
|
||||
self.inspectionDate = Date()
|
||||
self.completedAt = nil
|
||||
self.createdAt = Date()
|
||||
self.lastModifiedAt = Date()
|
||||
self.syncStatus = "pending"
|
||||
self.syncErrorMessage = nil
|
||||
self.syncRetryCount = 0
|
||||
self.pendingPhotos = []
|
||||
self.localIssues = []
|
||||
}
|
||||
|
||||
// ── Form data helpers ──────────────────────────────────────────────────
|
||||
|
||||
var formData: [String: Any] {
|
||||
get {
|
||||
guard let data = formDataJSON.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return [:] }
|
||||
return dict
|
||||
}
|
||||
set {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: newValue),
|
||||
let str = String(data: data, encoding: .utf8) {
|
||||
formDataJSON = str
|
||||
lastModifiedAt = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setValue(_ value: Any, forFieldId fieldId: String) {
|
||||
var current = formData
|
||||
current[fieldId] = value
|
||||
formData = current
|
||||
}
|
||||
|
||||
func getValue(forFieldId fieldId: String) -> Any? {
|
||||
formData[fieldId]
|
||||
}
|
||||
|
||||
// ── Score calculation (mirrors Python _compute_score_from_form) ────────
|
||||
|
||||
func computeScore(fromSchema schema: [[String: Any]]) -> Double? {
|
||||
let scoreable = schema.filter {
|
||||
["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "")
|
||||
}
|
||||
guard !scoreable.isEmpty else { return nil }
|
||||
|
||||
var total = 0
|
||||
var earned = 0
|
||||
|
||||
for field in scoreable {
|
||||
guard let fid = field["id"] as? String ?? (field["id"].map { "\($0)" }),
|
||||
let ftype = field["type"] as? String
|
||||
else { continue }
|
||||
|
||||
let val = formData[fid].map { "\($0)" } ?? ""
|
||||
|
||||
switch ftype {
|
||||
case "rating":
|
||||
if let v = Int(val), v > 0 {
|
||||
earned += v
|
||||
total += 5
|
||||
}
|
||||
case "checkbox":
|
||||
total += 1
|
||||
if val == "true" { earned += 1 }
|
||||
case "radio":
|
||||
total += 1
|
||||
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) {
|
||||
earned += 1
|
||||
}
|
||||
case "pass_fail":
|
||||
if val.isEmpty { continue }
|
||||
total += 1
|
||||
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) {
|
||||
earned += 1
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
guard total > 0 else { return nil }
|
||||
return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for rounding
|
||||
extension Double {
|
||||
func rounded(toPlaces places: Int) -> Double {
|
||||
let divisor = pow(10.0, Double(places))
|
||||
return (self * divisor).rounded() / divisor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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 areaServerId: Int
|
||||
var severity: String // "low" | "medium" | "high" | "critical"
|
||||
var issueDescription: String
|
||||
var photoLocalPath: String? // local file path before upload
|
||||
var photoServerPath: String? // server path after upload
|
||||
|
||||
var createdAt: Date
|
||||
var syncStatus: String // "pending" | "synced" | "failed"
|
||||
var syncRetryCount: Int
|
||||
var syncErrorMessage: String?
|
||||
|
||||
var inspection: LocalInspection?
|
||||
|
||||
init(
|
||||
inspectionLocalId: String,
|
||||
areaServerId: Int,
|
||||
severity: String,
|
||||
description: String
|
||||
) {
|
||||
self.localId = UUID().uuidString
|
||||
self.serverId = nil
|
||||
self.inspectionLocalId = inspectionLocalId
|
||||
self.areaServerId = areaServerId
|
||||
self.severity = severity
|
||||
self.issueDescription = description
|
||||
self.photoLocalPath = nil
|
||||
self.photoServerPath = nil
|
||||
self.createdAt = Date()
|
||||
self.syncStatus = "pending"
|
||||
self.syncRetryCount = 0
|
||||
self.syncErrorMessage = nil
|
||||
}
|
||||
|
||||
var severityColor: String {
|
||||
switch severity {
|
||||
case "critical": return "red"
|
||||
case "high": return "orange"
|
||||
case "medium": return "yellow"
|
||||
default: return "blue"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Models/LocalTemplate.swift
|
||||
// --------------------------
|
||||
// SwiftData model for locally cached inspection templates.
|
||||
// The form_schema is stored as a raw JSON string and decoded on demand.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class LocalTemplate {
|
||||
@Attribute(.unique) var serverId: Int
|
||||
var name: String
|
||||
var templateDescription: String
|
||||
var frequency: String
|
||||
/// Raw JSON string of the form_schema array — decoded on demand
|
||||
var formSchemaJSON: String
|
||||
var lastSyncedAt: Date
|
||||
|
||||
init(from summary: APITemplateSummary) {
|
||||
self.serverId = summary.id
|
||||
self.name = summary.name
|
||||
self.templateDescription = summary.description
|
||||
self.frequency = summary.frequency
|
||||
self.formSchemaJSON = "[]"
|
||||
self.lastSyncedAt = Date()
|
||||
}
|
||||
|
||||
func updateSummary(from summary: APITemplateSummary) {
|
||||
self.name = summary.name
|
||||
self.templateDescription = summary.description
|
||||
self.frequency = summary.frequency
|
||||
self.lastSyncedAt = Date()
|
||||
}
|
||||
|
||||
func updateSchema(from template: APITemplate) {
|
||||
// Re-serialize the form_schema to JSON for local storage
|
||||
if let data = try? JSONSerialization.data(withJSONObject: template.formSchema.map({ dict in
|
||||
dict.mapValues { $0.value }
|
||||
})),
|
||||
let str = String(data: data, encoding: .utf8) {
|
||||
self.formSchemaJSON = str
|
||||
}
|
||||
self.lastSyncedAt = Date()
|
||||
}
|
||||
|
||||
/// Decode the stored JSON string back into an array of field dictionaries.
|
||||
/// Returns an empty array if the JSON is invalid.
|
||||
var formSchema: [[String: Any]] {
|
||||
guard let data = formSchemaJSON.data(using: .utf8),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||
else { return [] }
|
||||
return array
|
||||
}
|
||||
|
||||
var frequencyLabel: String {
|
||||
switch frequency {
|
||||
case "daily": return "Daily"
|
||||
case "weekly": return "Weekly"
|
||||
case "monthly": return "Monthly"
|
||||
case "quarterly": return "Quarterly"
|
||||
default: return frequency.capitalized
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Models/PendingPhoto.swift
|
||||
// -------------------------
|
||||
// SwiftData model for photos waiting to be uploaded to the server.
|
||||
// Photos are saved locally first, uploaded during sync, then the
|
||||
// local path reference is replaced with the server path.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class PendingPhoto {
|
||||
|
||||
@Attribute(.unique) var localId: String
|
||||
/// Absolute path in app's Documents/JQC/Photos/ directory
|
||||
var localFilePath: String
|
||||
/// "inspection" | "issue"
|
||||
var entityType: String
|
||||
/// References LocalInspection.localId or LocalIssue.localId
|
||||
var entityLocalId: String
|
||||
/// For inspection form image fields — the field's id string
|
||||
var fieldId: String?
|
||||
/// Populated after successful upload
|
||||
var serverPath: String?
|
||||
/// "pending" | "uploaded" | "failed"
|
||||
var uploadStatus: String
|
||||
var createdAt: Date
|
||||
|
||||
var inspection: LocalInspection?
|
||||
|
||||
init(
|
||||
localFilePath: String,
|
||||
entityType: String,
|
||||
entityLocalId: String,
|
||||
fieldId: String? = nil
|
||||
) {
|
||||
self.localId = UUID().uuidString
|
||||
self.localFilePath = localFilePath
|
||||
self.entityType = entityType
|
||||
self.entityLocalId = entityLocalId
|
||||
self.fieldId = fieldId
|
||||
self.serverPath = nil
|
||||
self.uploadStatus = "pending"
|
||||
self.createdAt = Date()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Models/SyncQueueEntry.swift
|
||||
// ---------------------------
|
||||
// SwiftData model for the outbox sync queue.
|
||||
// Every offline write (inspection, issue, photo) enqueues an entry here.
|
||||
// SyncManager processes entries in FIFO order when connectivity is restored.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class SyncQueueEntry {
|
||||
|
||||
@Attribute(.unique) var entryId: String
|
||||
var createdAt: Date
|
||||
/// "inspection" | "issue" | "photo"
|
||||
var entityType: String
|
||||
/// References the entity's localId
|
||||
var localId: String
|
||||
/// "pending" | "in_flight" | "synced" | "failed"
|
||||
var syncStatus: String
|
||||
var retryCount: Int
|
||||
var lastAttemptAt: Date?
|
||||
var lastErrorMessage: String?
|
||||
/// JSON-serialized payload to POST to the server
|
||||
var payloadJSON: String
|
||||
|
||||
init(entityType: String, localId: String, payloadJSON: String) {
|
||||
self.entryId = UUID().uuidString
|
||||
self.createdAt = Date()
|
||||
self.entityType = entityType
|
||||
self.localId = localId
|
||||
self.syncStatus = "pending"
|
||||
self.retryCount = 0
|
||||
self.lastAttemptAt = nil
|
||||
self.lastErrorMessage = nil
|
||||
self.payloadJSON = payloadJSON
|
||||
}
|
||||
|
||||
var payload: [String: Any] {
|
||||
guard let data = payloadJSON.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return [:] }
|
||||
return dict
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user