Workouts 2.0: re-base persistence on iCloud Drive documents

Replace Core Data + NSPersistentCloudKitContainer + App-Group store +
WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents
architecture:

- iCloud Drive JSON documents are the sole source of truth (one file per
  aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a
  rebuildable SwiftData cache populated only by an NSMetadataQuery observer
  and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate.
- Shared model layer (ULID, Codable *Documents + stateless mappers, @Model
  cache entities, SwiftData container) compiled into both targets.
- New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone
  is the sole writer of iCloud Drive, the watch round-trips documents.
- AppServices DI + iCloud-required root gate; Swift 6 strict concurrency.
- Starter splits generated on demand from the bundled YAML catalogs.
- Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments
  entitlement (drop CloudKit/App Group/aps-environment).
- Duration stored as Int seconds (was a Date epoch hack); fix workout
  end-on-create, undismissable delete dialog, toolbar-hiding nav stacks,
  and the Settings placeholder.
- Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the
  Scripts/ TestFlight pipeline (release.sh + ASC API scripts).

MARKETING_VERSION 2.0.
This commit is contained in:
2026-06-19 14:25:27 -04:00
parent 9a881e841b
commit 85d0eaddbb
86 changed files with 3873 additions and 3755 deletions
+118 -62
View File
@@ -8,15 +8,20 @@
//
import SwiftUI
import CoreData
import SwiftData
import Charts
struct ExerciseView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
/// Working copy of the parent workout. Editing a log = editing this doc and
/// re-saving the whole aggregate. Driving the UI from local state (not the
/// cache entity) keeps rapid set taps from racing the filecache update.
@State private var doc: WorkoutDocument
@State private var progress: Int = 0
@State private var showingPlanEdit = false
@State private var showingNotesEdit = false
@@ -24,12 +29,49 @@ struct ExerciseView: View {
let notStartedColor = Color.white
let completedColor = Color.green
/// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's
/// working copy right after adding an exercise) so the screen doesn't wait on
/// the filecache round-trip to find the just-created log.
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
self.workout = workout
self.logID = logID
_doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout))
}
/// The log being edited within the working doc.
private var log: WorkoutLogDocument? {
doc.logs.first { $0.id == logID }
}
var body: some View {
Group {
if let log {
content(for: log)
} else {
// The just-added log hasn't reached the cache yet; refresh shortly.
ProgressView()
}
}
.navigationTitle(log?.exerciseName ?? "")
.sheet(isPresented: $showingPlanEdit) {
PlanEditView(workout: workout, logID: logID)
}
.sheet(isPresented: $showingNotesEdit) {
NotesEditView(workout: workout, logID: logID)
}
.onAppear {
refreshDocIfNeeded()
progress = log?.currentStateIndex ?? 0
}
}
@ViewBuilder
private func content(for log: WorkoutLogDocument) -> some View {
Form {
// MARK: - Progress Section
Section(header: Text("Progress")) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: Int(workoutLog.sets)), spacing: 4) {
ForEach(1...max(1, Int(workoutLog.sets)), id: \.self) { index in
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: max(1, log.sets)), spacing: 4) {
ForEach(1...max(1, log.sets), id: \.self) { index in
ZStack {
let completed = index <= progress
let color = completed ? completedColor : notStartedColor
@@ -50,21 +92,17 @@ struct ExerciseView: View {
.colorInvert()
}
.onTapGesture {
let totalSets = Int(workoutLog.sets)
let totalSets = log.sets
let isLastTile = index == totalSets
let wasAlreadyAtThisProgress = progress == index
withAnimation(.easeInOut(duration: 0.2)) {
if wasAlreadyAtThisProgress {
progress = 0
} else {
progress = index
}
progress = wasAlreadyAtThisProgress ? 0 : index
}
updateLogStatus()
// If tapping the last tile to complete, go back to list
// Tapping the final tile to complete returns to the list.
if isLastTile && !wasAlreadyAtThisProgress {
dismiss()
}
@@ -75,7 +113,7 @@ struct ExerciseView: View {
// MARK: - Plan Section (Read-only with Edit button)
Section {
PlanTilesView(workoutLog: workoutLog)
PlanTilesView(log: log)
} header: {
HStack {
Text("Plan")
@@ -90,7 +128,7 @@ struct ExerciseView: View {
// MARK: - Notes Section (Read-only with Edit button)
Section {
if let notes = workoutLog.notes, !notes.isEmpty {
if let notes = log.notes, !notes.isEmpty {
Text(notes)
.foregroundColor(.primary)
} else {
@@ -112,93 +150,111 @@ struct ExerciseView: View {
// MARK: - Progress Tracking Chart
Section(header: Text("Progress Tracking")) {
WeightProgressionChartView(exerciseName: workoutLog.exerciseName)
WeightProgressionChartView(exerciseName: log.exerciseName)
}
}
.navigationTitle(workoutLog.exerciseName)
.sheet(isPresented: $showingPlanEdit) {
PlanEditView(workoutLog: workoutLog)
// Pull plan/notes edits made in the sheets back into the live doc.
.onChange(of: showingPlanEdit) { _, presenting in
if !presenting { refreshDocFromCache() }
}
.sheet(isPresented: $showingNotesEdit) {
NotesEditView(workoutLog: workoutLog)
}
.onAppear {
progress = Int(workoutLog.currentStateIndex)
}
.onChange(of: workoutLog.currentStateIndex) { _, newValue in
// Update local state when CoreData changes (e.g., from Watch sync)
if progress != Int(newValue) {
withAnimation(.easeInOut(duration: 0.2)) {
progress = Int(newValue)
}
}
.onChange(of: showingNotesEdit) { _, presenting in
if !presenting { refreshDocFromCache() }
}
}
// MARK: - Mutations
private func updateLogStatus() {
workoutLog.currentStateIndex = Int32(progress)
if progress >= Int(workoutLog.sets) {
workoutLog.status = .completed
workoutLog.completed = true
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = progress
if progress >= doc.logs[i].sets {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if progress > 0 {
workoutLog.status = .inProgress
workoutLog.completed = false
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
} else {
workoutLog.status = .notStarted
workoutLog.completed = false
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
}
updateWorkoutStatus()
saveChanges()
recomputeWorkoutStatus()
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
private func updateWorkoutStatus() {
guard let workout = workoutLog.workout else { return }
let logs = workout.logsArray
let allCompleted = logs.allSatisfy { $0.status == .completed }
let anyInProgress = logs.contains { $0.status == .inProgress }
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
/// Recompute the workout's status/end from its logs.
private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
workout.status = .completed
workout.end = Date()
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
workout.status = .inProgress
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
workout.status = .notStarted
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
}
private func saveChanges() {
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
/// If the requested log isn't in the working doc yet (just-added race), pull a
/// fresh copy from the cache entity once it catches up.
private func refreshDocIfNeeded() {
guard log == nil else { return }
refreshDocFromCache()
}
/// Re-read the workout from the cache to absorb edits made by child sheets
/// (plan/notes) without clobbering progress edits made here.
private func refreshDocFromCache() {
let fresh = WorkoutDocument(from: workout)
// Preserve the locally edited progress for the open log if the cache lags.
if let i = fresh.logs.firstIndex(where: { $0.id == logID }),
let mine = doc.logs.first(where: { $0.id == logID }),
fresh.logs[i].currentStateIndex != mine.currentStateIndex {
doc = fresh
doc.logs[i].currentStateIndex = mine.currentStateIndex
} else {
doc = fresh
}
if let current = log {
progress = current.currentStateIndex
}
}
}
// MARK: - Plan Tiles View
struct PlanTilesView: View {
@ObservedObject var workoutLog: WorkoutLog
let log: WorkoutLogDocument
var body: some View {
if workoutLog.loadTypeEnum == .duration {
if LoadType(rawValue: log.loadType) == .duration {
// Duration layout: Sets | Duration
HStack(spacing: 0) {
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
PlanTile(label: "Sets", value: "\(log.sets)")
PlanTile(label: "Duration", value: formattedDuration)
}
} else {
// Weight layout: Sets | Reps | Weight
HStack(spacing: 0) {
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
PlanTile(label: "Reps", value: "\(workoutLog.reps)")
PlanTile(label: "Weight", value: "\(workoutLog.weight) lbs")
PlanTile(label: "Sets", value: "\(log.sets)")
PlanTile(label: "Reps", value: "\(log.reps)")
PlanTile(label: "Weight", value: "\(log.weight) lbs")
}
}
}
private var formattedDuration: String {
let mins = workoutLog.durationMinutes
let secs = workoutLog.durationSeconds
let mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 {
return "\(mins)m \(secs)s"
} else if mins > 0 {
+12 -7
View File
@@ -6,13 +6,14 @@
//
import SwiftUI
import CoreData
import SwiftData
struct NotesEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
@State private var notesText: String = ""
@@ -40,14 +41,18 @@ struct NotesEditView: View {
}
}
.onAppear {
notesText = workoutLog.notes ?? ""
notesText = WorkoutDocument(from: workout)
.logs.first(where: { $0.id == logID })?.notes ?? ""
}
}
}
private func saveChanges() {
workoutLog.notes = notesText
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
var doc = WorkoutDocument(from: workout)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].notes = notesText
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
}
+46 -29
View File
@@ -6,13 +6,15 @@
//
import SwiftUI
import CoreData
import SwiftData
struct PlanEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
let workout: Workout
let logID: String
@State private var sets: Int = 3
@State private var reps: Int = 12
@@ -21,11 +23,6 @@ struct PlanEditView: View {
@State private var durationSeconds: Int = 0
@State private var selectedLoadType: LoadType = .weight
// Find the corresponding exercise in the split for syncing changes
private var correspondingExercise: Exercise? {
workoutLog.workout?.split?.exercisesArray.first { $0.name == workoutLog.exerciseName }
}
var body: some View {
NavigationStack {
Form {
@@ -135,34 +132,54 @@ struct PlanEditView: View {
}
}
.onAppear {
sets = Int(workoutLog.sets)
reps = Int(workoutLog.reps)
weight = Int(workoutLog.weight)
durationMinutes = workoutLog.durationMinutes
durationSeconds = workoutLog.durationSeconds
selectedLoadType = workoutLog.loadTypeEnum
if let log = WorkoutDocument(from: workout).logs.first(where: { $0.id == logID }) {
sets = log.sets
reps = log.reps
weight = log.weight
durationMinutes = log.durationSeconds / 60
durationSeconds = log.durationSeconds % 60
selectedLoadType = LoadType(rawValue: log.loadType) ?? .weight
}
}
}
}
private func saveChanges() {
workoutLog.sets = Int32(sets)
workoutLog.reps = Int32(reps)
workoutLog.weight = Int32(weight)
workoutLog.durationMinutes = durationMinutes
workoutLog.durationSeconds = durationSeconds
workoutLog.loadTypeEnum = selectedLoadType
let totalSeconds = durationMinutes * 60 + durationSeconds
// Sync to corresponding exercise
if let exercise = correspondingExercise {
exercise.sets = workoutLog.sets
exercise.reps = workoutLog.reps
exercise.weight = workoutLog.weight
exercise.loadType = workoutLog.loadType
exercise.duration = workoutLog.duration
// 1) Update the log within the parent workout document.
var doc = WorkoutDocument(from: workout)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].sets = sets
doc.logs[i].reps = reps
doc.logs[i].weight = weight
doc.logs[i].durationSeconds = totalSeconds
doc.logs[i].loadType = selectedLoadType.rawValue
doc.updatedAt = Date()
let exerciseName = doc.logs[i].exerciseName
let workoutDoc = doc
// 2) Mirror the plan onto the matching exercise in the split template.
var splitDoc: SplitDocument?
if let splitID = doc.splitID,
let split = CacheMapper.fetchSplit(id: splitID, in: modelContext) {
var sDoc = SplitDocument(from: split)
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
sDoc.exercises[ei].sets = sets
sDoc.exercises[ei].reps = reps
sDoc.exercises[ei].weight = weight
sDoc.exercises[ei].durationSeconds = totalSeconds
sDoc.exercises[ei].loadType = selectedLoadType.rawValue
sDoc.updatedAt = Date()
splitDoc = sDoc
}
}
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
Task {
await sync.save(workout: workoutDoc)
if let splitDoc {
await sync.save(split: splitDoc)
}
}
}
}
@@ -9,21 +9,31 @@
import SwiftUI
import Charts
import CoreData
import SwiftData
struct WeightProgressionChartView: View {
@Environment(\.managedObjectContext) private var viewContext
let exerciseName: String
@State private var weightData: [WeightDataPoint] = []
@State private var isLoading: Bool = true
@State private var motivationalMessage: String = ""
/// Completed logs for this exercise, oldest first.
@Query private var logs: [WorkoutLog]
init(exerciseName: String) {
self.exerciseName = exerciseName
let name = exerciseName
_logs = Query(
filter: #Predicate<WorkoutLog> { $0.exerciseName == name && $0.completed },
sort: \WorkoutLog.date,
order: .forward
)
}
private var weightData: [WeightDataPoint] {
logs.map { WeightDataPoint(date: $0.date, weight: $0.weight) }
}
var body: some View {
VStack(alignment: .leading) {
if isLoading {
ProgressView("Loading data...")
} else if weightData.isEmpty {
if weightData.isEmpty {
Text("No weight history available yet.")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
@@ -70,51 +80,33 @@ struct WeightProgressionChartView: View {
}
}
.padding()
.onAppear {
loadWeightData()
}
}
private func loadWeightData() {
isLoading = true
let request: NSFetchRequest<WorkoutLog> = WorkoutLog.fetchRequest()
request.predicate = NSPredicate(format: "exerciseName == %@ AND completed == YES", exerciseName)
request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.date, ascending: true)]
if let logs = try? viewContext.fetch(request) {
weightData = logs.map { log in
WeightDataPoint(date: log.date, weight: Int(log.weight))
}
generateMotivationalMessage()
private var motivationalMessage: String {
let data = weightData
guard data.count >= 2 else {
return "Complete more workouts to track your progress!"
}
isLoading = false
}
private func generateMotivationalMessage() {
guard weightData.count >= 2 else {
motivationalMessage = "Complete more workouts to track your progress!"
return
}
let firstWeight = weightData.first?.weight ?? 0
let currentWeight = weightData.last?.weight ?? 0
let firstWeight = data.first?.weight ?? 0
let currentWeight = data.last?.weight ?? 0
let weightDifference = currentWeight - firstWeight
if weightDifference > 0 {
let percentIncrease = Int((Double(weightDifference) / Double(firstWeight)) * 100)
let percentIncrease = firstWeight > 0
? Int((Double(weightDifference) / Double(firstWeight)) * 100)
: 0
if percentIncrease >= 20 {
motivationalMessage = "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
return "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
} else if percentIncrease >= 10 {
motivationalMessage = "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
return "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)!"
} else {
motivationalMessage = "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
return "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up!"
}
} else if weightDifference == 0 {
motivationalMessage = "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
return "You're maintaining consistent weight. Focus on form and consider increasing when ready!"
} else {
motivationalMessage = "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
return "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!"
}
}
}
@@ -8,24 +8,47 @@
//
import SwiftUI
import CoreData
import SwiftData
struct WorkoutLogListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@ObservedObject var workout: Workout
let workout: Workout
/// Working copy of the workout aggregate. Active-session edits mutate this
/// local value (not the cache entity), which avoids losing rapid taps to the
/// fileobservercache lag, and is the single source of truth while the
/// screen is open.
@State private var doc: WorkoutDocument
@State private var showingAddSheet = false
@State private var itemToDelete: WorkoutLog? = nil
@State private var newlyAddedLog: WorkoutLog? = nil
@State private var logToDelete: WorkoutLogDocument?
@State private var addedLog: AddedLogRoute?
var sortedWorkoutLogs: [WorkoutLog] {
workout.logsArray
/// Wrapper so the programmatic push after adding an exercise uses a distinct
/// `navigationDestination(item:)` and doesn't collide with the value-based
/// row links registered for `String`.
private struct AddedLogRoute: Identifiable, Hashable { let id: String }
init(workout: Workout) {
self.workout = workout
_doc = State(initialValue: WorkoutDocument(from: workout))
}
private var sortedLogs: [WorkoutLogDocument] {
doc.logs.sorted { $0.order < $1.order }
}
/// The split this workout was started from (for adding more exercises).
private var split: Split? {
guard let splitID = doc.splitID else { return nil }
return CacheMapper.fetchSplit(id: splitID, in: modelContext)
}
var body: some View {
Group {
if sortedWorkoutLogs.isEmpty {
if sortedLogs.isEmpty {
ContentUnavailableView {
Label("No Exercises", systemImage: "figure.strengthtraining.traditional")
} description: {
@@ -40,15 +63,11 @@ struct WorkoutLogListView: View {
}
} else {
Form {
Section(header: Text("\(workout.label)")) {
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
let workoutLogStatus = log.status.checkboxStatus
NavigationLink {
ExerciseView(workoutLog: log)
} label: {
Section(header: Text(label)) {
ForEach(sortedLogs) { log in
NavigationLink(value: log.id) {
CheckboxListItem(
status: workoutLogStatus,
status: workoutStatus(log).checkboxStatus,
title: log.exerciseName,
subtitle: subtitleForLog(log)
) {
@@ -65,7 +84,7 @@ struct WorkoutLogListView: View {
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
itemToDelete = log
logToDelete = log
} label: {
Label("Delete", systemImage: "trash")
}
@@ -77,130 +96,173 @@ struct WorkoutLogListView: View {
}
}
}
.navigationDestination(item: $newlyAddedLog) { log in
ExerciseView(workoutLog: log)
.navigationDestination(for: String.self) { logID in
ExerciseView(workout: workout, logID: logID)
}
.navigationDestination(item: $addedLog) { route in
// Seed with our working doc so the brand-new log is available before
// the cache catches up.
ExerciseView(workout: workout, logID: route.id, seedDoc: doc)
}
.navigationTitle(doc.splitName ?? Split.unnamed)
// Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
// cache reflects them, so the list shows live status on return.
.onChange(of: workout.updatedAt) { _, _ in
doc = WorkoutDocument(from: workout)
}
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {
Button {
showingAddSheet.toggle()
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddSheet) {
SplitExercisePickerSheet(
split: workout.split,
existingExerciseNames: Set(sortedWorkoutLogs.map { $0.exerciseName })
split: split,
existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
) { exercise in
addExerciseFromSplit(exercise)
}
}
.confirmationDialog(
"Delete Exercise?",
isPresented: Binding<Bool>(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
isPresented: Binding(
get: { logToDelete != nil },
set: { if !$0 { logToDelete = nil } }
),
titleVisibility: .visible
) {
titleVisibility: .visible,
presenting: logToDelete
) { log in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
updateWorkoutStatus()
try? viewContext.save()
itemToDelete = nil
}
}
deleteLog(log)
logToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
logToDelete = nil
}
}
}
private func cycleStatus(for log: WorkoutLog) {
switch log.status {
case .notStarted:
log.status = .inProgress
case .inProgress:
log.status = .completed
case .completed:
log.status = .notStarted
case .skipped:
log.status = .notStarted
}
updateWorkoutStatus()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
}
// MARK: - Derived
private func completeLog(_ log: WorkoutLog) {
log.status = .completed
updateWorkoutStatus()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
}
private func updateWorkoutStatus() {
let logs = sortedWorkoutLogs
let allCompleted = logs.allSatisfy { $0.status == .completed }
let anyInProgress = logs.contains { $0.status == .inProgress }
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
if allCompleted {
workout.status = .completed
workout.end = Date()
} else if anyInProgress || !allNotStarted {
workout.status = .inProgress
private var label: String {
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
if doc.start.isSameDay(as: end) {
return "\(doc.start.formattedDate())\(end.formattedTime())"
} else {
return "\(doc.start.formattedDate())\(end.formattedDate())"
}
} else {
workout.status = .notStarted
return doc.start.formattedDate()
}
}
private func workoutStatus(_ log: WorkoutLogDocument) -> WorkoutStatus {
WorkoutStatus(rawValue: log.status) ?? .notStarted
}
// MARK: - Mutations (drive the local doc, persist via SyncEngine)
private func cycleStatus(for log: WorkoutLogDocument) {
guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
let next: WorkoutStatus
switch workoutStatus(log) {
case .notStarted: next = .inProgress
case .inProgress: next = .completed
case .completed: next = .notStarted
case .skipped: next = .notStarted
}
doc.logs[i].status = next.rawValue
doc.logs[i].completed = (next == .completed)
save()
}
private func completeLog(_ log: WorkoutLogDocument) {
guard let i = doc.logs.firstIndex(where: { $0.id == log.id }) else { return }
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
save()
}
private func deleteLog(_ log: WorkoutLogDocument) {
withAnimation {
doc.logs.removeAll { $0.id == log.id }
save()
}
}
private func moveLog(from source: IndexSet, to destination: Int) {
var logs = sortedWorkoutLogs
var logs = sortedLogs
logs.move(fromOffsets: source, toOffset: destination)
for (index, log) in logs.enumerated() {
log.order = Int32(index)
if let i = doc.logs.firstIndex(where: { $0.id == log.id }) {
doc.logs[i].order = index
}
}
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
save()
}
private func addExerciseFromSplit(_ exercise: Exercise) {
let now = Date()
// Update workout start time if this is the first exercise
if sortedWorkoutLogs.isEmpty {
workout.start = now
// Reuse the workout's start time only when it's the very first exercise.
if doc.logs.isEmpty {
doc.start = now
}
workout.end = nil
doc.end = nil
let log = WorkoutLog(context: viewContext)
log.exerciseName = exercise.name
log.date = now
log.order = Int32(sortedWorkoutLogs.count)
log.sets = exercise.sets
log.reps = exercise.reps
log.weight = exercise.weight
log.loadType = exercise.loadType
log.duration = exercise.duration
log.status = .notStarted
log.workout = workout
let newLog = WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: doc.logs.count,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: now
)
doc.logs.append(newLog)
save()
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
// Navigate to the new exercise view
newlyAddedLog = log
// Push the new exercise straight away.
addedLog = AddedLogRoute(id: newLog.id)
}
private func subtitleForLog(_ log: WorkoutLog) -> String {
if log.loadTypeEnum == .duration {
let mins = log.durationMinutes
let secs = log.durationSeconds
/// Recompute the workout's status/end from its logs, then persist.
private func save() {
let statuses = doc.logs.map { workoutStatus($0) }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
private func subtitleForLog(_ log: WorkoutLogDocument) -> String {
if LoadType(rawValue: log.loadType) == .duration {
let mins = log.durationSeconds / 60
let secs = log.durationSeconds % 60
if mins > 0 && secs > 0 {
return "\(log.sets) × \(mins)m \(secs)s"
} else if mins > 0 {
@@ -224,7 +286,7 @@ struct SplitExercisePickerSheet: View {
let onExerciseSelected: (Exercise) -> Void
private var availableExercises: [Exercise] {
guard let split = split else { return [] }
guard let split else { return [] }
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
}
@@ -233,7 +295,7 @@ struct SplitExercisePickerSheet: View {
Group {
if !availableExercises.isEmpty {
List {
ForEach(availableExercises, id: \.objectID) { exercise in
ForEach(availableExercises) { exercise in
Button {
onExerciseSelected(exercise)
dismiss()
@@ -8,29 +8,29 @@
//
import SwiftUI
import CoreData
import SwiftData
struct WorkoutLogsView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
animation: .default
)
private var workouts: FetchedResults<Workout>
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@State private var showingSplitPicker = false
@State private var showingSettings = false
@State private var itemToDelete: Workout? = nil
@State private var itemToDelete: Workout?
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
var body: some View {
NavigationStack {
List {
ForEach(workouts, id: \.objectID) { workout in
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
ForEach(workouts) { workout in
NavigationLink {
WorkoutLogListView(workout: workout)
} label: {
CalendarListItem(
date: workout.start,
title: workout.split?.name ?? Split.unnamed,
title: workout.splitName ?? Split.unnamed,
subtitle: getSubtitle(for: workout),
subtitle2: workout.statusName
)
@@ -77,21 +77,16 @@ struct WorkoutLogsView: View {
}
.confirmationDialog(
"Delete Workout?",
isPresented: Binding<Bool>(
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible
) {
titleVisibility: .visible,
presenting: itemToDelete
) { workout in
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
viewContext.delete(item)
try? viewContext.save()
WatchConnectivityManager.shared.syncAllData()
itemToDelete = nil
}
}
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
@@ -112,22 +107,16 @@ struct WorkoutLogsView: View {
// MARK: - Split Picker Sheet
struct SplitPickerSheet: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var splits: FetchedResults<Split>
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
private var splits: [Split]
var body: some View {
NavigationStack {
List {
ForEach(splits, id: \.objectID) { split in
ForEach(splits) { split in
Button {
startWorkout(with: split)
} label: {
@@ -155,35 +144,40 @@ struct SplitPickerSheet: View {
}
private func startWorkout(with split: Split) {
let workout = Workout(context: viewContext)
workout.start = Date()
workout.status = .notStarted
workout.split = split
for exercise in split.exercisesArray {
let workoutLog = WorkoutLog(context: viewContext)
workoutLog.exerciseName = exercise.name
workoutLog.date = Date()
workoutLog.order = exercise.order
workoutLog.sets = exercise.sets
workoutLog.reps = exercise.reps
workoutLog.weight = exercise.weight
workoutLog.loadType = exercise.loadType
workoutLog.duration = exercise.duration
workoutLog.status = .notStarted
workoutLog.workout = workout
let start = Date()
let logs = split.exercisesArray.enumerated().map { index, exercise in
WorkoutLogDocument(
id: ULID.make(),
exerciseName: exercise.name,
order: index,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationTotalSeconds,
currentStateIndex: 0,
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: start
)
}
try? viewContext.save()
// Sync to Watch
WatchConnectivityManager.shared.syncAllData()
// A freshly started workout has no `end` only completion stamps it.
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema,
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: start,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: start,
updatedAt: start,
logs: logs
)
Task { await sync.save(workout: doc) }
dismiss()
}
}
#Preview {
WorkoutLogsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}