// // DiagnosticsView.swift // Workouts // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // // On-demand iCloud sync diagnostics, built on the copied indie-diag reference // types. Reached from Settings › iCloud Sync › Diagnostics. Generates a fresh // snapshot on appear and on pull-to-refresh — never on a timer (see the // indie-diag skill's anti-patterns). // import SwiftUI @MainActor @Observable final class DiagnosticsViewModel { /// One coordinator for the screen's lifetime; it's cheap (no I/O until /// `generate()` runs) and each of its pieces tears itself down after each call. /// The schema scanner resolves the forward-gate ceiling per file, because /// Workouts has two document types with independent version counters — a /// `Splits/` file is gated by `RoutineDocument.currentSchemaVersion`, a /// `Workouts/` file by `WorkoutDocument.currentSchemaVersion`, and a `Stubs/` /// tombstone carries no versioned payload so it's never counted as skipped. private let coordinator = DiagnosticsCoordinator( containerIdentifier: SyncEngine.containerIdentifier, schemaScanner: SchemaSkipScanner( ceilingForURL: { url in let path = url.path // PINNED: routine documents live under the on-disk "Splits/" directory. if path.contains("/Splits/") { return RoutineDocument.currentSchemaVersion } if path.contains("/Workouts/") { return WorkoutDocument.currentSchemaVersion } return .max }, representativeCeiling: max( RoutineDocument.currentSchemaVersion, WorkoutDocument.currentSchemaVersion ) ) ) private(set) var report: DiagnosticsReport? private(set) var isRefreshing = false func refresh() async { isRefreshing = true report = await coordinator.generate() isRefreshing = false } } struct DiagnosticsView: View { @State private var viewModel = DiagnosticsViewModel() var body: some View { List { if let report = viewModel.report { overallSection(report) iCloudSection(report) documentsSection(report) networkSection(report) copySection(report) } else if viewModel.isRefreshing { Section { HStack { ProgressView(); Text("Gathering…").foregroundStyle(.secondary) } } } } .navigationTitle("Diagnostics") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.refresh() } .refreshable { await viewModel.refresh() } } // MARK: - Sections private func overallSection(_ report: DiagnosticsReport) -> some View { Section("Overall") { HStack { Image(systemName: healthSymbol(report.overallHealth)) .foregroundStyle(healthColor(report.overallHealth)) Text(report.overallHealth.rawValue) Spacer() if viewModel.isRefreshing { ProgressView() } } } } private func iCloudSection(_ report: DiagnosticsReport) -> some View { Section("iCloud") { LabeledContent("Account", value: report.containerStatus.hasSignedInAccount ? "Signed in" : "Signed out") LabeledContent("Container", value: report.containerStatus.isAvailable ? "Available" : "Unavailable") } } @ViewBuilder private func documentsSection(_ report: DiagnosticsReport) -> some View { Section("Documents") { LabeledContent("Current") { Text("\(report.documentSync.currentCount) / \(report.documentSync.totalCount)") } if report.documentSync.isSyncing { Label("Syncing…", systemImage: "arrow.triangle.2.circlepath") .foregroundStyle(.secondary) } // Skipped and evicted are presented as a pair: evicted documents are // never probed, so the skipped count is a floor. Informational styling, // not a warning color — a skipped document is an expected, recoverable // state (update the app and it opens). if let skips = report.schemaSkips, skips.skippedCount > 0 || report.documentSync.evictedCount > 0 { LabeledContent("Skipped · Evicted") { Text("\(skips.skippedCount) · \(report.documentSync.evictedCount)") .foregroundStyle(.secondary) } if skips.skippedCount > 0 { Text("Skipped documents were saved by a newer version of the app — update to open them.") .font(.caption) .foregroundStyle(.secondary) } } else if report.documentSync.evictedCount > 0 { LabeledContent("Evicted", value: "\(report.documentSync.evictedCount)") } if report.documentSync.conflictCount > 0 { LabeledContent("Conflicts", value: "\(report.documentSync.conflictCount)") .foregroundStyle(.orange) } if report.documentSync.errorCount > 0 { LabeledContent("Errors", value: "\(report.documentSync.errorCount)") .foregroundStyle(.orange) } } } private func networkSection(_ report: DiagnosticsReport) -> some View { Section("Network") { LabeledContent("Connection", value: report.networkStatus.connectionType.rawValue) LabeledContent("Quality", value: report.networkStatus.quality.rawValue) if report.networkStatus.isConstrained { Label("Low Data Mode", systemImage: "exclamationmark.circle").foregroundStyle(.secondary) } } } private func copySection(_ report: DiagnosticsReport) -> some View { Section { Button { UIPasteboard.general.string = report.summary } label: { Label("Copy Diagnostics", systemImage: "doc.on.doc") } } footer: { Text("Copies a plain-text summary you can paste into a support message.") } } // MARK: - Health styling private func healthSymbol(_ health: DiagnosticHealth) -> String { switch health { case .healthy: "checkmark.circle.fill" case .syncing: "arrow.triangle.2.circlepath" case .warning: "exclamationmark.triangle.fill" case .critical: "xmark.octagon.fill" } } private func healthColor(_ health: DiagnosticHealth) -> Color { switch health { case .healthy: .green case .syncing: .secondary case .warning, .critical: .orange } } }