Files
workouts/Workouts/Views/Settings/DuplicateCleanupView.swift
T
rzen 7586edd878 Reconcile starter seeds on connect and add restore + duplicate cleanup
Seeding now covers existing installs, not just empty containers: after the
same settle delay as auto-seed, connect() branches to reconcileSeeds(),
driven by a pure tested planner — upgrade an older seed revision in place
(safe: clone-on-edit guarantees no user content at seed ULIDs), skip
up-to-date/quarantined files, respect delete-veto stubs, and write missing
seeds unless a same-name legacy split exists. Settings gains Restore
Starter Splits (the one deliberate veto lift, writing current bundle
bytes) and a dev duplicate-cleanup tool backed by a fail-closed scanner.
The HealthKit estimate now follows the clone redirect when resolving a
workout's split.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 16:29:59 -04:00

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 splits/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 split referenced by a workout, a bundled starter split, 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 splitsDeleted: 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 splits, starter splits, 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 splits and workouts that are exact content duplicates — for example, left over from testing or a sync hiccup — and offers to delete the extra copies. Splits referenced by a workout, starter splits, 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.splitGroups.isEmpty {
Section("Duplicate Splits") {
ForEach(plan.splitGroups) { group in
splitGroupRow(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 splitGroupRow(_ group: DuplicateCleanupPlan.SplitGroup) -> 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.splitName ?? "(no split)",
detail: group.keep.start.formattedDate(),
id: group.keep.id,
isKeep: true
)
ForEach(group.delete) { doc in
memberRow(
name: doc.splitName ?? "(no split)",
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.splitsDeleted) split\(summary.splitsDeleted == 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(
splitsDeleted: result.splitsDeleted,
workoutsDeleted: result.workoutsDeleted,
skipped: result.skipped
))
}
}