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
282 lines
9.4 KiB
Swift
282 lines
9.4 KiB
Swift
//
|
|
// DuplicateCleanupView.swift
|
|
// Workouts
|
|
//
|
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// Developer-facing tool: scans iCloud Drive for routines/workouts that are exact
|
|
/// content duplicates (see `DuplicateCleanupPlanner`) — typically the residue of
|
|
/// testing, a restore, or a sync hiccup — and offers to delete the extras. Never
|
|
/// touches a routine referenced by a workout, a bundled starter routine, or an
|
|
/// in-progress workout.
|
|
struct DuplicateCleanupView: View {
|
|
@Environment(SyncEngine.self) private var sync
|
|
|
|
private enum ScanFailure {
|
|
case unreadableFiles([String])
|
|
case other(String)
|
|
}
|
|
|
|
private struct CleanupSummary {
|
|
var routinesDeleted: Int
|
|
var workoutsDeleted: Int
|
|
var skipped: Int
|
|
}
|
|
|
|
private enum Phase {
|
|
case idle
|
|
case scanning
|
|
case results(DuplicateCleanupPlan)
|
|
case failed(ScanFailure)
|
|
case deleting
|
|
case done(CleanupSummary)
|
|
}
|
|
|
|
@State private var phase: Phase = .idle
|
|
@State private var pendingPlan: DuplicateCleanupPlan?
|
|
|
|
var body: some View {
|
|
content
|
|
.navigationTitle("Clean Up Duplicates")
|
|
.confirmationDialog(
|
|
"Delete \(pendingPlan?.deleteCount ?? 0) Duplicate\((pendingPlan?.deleteCount ?? 0) == 1 ? "" : "s")?",
|
|
isPresented: Binding(
|
|
get: { pendingPlan != nil },
|
|
set: { if !$0 { pendingPlan = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: pendingPlan
|
|
) { plan in
|
|
Button("Delete", role: .destructive) {
|
|
pendingPlan = nil
|
|
Task { await performDelete(plan) }
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
pendingPlan = nil
|
|
}
|
|
} message: { _ in
|
|
Text("This permanently deletes the duplicate copies shown below. Referenced routines, starter routines, and in-progress workouts are always kept.")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
switch phase {
|
|
case .idle:
|
|
idleView
|
|
case .scanning:
|
|
ProgressView("Scanning…")
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
case .results(let plan):
|
|
resultsView(plan)
|
|
case .failed(let failure):
|
|
failureView(failure)
|
|
case .deleting:
|
|
ProgressView("Deleting…")
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
case .done(let summary):
|
|
doneView(summary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Idle
|
|
|
|
private var idleView: some View {
|
|
Form {
|
|
Section {
|
|
Button {
|
|
Task { await scan() }
|
|
} label: {
|
|
Label("Scan for Duplicates", systemImage: "magnifyingglass")
|
|
}
|
|
} footer: {
|
|
Text("Looks for routines and workouts that are exact content duplicates — for example, left over from testing or a sync hiccup — and offers to delete the extra copies. Routines referenced by a workout, starter routines, and in-progress workouts are never deleted.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Results
|
|
|
|
@ViewBuilder
|
|
private func resultsView(_ plan: DuplicateCleanupPlan) -> some View {
|
|
if plan.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Duplicates Found",
|
|
systemImage: "checkmark.circle",
|
|
description: Text("Everything in iCloud looks unique.")
|
|
)
|
|
} else {
|
|
List {
|
|
if !plan.routineGroups.isEmpty {
|
|
Section("Duplicate Routines") {
|
|
ForEach(plan.routineGroups) { group in
|
|
routineGroupRow(group)
|
|
}
|
|
}
|
|
}
|
|
if !plan.workoutGroups.isEmpty {
|
|
Section("Duplicate Workouts") {
|
|
ForEach(plan.workoutGroups) { group in
|
|
workoutGroupRow(group)
|
|
}
|
|
}
|
|
}
|
|
Section {
|
|
Button(role: .destructive) {
|
|
pendingPlan = plan
|
|
} label: {
|
|
HStack {
|
|
Spacer()
|
|
Text("Delete \(plan.deleteCount) Duplicate\(plan.deleteCount == 1 ? "" : "s")")
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func routineGroupRow(_ group: DuplicateCleanupPlan.RoutineGroup) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ForEach(group.keep) { doc in
|
|
memberRow(name: doc.name, detail: "\(doc.exercises.count) exercises", id: doc.id, isKeep: true)
|
|
}
|
|
ForEach(group.delete) { doc in
|
|
memberRow(name: doc.name, detail: "\(doc.exercises.count) exercises", id: doc.id, isKeep: false)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func workoutGroupRow(_ group: DuplicateCleanupPlan.WorkoutGroup) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
memberRow(
|
|
name: group.keep.routineName ?? "(no routine)",
|
|
detail: group.keep.start.formattedDate(),
|
|
id: group.keep.id,
|
|
isKeep: true
|
|
)
|
|
ForEach(group.delete) { doc in
|
|
memberRow(
|
|
name: doc.routineName ?? "(no routine)",
|
|
detail: doc.start.formattedDate(),
|
|
id: doc.id,
|
|
isKeep: false
|
|
)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func memberRow(name: String, detail: String, id: String, isKeep: Bool) -> some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(name)
|
|
Text("\(detail) · id …\(id.suffix(6))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if isKeep {
|
|
Label("Keep", systemImage: "checkmark.circle")
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Label("Delete", systemImage: "trash")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Failure
|
|
|
|
@ViewBuilder
|
|
private func failureView(_ failure: ScanFailure) -> some View {
|
|
switch failure {
|
|
case .unreadableFiles(let paths):
|
|
List {
|
|
Section {
|
|
Label(
|
|
"Scan aborted: \(paths.count) file\(paths.count == 1 ? "" : "s") could not be read. Nothing was deleted.",
|
|
systemImage: "exclamationmark.triangle.fill"
|
|
)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
Section("Unreadable Files") {
|
|
ForEach(paths, id: \.self) { path in
|
|
Text(path)
|
|
.font(.system(.footnote, design: .monospaced))
|
|
}
|
|
}
|
|
Section {
|
|
Button("Rescan") {
|
|
Task { await scan() }
|
|
}
|
|
}
|
|
}
|
|
case .other(let message):
|
|
List {
|
|
Section {
|
|
Label(message, systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
Section {
|
|
Button("Try Again") {
|
|
Task { await scan() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Done
|
|
|
|
private func doneView(_ summary: CleanupSummary) -> some View {
|
|
List {
|
|
Section {
|
|
Label(
|
|
"Deleted \(summary.routinesDeleted) routine\(summary.routinesDeleted == 1 ? "" : "s"), \(summary.workoutsDeleted) workout\(summary.workoutsDeleted == 1 ? "" : "s"). \(summary.skipped) skipped (referenced or active).",
|
|
systemImage: "checkmark.circle.fill"
|
|
)
|
|
.foregroundStyle(.green)
|
|
}
|
|
Section {
|
|
Button("Scan Again") {
|
|
Task { await scan() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func scan() async {
|
|
phase = .scanning
|
|
do {
|
|
let plan = try await sync.scanForDuplicates()
|
|
phase = .results(plan)
|
|
} catch let error as SyncEngine.DuplicateScanError {
|
|
switch error {
|
|
case .notConnected:
|
|
phase = .failed(.other("iCloud isn't connected yet. Try again in a moment."))
|
|
case .unreadableFiles(let paths):
|
|
phase = .failed(.unreadableFiles(paths))
|
|
}
|
|
} catch {
|
|
phase = .failed(.other(error.localizedDescription))
|
|
}
|
|
}
|
|
|
|
private func performDelete(_ plan: DuplicateCleanupPlan) async {
|
|
phase = .deleting
|
|
let result = await sync.performCleanup(plan)
|
|
phase = .done(CleanupSummary(
|
|
routinesDeleted: result.routinesDeleted,
|
|
workoutsDeleted: result.workoutsDeleted,
|
|
skipped: result.skipped
|
|
))
|
|
}
|
|
}
|