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
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
//
|
||||
// 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
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,18 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import IndieAbout
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
@Query(sort: \Split.order) private var splits: [Split]
|
||||
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
@State private var showingAddSplitSheet = false
|
||||
|
||||
@State private var isRestoringSeeds = false
|
||||
@State private var restoreSeedsMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -53,62 +51,50 @@ struct SettingsView: View {
|
||||
Text("How long the watch waits on the finish screen before completing an exercise automatically.")
|
||||
}
|
||||
|
||||
// MARK: - Splits Section
|
||||
Section(header: Text("Splits")) {
|
||||
if splits.isEmpty {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "dumbbell.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Splits Yet")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Create a split to organize your workout routine.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.vertical)
|
||||
Spacer()
|
||||
// MARK: - Library Section
|
||||
Section(header: Text("Library")) {
|
||||
NavigationLink {
|
||||
SplitListView()
|
||||
} label: {
|
||||
Label("Splits", systemImage: "dumbbell.fill")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ExerciseLibraryView()
|
||||
} label: {
|
||||
Label("Exercises", systemImage: "figure.strengthtraining.traditional")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Starter Splits Section
|
||||
Section {
|
||||
Button {
|
||||
Task {
|
||||
isRestoringSeeds = true
|
||||
restoreSeedsMessage = nil
|
||||
let n = await sync.restoreSeeds()
|
||||
isRestoringSeeds = false
|
||||
restoreSeedsMessage = n == 0
|
||||
? "All starter splits are already present."
|
||||
: "Restored \(n) starter split\(n == 1 ? "" : "s")."
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Restore Starter Splits", systemImage: "arrow.counterclockwise")
|
||||
if isRestoringSeeds {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isRestoringSeeds || sync.iCloudStatus != .available)
|
||||
} header: {
|
||||
Text("Starter Splits")
|
||||
} footer: {
|
||||
if let restoreSeedsMessage {
|
||||
Text(restoreSeedsMessage)
|
||||
} else {
|
||||
ForEach(splits) { split in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: split.systemImage)
|
||||
.foregroundColor(Color.color(from: split.color))
|
||||
.frame(width: 24)
|
||||
Text(split.name)
|
||||
Spacer()
|
||||
Text("\(split.exercisesArray.count)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showingAddSplitSheet = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
Text("Add Split")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "wand.and.sparkles")
|
||||
.foregroundColor(.accentColor)
|
||||
Text("Add Starter Splits")
|
||||
}
|
||||
Text("Brings back the bundled starter splits you've deleted. Splits you've edited or created are never touched.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +119,17 @@ struct SettingsView: View {
|
||||
Text("iCloud Sync")
|
||||
}
|
||||
|
||||
// MARK: - Developer Section
|
||||
Section {
|
||||
NavigationLink {
|
||||
DuplicateCleanupView()
|
||||
} label: {
|
||||
Label("Clean Up Duplicates", systemImage: "wrench.and.screwdriver")
|
||||
}
|
||||
} header: {
|
||||
Text("Developer")
|
||||
}
|
||||
|
||||
// MARK: - About Section
|
||||
Section {
|
||||
IndieAbout(configuration: AppInfoConfiguration(
|
||||
@@ -144,9 +141,6 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.sheet(isPresented: $showingAddSplitSheet) {
|
||||
SplitAddEditView(split: nil)
|
||||
}
|
||||
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||
.onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||
.onChange(of: weightUnit) { _, _ in services.watchBridge.pushAll() }
|
||||
|
||||
Reference in New Issue
Block a user