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
176 lines
6.8 KiB
Swift
176 lines
6.8 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|
||
}
|