Files

484 lines
20 KiB
Swift

// Views/Dashboard/PhotoDiagnosticView.swift
// ------------------------------------------
// READ-ONLY diagnostic for the lost-photo defect (August 2026).
//
// Reports, per record, which photo fields never received a server path and
// whether the underlying JPEG is still recoverable on this device. It exists to
// size the problem BEFORE any fix ships, because the fix sequence is
// destructive if run in the wrong order: correcting cleanupOrphanedPhotos()
// deletes exactly the files this report is looking for.
//
// THIS VIEW MUST STAY READ-ONLY. It never calls context.save(), context.delete(),
// FileManager write/remove, or any APIClient method. It only fetches, reads
// files' existence, and formats text. Anything that repairs data belongs in a
// separate, explicitly-named screen — a diagnostic the user cannot trust to be
// safe is a diagnostic they will not run.
//
// ── What it looks for ────────────────────────────────────────────────────────
// A photo is "lost" when a form field still holds the local sentinel
// ("local://<path>") instead of a server path ("uploads/..."). APIClient
// .submitInspection() rewrites that sentinel to "" in the request body only —
// inspection.formData is left intact — so the device still knows which field
// the photo belonged to. That is what makes recovery possible, and what this
// report enumerates.
//
// ── File resolution ──────────────────────────────────────────────────────────
// Stored paths are ABSOLUTE and include the app container UUID, which iOS
// changes on update / reinstall / restore. So a stored path that no longer
// exists does NOT mean the file is gone: the same filename usually still exists
// under the current container. Filenames are UUIDs generated per save, so a
// basename match is unambiguous and safe to rely on.
import SwiftUI
import SwiftData
import UIKit
// MARK: - Model
/// Where the JPEG actually is, independent of what the database claims.
enum PhotoFileState {
/// The stored absolute path resolves — nothing has moved.
case foundAtStoredPath
/// The stored path is stale (container UUID changed) but a file with the
/// same unique filename exists now. Recoverable; carries the live path.
case foundByFilename(String)
/// No file with that name anywhere under the photo directories.
case missing
var isRecoverable: Bool {
if case .missing = self { return false }
return true
}
}
/// How much damage has already been done for one missing photo.
enum PhotoLossSeverity: Int {
/// Submitted to the server with the field blank — the loss is live, and
/// only a PATCH can repair it.
case lostOnServer = 0
/// Completed but still in the outbox: it will be submitted blank on the
/// next sync unless the fix lands first.
case willBeLostOnNextSync = 1
/// Still a draft — nothing lost yet.
case draftNotYetSubmitted = 2
var label: String {
switch self {
case .lostOnServer: return "LOST ON SERVER"
case .willBeLostOnNextSync: return "WILL BE LOST"
case .draftNotYetSubmitted: return "DRAFT (safe)"
}
}
var color: Color {
switch self {
case .lostOnServer: return .red
case .willBeLostOnNextSync: return .orange
case .draftNotYetSubmitted: return .secondary
}
}
}
struct PhotoDiagnosticEntry: Identifiable {
let id = UUID()
let kind: String // "Inspection" | "Issue"
let localId: String
let serverId: Int? // the row to PATCH, when already submitted
let title: String // template / issue description
let facilityName: String
let date: Date
let fieldId: String? // nil for issue photos (no form field)
let storedPath: String
let fileState: PhotoFileState
/// PendingPhoto.uploadStatus, or nil when no row survives for this photo.
let uploadStatus: String?
let severity: PhotoLossSeverity
}
// MARK: - View
struct PhotoDiagnosticView: View {
@Environment(\.modelContext) private var context
@State private var entries: [PhotoDiagnosticEntry] = []
@State private var failedPhotoCount = 0
@State private var totalPhotoRows = 0
@State private var scanned = false
@State private var copied = false
private var lostOnServer: [PhotoDiagnosticEntry] {
entries.filter { $0.severity == .lostOnServer }
}
private var willBeLost: [PhotoDiagnosticEntry] {
entries.filter { $0.severity == .willBeLostOnNextSync }
}
private var drafts: [PhotoDiagnosticEntry] {
entries.filter { $0.severity == .draftNotYetSubmitted }
}
private var recoverable: [PhotoDiagnosticEntry] {
entries.filter { $0.fileState.isRecoverable }
}
private var unrecoverable: [PhotoDiagnosticEntry] {
entries.filter { !$0.fileState.isRecoverable }
}
var body: some View {
List {
Section {
Label(
"This screen only reads. It does not upload, delete, "
+ "repair, or modify anything.",
systemImage: "lock.shield"
)
.font(.caption)
.foregroundStyle(.secondary)
}
if !scanned {
Section {
HStack {
ProgressView()
Text("Scanning…").padding(.leading, 8)
}
}
} else {
summarySection
if !entries.isEmpty {
entrySection("Lost on Server", lostOnServer,
footer: "Submitted with the photo field blank. "
+ "Repairing these needs a re-upload plus a PATCH.")
entrySection("Will Be Lost on Next Sync", willBeLost,
footer: "Still in the outbox. These are submitted "
+ "blank unless the fix lands first.")
entrySection("Drafts", drafts,
footer: "Not submitted yet — nothing lost.")
}
copySection
}
}
.navigationTitle("Photo Diagnostic")
.navigationBarTitleDisplayMode(.inline)
.task { runScan() }
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
scanned = false
runScan()
} label: {
Label("Rescan", systemImage: "arrow.clockwise")
}
}
}
}
// ── Sections ──────────────────────────────────────────────────────────
private var summarySection: some View {
Section("Summary") {
row("Photos affected", "\(entries.count)",
tint: entries.isEmpty ? .green : .red)
row("Already lost on server", "\(lostOnServer.count)",
tint: lostOnServer.isEmpty ? .secondary : .red)
row("Will be lost on next sync", "\(willBeLost.count)",
tint: willBeLost.isEmpty ? .secondary : .orange)
row("Still recoverable (file on device)", "\(recoverable.count)",
tint: recoverable.isEmpty ? .secondary : .green)
row("File gone — unrecoverable", "\(unrecoverable.count)",
tint: unrecoverable.isEmpty ? .secondary : .red)
Divider()
row("Photo upload rows total", "\(totalPhotoRows)", tint: .secondary)
row("Rows marked failed", "\(failedPhotoCount)",
tint: failedPhotoCount == 0 ? .secondary : .orange)
if entries.isEmpty {
Label("No missing photos found on this device.",
systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.callout)
}
}
}
@ViewBuilder
private func entrySection(_ title: String,
_ list: [PhotoDiagnosticEntry],
footer: String) -> some View {
if !list.isEmpty {
Section {
ForEach(list) { e in entryRow(e) }
} header: {
Text("\(title) (\(list.count))")
} footer: {
Text(footer)
}
}
}
private func entryRow(_ e: PhotoDiagnosticEntry) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(e.severity.label)
.font(.caption2.bold())
.padding(.horizontal, 6).padding(.vertical, 2)
.background(e.severity.color.opacity(0.15))
.foregroundStyle(e.severity.color)
.clipShape(Capsule())
Spacer()
if let sid = e.serverId {
Text("server #\(sid)")
.font(.caption2).foregroundStyle(.secondary)
} else {
Text("not on server")
.font(.caption2).foregroundStyle(.secondary)
}
}
Text("\(e.kind): \(e.title)").font(.callout.bold())
Text(e.facilityName).font(.caption).foregroundStyle(.secondary)
Text(e.date.formatted(date: .abbreviated, time: .shortened))
.font(.caption2).foregroundStyle(.secondary)
if let fid = e.fieldId {
Text("Field ID: \(fid)").font(.caption2).foregroundStyle(.secondary)
}
Text("Upload status: \(e.uploadStatus ?? "no record")")
.font(.caption2).foregroundStyle(.secondary)
switch e.fileState {
case .foundAtStoredPath:
Label("File present at stored path — recoverable",
systemImage: "checkmark.circle")
.font(.caption2).foregroundStyle(.green)
case .foundByFilename:
Label("Stored path stale (container changed) — file found by "
+ "name, recoverable", systemImage: "arrow.triangle.2.circlepath")
.font(.caption2).foregroundStyle(.orange)
case .missing:
Label("File not on this device — unrecoverable",
systemImage: "exclamationmark.triangle")
.font(.caption2).foregroundStyle(.red)
}
}
.padding(.vertical, 2)
}
private var copySection: some View {
Section {
Button {
UIPasteboard.general.string = textReport()
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
} label: {
Label(copied ? "Copied" : "Copy Full Report",
systemImage: copied ? "checkmark" : "doc.on.doc")
}
} footer: {
Text("Copies a plain-text version, including full file paths, for "
+ "sharing or for driving a recovery pass.")
}
}
private func row(_ label: String, _ value: String, tint: Color) -> some View {
HStack {
Text(label)
Spacer()
Text(value).bold().foregroundStyle(tint)
}
.font(.callout)
}
// ── Scan (read-only) ──────────────────────────────────────────────────
private func runScan() {
// Fetch-all + filter in Swift — no #Predicate (CLAUDE.md rule 3),
// and every `try?` parenthesised before `??` (rule 25).
let inspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
let issues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
let photos = (try? context.fetch(FetchDescriptor<PendingPhoto>())) ?? []
let facilities = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
let templates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
totalPhotoRows = photos.count
failedPhotoCount = photos.filter { $0.uploadStatus == "failed" }.count
var facilityName: [Int: String] = [:]
for f in facilities { facilityName[f.serverId] = f.name }
var templateName: [Int: String] = [:]
for t in templates { templateName[t.serverId] = t.name }
// basename -> live path, for re-resolving stale container paths.
let diskIndex = buildDiskIndex()
// Photo rows keyed by the file they point at, so a form field can be
// matched to its upload record.
var photoByPath: [String: PendingPhoto] = [:]
for p in photos { photoByPath[p.localFilePath] = p }
var found: [PhotoDiagnosticEntry] = []
// ── Inspections: form fields still holding the local:// sentinel ──
for insp in inspections {
for (fieldId, value) in insp.formData {
guard let s = value as? String, s.hasPrefix("local://") else { continue }
let path = String(s.dropFirst("local://".count))
let severity: PhotoLossSeverity
if insp.status == "draft" {
severity = .draftNotYetSubmitted
} else if insp.serverId != nil || insp.syncStatus == "synced" {
severity = .lostOnServer
} else {
severity = .willBeLostOnNextSync
}
found.append(PhotoDiagnosticEntry(
kind: "Inspection",
localId: insp.localId,
serverId: insp.serverId,
title: templateName[insp.templateServerId]
?? "Template #\(insp.templateServerId)",
facilityName: facilityName[insp.facilityServerId]
?? "Facility #\(insp.facilityServerId)",
date: insp.inspectionDate,
fieldId: fieldId,
storedPath: path,
fileState: resolve(path, diskIndex),
uploadStatus: photoByPath[path]?.uploadStatus,
severity: severity
))
}
}
// ── Issues: local photos that never produced a server path ────────
// Same defect, different surface. processIssueQueue clears
// photoLocalPaths only after a successful submit, so a synced issue
// still holding local paths with no server paths lost its evidence.
for issue in issues {
guard !issue.photoLocalPaths.isEmpty else { continue }
let missingServerSide = issue.photoServerPaths.count < issue.photoLocalPaths.count
guard missingServerSide else { continue }
let severity: PhotoLossSeverity
if issue.syncStatus == "synced" || issue.serverId != nil {
severity = .lostOnServer
} else if issue.syncStatus == "failed" {
severity = .willBeLostOnNextSync
} else {
severity = .willBeLostOnNextSync
}
for path in issue.photoLocalPaths {
// A path already mirrored server-side is fine — skip it.
if let p = photoByPath[path], p.uploadStatus == "uploaded" { continue }
found.append(PhotoDiagnosticEntry(
kind: "Issue",
localId: issue.localId,
serverId: issue.serverId,
title: issue.issueDescription.isEmpty
? "(no description)"
: String(issue.issueDescription.prefix(60)),
facilityName: facilityName[issue.facilityServerId]
?? "Facility #\(issue.facilityServerId)",
date: issue.createdAt,
fieldId: nil,
storedPath: path,
fileState: resolve(path, diskIndex),
uploadStatus: photoByPath[path]?.uploadStatus,
severity: severity
))
}
}
entries = found.sorted {
if $0.severity.rawValue != $1.severity.rawValue {
return $0.severity.rawValue < $1.severity.rawValue
}
return $0.date > $1.date
}
scanned = true
}
/// Map of filename -> current absolute path for every file under the photo
/// directories. Filenames are per-save UUIDs, so collisions are not a
/// practical concern and a basename match identifies a file uniquely.
private func buildDiskIndex() -> [String: String] {
let fm = FileManager.default
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first
else { return [:] }
var index: [String: String] = [:]
// Both directories the app writes to today. Enumerating rather than
// assuming, so a file left by an older build is still found.
for sub in ["JQC/Photos", "JQC/ResultPhotos", "JQCPhotos"] {
let dir = docs.appendingPathComponent(sub, isDirectory: true)
guard let files = try? fm.contentsOfDirectory(
at: dir, includingPropertiesForKeys: nil
) else { continue }
for f in files { index[f.lastPathComponent] = f.path }
}
return index
}
private func resolve(_ storedPath: String,
_ diskIndex: [String: String]) -> PhotoFileState {
if FileManager.default.fileExists(atPath: storedPath) {
return .foundAtStoredPath
}
let name = URL(fileURLWithPath: storedPath).lastPathComponent
if let live = diskIndex[name] {
return .foundByFilename(live)
}
return .missing
}
// ── Text report ───────────────────────────────────────────────────────
private func textReport() -> String {
var out = """
JQC PHOTO DIAGNOSTIC (read-only)
Generated: \(Date().formatted(date: .abbreviated, time: .standard))
Server: \(ServerConfig.current)
SUMMARY
Photos affected .................. \(entries.count)
Already lost on server ......... \(lostOnServer.count)
Will be lost on next sync ...... \(willBeLost.count)
Drafts (safe) .................. \(drafts.count)
Recoverable (file present) ..... \(recoverable.count)
Unrecoverable (file gone) ...... \(unrecoverable.count)
Photo rows total ............... \(totalPhotoRows)
Rows marked failed ............. \(failedPhotoCount)
DETAIL
"""
for e in entries {
let state: String
switch e.fileState {
case .foundAtStoredPath: state = "file OK at stored path"
case .foundByFilename(let p): state = "file found by name -> \(p)"
case .missing: state = "FILE MISSING"
}
out += """
[\(e.severity.label)] \(e.kind) \(e.serverId.map { "server #\($0)" } ?? "(unsent)")
title : \(e.title)
facility : \(e.facilityName)
date : \(e.date.formatted(date: .abbreviated, time: .shortened))
localId : \(e.localId)
fieldId : \(e.fieldId ?? "-")
storedPath : \(e.storedPath)
upload : \(e.uploadStatus ?? "no record")
fileState : \(state)
"""
}
if entries.isEmpty { out += "(none)\n" }
return out
}
}