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:
@@ -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 file→cache 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 file→cache 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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// file→observer→cache 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user