88 lines
3.6 KiB
Swift
88 lines
3.6 KiB
Swift
// 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"
|
|
///
|
|
/// "failed" is TERMINAL: it means every upload attempt was used up, and it
|
|
/// is what lets processInspectionQueue stop waiting and submit without this
|
|
/// photo. A *transient* error must therefore leave the row "pending" — see
|
|
/// `uploadRetryCount`. Marking "failed" on the first error is what turned a
|
|
/// single dropped connection into a permanently lost evidence photo.
|
|
var uploadStatus: String
|
|
/// Consecutive failed upload attempts. The row stays "pending" — and so
|
|
/// keeps blocking its parent's submission — until this reaches
|
|
/// `SyncManager.maxPhotoUploadAttempts`.
|
|
///
|
|
/// Non-optional with an inline default so SwiftData migrates lightweight
|
|
/// (CLAUDE.md rule 8): rows in existing stores read as 0.
|
|
var uploadRetryCount: Int = 0
|
|
var createdAt: Date
|
|
|
|
/// Why the last upload attempt failed, for diagnosis.
|
|
///
|
|
/// Nothing recorded this before: a photo could burn all five attempts and
|
|
/// cost an inspection its evidence with no trace anywhere of the reason —
|
|
/// the device showed only "failed" and the server logs only SUCCESSFUL
|
|
/// uploads. Cleared on a successful upload.
|
|
///
|
|
/// Optional so existing SwiftData stores migrate lightweight (rule 8).
|
|
var lastUploadError: String?
|
|
|
|
// ── Capture metadata (sent to the server, burned into the photo) ───────
|
|
// Recorded when the shutter fires, NOT when the upload runs — the app is
|
|
// offline-first, so a photo taken at 09:14 may not sync until 16:00 and
|
|
// the stamp must show 09:14. EXIF cannot serve as a fallback here: the
|
|
// photo is re-encoded via jpegData() on save, which strips every tag.
|
|
// Optional so existing SwiftData stores migrate without a schema step.
|
|
var capturedAt: Date?
|
|
var captureLatitude: Double?
|
|
var captureLongitude: Double?
|
|
|
|
var inspection: LocalInspection?
|
|
|
|
init(
|
|
localFilePath: String,
|
|
entityType: String,
|
|
entityLocalId: String,
|
|
fieldId: String? = nil,
|
|
capturedAt: Date? = nil,
|
|
captureLatitude: Double? = nil,
|
|
captureLongitude: Double? = nil
|
|
) {
|
|
self.localId = UUID().uuidString
|
|
self.localFilePath = localFilePath
|
|
self.entityType = entityType
|
|
self.entityLocalId = entityLocalId
|
|
self.fieldId = fieldId
|
|
self.serverPath = nil
|
|
self.uploadStatus = "pending"
|
|
self.uploadRetryCount = 0
|
|
self.createdAt = Date()
|
|
// Fall back to now when the caller has no recorded capture moment —
|
|
// still far better than the server's upload-time default.
|
|
self.capturedAt = capturedAt ?? Date()
|
|
self.captureLatitude = captureLatitude
|
|
self.captureLongitude = captureLongitude
|
|
}
|
|
}
|