Aug 17 - Update photo dignostic
This commit is contained in:
@@ -0,0 +1,483 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,25 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-only investigation aid for the lost-photo defect. Placed
|
||||||
|
// above Cache deliberately: "Clear Reference Cache" sits next to it
|
||||||
|
// and the diagnostic must be run BEFORE anything that touches
|
||||||
|
// stored data, while the evidence is still intact.
|
||||||
|
Section("Diagnostics") {
|
||||||
|
NavigationLink {
|
||||||
|
PhotoDiagnosticView()
|
||||||
|
} label: {
|
||||||
|
// Not a photo.badge.* symbol — those are not universally
|
||||||
|
// available on iOS 17 (CLAUDE.md rule 41).
|
||||||
|
Label("Photo Diagnostic", systemImage: "doc.text.magnifyingglass")
|
||||||
|
}
|
||||||
|
Text("Reports inspection and issue photos that never reached the "
|
||||||
|
+ "server, and whether the original file is still on this "
|
||||||
|
+ "device. Read-only — changes nothing.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Section("Cache") {
|
Section("Cache") {
|
||||||
Button {
|
Button {
|
||||||
showClearCacheAlert = true
|
showClearCacheAlert = true
|
||||||
|
|||||||
Reference in New Issue
Block a user