Files
workouts/Workouts/Diagnostics/DiagnosticsView.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

176 lines
6.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
}
}
}