05/02 Phase B

This commit is contained in:
Nguyen Ngo
2026-05-02 11:46:28 -04:00
parent ef0a6448cf
commit 8bdcbecd73
22 changed files with 3262 additions and 69 deletions
+47
View File
@@ -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"
}
}
}
+45
View File
@@ -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()
}
}
+154
View File
@@ -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
}
}
+56
View File
@@ -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"
}
}
}
+64
View File
@@ -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
}
}
}
+45
View File
@@ -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()
}
}
+45
View File
@@ -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
}
}