// // 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 )) } }