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,32 +8,55 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct ExerciseAddEditView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showingExercisePicker = false
|
||||
|
||||
@ObservedObject var exercise: Exercise
|
||||
// The exercise entity provides initial values (read-only).
|
||||
let exercise: Exercise
|
||||
// The parent split is needed to rebuild and save the SplitDocument.
|
||||
let split: Split
|
||||
|
||||
@State private var originalWeight: Int32? = nil
|
||||
@State private var loadType: LoadType = .none
|
||||
// Local editable state
|
||||
@State private var exerciseName: String
|
||||
@State private var originalWeight: Int
|
||||
@State private var loadType: LoadType
|
||||
@State private var minutes: Int
|
||||
@State private var seconds: Int
|
||||
@State private var weightTens: Int
|
||||
@State private var weightOnes: Int
|
||||
@State private var reps: Int
|
||||
@State private var sets: Int
|
||||
@State private var weightReminderWeeks: Int
|
||||
@State private var weightLastUpdated: Date?
|
||||
|
||||
@State private var minutes = 0
|
||||
@State private var seconds = 0
|
||||
init(exercise: Exercise, split: Split) {
|
||||
self.exercise = exercise
|
||||
self.split = split
|
||||
|
||||
@State private var weight_tens = 0
|
||||
@State private var weight = 0
|
||||
|
||||
@State private var reps: Int = 0
|
||||
@State private var sets: Int = 0
|
||||
let w = exercise.weight
|
||||
_exerciseName = State(initialValue: exercise.name)
|
||||
_originalWeight = State(initialValue: w)
|
||||
_loadType = State(initialValue: exercise.loadTypeEnum)
|
||||
_minutes = State(initialValue: exercise.durationMinutes)
|
||||
_seconds = State(initialValue: exercise.durationSeconds)
|
||||
_weightTens = State(initialValue: (w / 10) * 10)
|
||||
_weightOnes = State(initialValue: w % 10)
|
||||
_reps = State(initialValue: exercise.reps)
|
||||
_sets = State(initialValue: exercise.sets)
|
||||
_weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks)
|
||||
_weightLastUpdated = State(initialValue: exercise.weightLastUpdated)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Exercise")) {
|
||||
if exercise.name.isEmpty {
|
||||
if exerciseName.isEmpty {
|
||||
Button(action: {
|
||||
showingExercisePicker = true
|
||||
}) {
|
||||
@@ -45,7 +68,7 @@ struct ExerciseAddEditView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ListItem(title: exercise.name)
|
||||
ListItem(title: exerciseName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +97,10 @@ struct ExerciseAddEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Load Type"), footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")) {
|
||||
Section(
|
||||
header: Text("Load Type"),
|
||||
footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")
|
||||
) {
|
||||
Picker("", selection: $loadType) {
|
||||
ForEach(LoadType.allCases, id: \.self) { load in
|
||||
Text(load.name)
|
||||
@@ -87,7 +113,7 @@ struct ExerciseAddEditView: View {
|
||||
if loadType == .weight {
|
||||
Section(header: Text("Weight")) {
|
||||
HStack {
|
||||
Picker("", selection: $weight_tens) {
|
||||
Picker("", selection: $weightTens) {
|
||||
ForEach(0..<100) { lbs in
|
||||
Text("\(lbs * 10)").tag(lbs * 10)
|
||||
}
|
||||
@@ -95,7 +121,7 @@ struct ExerciseAddEditView: View {
|
||||
.frame(height: 100)
|
||||
.pickerStyle(.wheel)
|
||||
|
||||
Picker("", selection: $weight) {
|
||||
Picker("", selection: $weightOnes) {
|
||||
ForEach(0..<10) { lbs in
|
||||
Text("\(lbs)").tag(lbs)
|
||||
}
|
||||
@@ -130,36 +156,21 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
Section(header: Text("Weight Increase")) {
|
||||
HStack {
|
||||
Text("Remind every \(exercise.weightReminderTimeIntervalWeeks) weeks")
|
||||
Text("Remind every \(weightReminderWeeks) weeks")
|
||||
Spacer()
|
||||
Stepper("", value: Binding(
|
||||
get: { Int(exercise.weightReminderTimeIntervalWeeks) },
|
||||
set: { exercise.weightReminderTimeIntervalWeeks = Int32($0) }
|
||||
), in: 0...366)
|
||||
Stepper("", value: $weightReminderWeeks, in: 0...366)
|
||||
}
|
||||
if let lastUpdated = exercise.weightLastUpdated {
|
||||
if let lastUpdated = weightLastUpdated {
|
||||
Text("Last weight change \(Date().humanTimeInterval(to: lastUpdated)) ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
originalWeight = exercise.weight
|
||||
weight_tens = Int(exercise.weight) / 10 * 10
|
||||
weight = Int(exercise.weight) % 10
|
||||
loadType = exercise.loadTypeEnum
|
||||
sets = Int(exercise.sets)
|
||||
reps = Int(exercise.reps)
|
||||
if let duration = exercise.duration {
|
||||
minutes = Int(duration.timeIntervalSince1970) / 60
|
||||
seconds = Int(duration.timeIntervalSince1970) % 60
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView { exerciseNames in
|
||||
exercise.name = exerciseNames.first ?? "Unnamed"
|
||||
exerciseName = exerciseNames.first ?? "Unnamed"
|
||||
}
|
||||
}
|
||||
.navigationTitle(exercise.name.isEmpty ? "New Exercise" : exercise.name)
|
||||
.navigationTitle(exerciseName.isEmpty ? "New Exercise" : exerciseName)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
@@ -169,19 +180,31 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
if let originalWeight = originalWeight, originalWeight != Int32(weight_tens + weight) {
|
||||
exercise.weightLastUpdated = Date()
|
||||
}
|
||||
exercise.duration = Date(timeIntervalSince1970: Double(minutes * 60 + seconds))
|
||||
exercise.weight = Int32(weight_tens + weight)
|
||||
exercise.sets = Int32(sets)
|
||||
exercise.reps = Int32(reps)
|
||||
exercise.loadType = Int32(loadType.rawValue)
|
||||
try? viewContext.save()
|
||||
saveExercise()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveExercise() {
|
||||
let newWeight = weightTens + weightOnes
|
||||
let updatedWeightDate: Date? = newWeight != originalWeight ? Date() : weightLastUpdated
|
||||
let durationSecs = minutes * 60 + seconds
|
||||
|
||||
var doc = SplitDocument(from: split)
|
||||
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) {
|
||||
doc.exercises[idx].name = exerciseName
|
||||
doc.exercises[idx].sets = sets
|
||||
doc.exercises[idx].reps = reps
|
||||
doc.exercises[idx].weight = newWeight
|
||||
doc.exercises[idx].loadType = loadType.rawValue
|
||||
doc.exercises[idx].durationSeconds = durationSecs
|
||||
doc.exercises[idx].weightLastUpdated = updatedWeightDate
|
||||
doc.exercises[idx].weightReminderWeeks = weightReminderWeeks
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,156 +8,196 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct ExerciseListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@ObservedObject var split: Split
|
||||
var split: Split
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
@State private var createdWorkout: Workout? = nil
|
||||
/// ID of the just-created workout; drives programmatic navigation once the
|
||||
/// cache observer delivers the entity a beat after the file write.
|
||||
@State private var pendingWorkoutID: String? = nil
|
||||
@State private var resolvedWorkout: Workout? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
let sortedExercises = split.exercisesArray
|
||||
Form {
|
||||
let sortedExercises = split.exercisesArray
|
||||
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises, id: \.objectID) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(split.name)")
|
||||
}
|
||||
.navigationTitle(split.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Start This Split") {
|
||||
startWorkout()
|
||||
}
|
||||
.disabled(split.exercisesArray.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $createdWorkout) { workout in
|
||||
// Navigate to the workout log once the entity appears in the cache.
|
||||
.navigationDestination(item: $resolvedWorkout) { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
}
|
||||
// Poll for the entity after we write the document.
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
addExercises(names: exerciseNames)
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item)
|
||||
ExerciseAddEditView(exercise: item, split: split)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
isPresented: Binding(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: itemToDelete
|
||||
) { item in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
viewContext.delete(item)
|
||||
try? viewContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
deleteExercise(item)
|
||||
itemToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: { item in
|
||||
Text("Remove \"\(item.name)\" from this split?")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func pollForWorkout(id: String) {
|
||||
Task {
|
||||
// Give the file→observer→cache loop a moment to complete (typically < 1 s).
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
|
||||
resolvedWorkout = workout
|
||||
pendingWorkoutID = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
// If still not available after ~3 s, clear the pending ID silently.
|
||||
pendingWorkoutID = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
var exercises = split.exercisesArray
|
||||
exercises.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, exercise) in exercises.enumerated() {
|
||||
exercise.order = Int32(index)
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.exercises = exercises.enumerated().map { i, ex in
|
||||
var ed = ExerciseDocument(from: ex)
|
||||
ed.order = i
|
||||
return ed
|
||||
}
|
||||
try? viewContext.save()
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
|
||||
private func startWorkout() {
|
||||
let workout = Workout(context: viewContext)
|
||||
workout.start = Date()
|
||||
workout.end = 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.status = .notStarted
|
||||
workoutLog.workout = workout
|
||||
let start = Date()
|
||||
let logs = split.exercisesArray.enumerated().map { i, ex in
|
||||
WorkoutLogDocument(
|
||||
id: ULID.make(), exerciseName: ex.name, order: i,
|
||||
sets: ex.sets, reps: ex.reps, weight: ex.weight,
|
||||
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
|
||||
currentStateIndex: 0, completed: false,
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
notes: nil, date: start
|
||||
)
|
||||
}
|
||||
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)
|
||||
pendingWorkoutID = doc.id
|
||||
}
|
||||
|
||||
try? viewContext.save()
|
||||
createdWorkout = workout
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
if names.count == 1 {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = names.first ?? "Unnamed"
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
try? viewContext.save()
|
||||
itemToEdit = exercise
|
||||
} else {
|
||||
let existingNames = Set(split.exercisesArray.map { $0.name })
|
||||
for name in names where !existingNames.contains(name) {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = name
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
var doc = SplitDocument(from: split)
|
||||
let existingNames = Set(doc.exercises.map { $0.name })
|
||||
let base = doc.exercises.count
|
||||
let newDocs = names
|
||||
.filter { !existingNames.contains($0) }
|
||||
.enumerated()
|
||||
.map { i, exName in
|
||||
ExerciseDocument(
|
||||
id: ULID.make(), name: exName, order: base + i,
|
||||
sets: 3, reps: 10, weight: 40,
|
||||
loadType: LoadType.weight.rawValue,
|
||||
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
|
||||
)
|
||||
}
|
||||
try? viewContext.save()
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
|
||||
private func deleteExercise(_ exercise: Exercise) {
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.exercises.removeAll { $0.id == exercise.id }
|
||||
for i in doc.exercises.indices {
|
||||
doc.exercises[i].order = i
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +135,7 @@ struct ExercisePickerView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadExerciseLists()
|
||||
exerciseLists = ExerciseListLoader.loadExerciseLists()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExerciseLists() {
|
||||
exerciseLists = ExerciseListLoader.loadExerciseLists()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Gates the whole UI on iCloud availability. Files are the source of truth, so
|
||||
/// there is no meaningful app without iCloud — we never fall back to local-only.
|
||||
struct RootGateView: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch syncEngine.iCloudStatus {
|
||||
case .checking:
|
||||
ProgressView("Connecting to iCloud…")
|
||||
case .available:
|
||||
ContentView()
|
||||
case .unavailable:
|
||||
ContentUnavailableView {
|
||||
Label("iCloud Required", systemImage: "icloud.slash")
|
||||
} description: {
|
||||
Text("Sign in to iCloud in Settings to use Workouts. Your data lives in iCloud Drive so it's safe and on all your devices.")
|
||||
} actions: {
|
||||
Button("Try Again") { Task { await syncEngine.connect() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active, syncEngine.iCloudStatus == .unavailable {
|
||||
Task { await syncEngine.connect() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,14 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
import IndieAbout
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [
|
||||
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
||||
],
|
||||
animation: .default
|
||||
)
|
||||
private var splits: FetchedResults<Split>
|
||||
@Query(sort: \Split.order) private var splits: [Split]
|
||||
|
||||
@State private var showingAddSplitSheet = false
|
||||
|
||||
@@ -47,7 +41,7 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ForEach(splits, id: \.objectID) { split in
|
||||
ForEach(splits) { split in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
} label: {
|
||||
@@ -73,12 +67,16 @@ struct SettingsView: View {
|
||||
Text("Add Split")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account Section
|
||||
Section(header: Text("Account")) {
|
||||
Text("Settings coming soon")
|
||||
.foregroundColor(.secondary)
|
||||
Button {
|
||||
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "wand.and.sparkles")
|
||||
.foregroundColor(.accentColor)
|
||||
Text("Add Starter Splits")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - About Section
|
||||
@@ -99,8 +97,3 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
|
||||
@@ -6,25 +6,5 @@
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol for items that can be ordered in a sequence
|
||||
protocol OrderableItem {
|
||||
/// Updates the order of the item to the specified index
|
||||
func updateOrder(to index: Int)
|
||||
}
|
||||
|
||||
/// Extension to make Split conform to OrderableItem
|
||||
extension Split: OrderableItem {
|
||||
func updateOrder(to index: Int) {
|
||||
self.order = Int32(index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to make Exercise conform to OrderableItem
|
||||
extension Exercise: OrderableItem {
|
||||
func updateOrder(to index: Int) {
|
||||
self.order = Int32(index)
|
||||
}
|
||||
}
|
||||
// No longer used — reordering is now a SyncEngine document write (onMove → doc save).
|
||||
// File kept to avoid Xcode project reference errors.
|
||||
|
||||
@@ -6,92 +6,5 @@
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public struct SortableForEach<Data, Content>: View where Data: Hashable, Content: View {
|
||||
@Binding var data: [Data]
|
||||
@Binding var allowReordering: Bool
|
||||
private let content: (Data, Bool) -> Content
|
||||
|
||||
@State private var draggedItem: Data?
|
||||
@State private var hasChangedLocation: Bool = false
|
||||
|
||||
public init(_ data: Binding<[Data]>,
|
||||
allowReordering: Binding<Bool>,
|
||||
@ViewBuilder content: @escaping (Data, Bool) -> Content) {
|
||||
_data = data
|
||||
_allowReordering = allowReordering
|
||||
self.content = content
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ForEach(data, id: \.self) { item in
|
||||
if allowReordering {
|
||||
content(item, hasChangedLocation && draggedItem == item)
|
||||
.onDrag {
|
||||
draggedItem = item
|
||||
return NSItemProvider(object: "\(item.hashValue)" as NSString)
|
||||
}
|
||||
.onDrop(of: [UTType.plainText], delegate: DragRelocateDelegate(
|
||||
item: item,
|
||||
data: $data,
|
||||
draggedItem: $draggedItem,
|
||||
hasChangedLocation: $hasChangedLocation))
|
||||
} else {
|
||||
content(item, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DragRelocateDelegate<ItemType>: DropDelegate where ItemType: Equatable {
|
||||
let item: ItemType
|
||||
@Binding var data: [ItemType]
|
||||
@Binding var draggedItem: ItemType?
|
||||
@Binding var hasChangedLocation: Bool
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
guard item != draggedItem,
|
||||
let current = draggedItem,
|
||||
let from = data.firstIndex(of: current),
|
||||
let to = data.firstIndex(of: item)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
hasChangedLocation = true
|
||||
|
||||
if data[to] != current {
|
||||
withAnimation {
|
||||
data.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: (to > from) ? to + 1 : to
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
// Update the order property of each item to match its position in the array
|
||||
updateItemOrders()
|
||||
|
||||
hasChangedLocation = false
|
||||
draggedItem = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Helper method to update the order property of each item
|
||||
private func updateItemOrders() {
|
||||
for (index, item) in data.enumerated() {
|
||||
if let orderableItem = item as? any OrderableItem {
|
||||
orderableItem.updateOrder(to: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No longer used — list reordering is handled with SwiftUI's native onMove modifier
|
||||
// backed by SyncEngine document writes. File kept to avoid Xcode project reference errors.
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct SplitAddEditView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let split: Split?
|
||||
@@ -23,8 +24,6 @@ struct SplitAddEditView: View {
|
||||
@State private var showingIconPicker: Bool = false
|
||||
@State private var showingDeleteConfirmation: Bool = false
|
||||
|
||||
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
|
||||
|
||||
var isEditing: Bool { split != nil }
|
||||
|
||||
init(split: Split?, onDelete: (() -> Void)? = nil) {
|
||||
@@ -117,8 +116,9 @@ struct SplitAddEditView: View {
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let split = split {
|
||||
viewContext.delete(split)
|
||||
try? viewContext.save()
|
||||
Task {
|
||||
await sync.delete(split: split)
|
||||
}
|
||||
dismiss()
|
||||
onDelete?()
|
||||
}
|
||||
@@ -131,18 +131,28 @@ struct SplitAddEditView: View {
|
||||
|
||||
private func save() {
|
||||
if let split = split {
|
||||
// Update existing
|
||||
split.name = name
|
||||
split.color = color
|
||||
split.systemImage = systemImage
|
||||
// Update existing split
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.name = name
|
||||
doc.color = color
|
||||
doc.systemImage = systemImage
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
} else {
|
||||
// Create new
|
||||
let newSplit = Split(context: viewContext)
|
||||
newSplit.name = name
|
||||
newSplit.color = color
|
||||
newSplit.systemImage = systemImage
|
||||
newSplit.order = 0
|
||||
// Create new split
|
||||
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
|
||||
let doc = SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchema,
|
||||
id: ULID.make(),
|
||||
name: name,
|
||||
color: color,
|
||||
systemImage: systemImage,
|
||||
order: existing.count,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date(),
|
||||
exercises: []
|
||||
)
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
try? viewContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct SplitDetailView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var split: Split
|
||||
var split: Split
|
||||
|
||||
@State private var showingExerciseAddSheet: Bool = false
|
||||
@State private var showingSplitEditSheet: Bool = false
|
||||
@@ -22,54 +22,52 @@ struct SplitDetailView: View {
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("What is a Split?")) {
|
||||
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||
.font(.caption)
|
||||
}
|
||||
Form {
|
||||
Section(header: Text("What is a Split?")) {
|
||||
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Section(header: Text("Exercises")) {
|
||||
let sortedExercises = split.exercisesArray
|
||||
Section(header: Text("Exercises")) {
|
||||
let sortedExercises = split.exercisesArray
|
||||
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises, id: \.objectID) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
}
|
||||
.onMove(perform: moveExercises)
|
||||
|
||||
Button {
|
||||
showingExerciseAddSheet = true
|
||||
} label: {
|
||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
Button {
|
||||
showingExerciseAddSheet = true
|
||||
} label: {
|
||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||
}
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(split.name)")
|
||||
}
|
||||
.navigationTitle(split.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
@@ -90,62 +88,73 @@ struct SplitDetailView: View {
|
||||
}
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item)
|
||||
ExerciseAddEditView(exercise: item, split: split)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
isPresented: Binding(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: itemToDelete
|
||||
) { item in
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
viewContext.delete(item)
|
||||
try? viewContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
deleteExercise(item)
|
||||
itemToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: { item in
|
||||
Text("Remove \"\(item.name)\" from this split?")
|
||||
}
|
||||
}
|
||||
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
var exercises = split.exercisesArray
|
||||
exercises.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, exercise) in exercises.enumerated() {
|
||||
exercise.order = Int32(index)
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.exercises = exercises.enumerated().map { i, ex in
|
||||
var ed = ExerciseDocument(from: ex)
|
||||
ed.order = i
|
||||
return ed
|
||||
}
|
||||
try? viewContext.save()
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
if names.count == 1 {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = names.first ?? "Unnamed"
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
try? viewContext.save()
|
||||
itemToEdit = exercise
|
||||
} else {
|
||||
let existingNames = Set(split.exercisesArray.map { $0.name })
|
||||
for name in names where !existingNames.contains(name) {
|
||||
let exercise = Exercise(context: viewContext)
|
||||
exercise.name = name
|
||||
exercise.order = Int32(split.exercisesArray.count)
|
||||
exercise.sets = 3
|
||||
exercise.reps = 10
|
||||
exercise.weight = 40
|
||||
exercise.loadType = Int32(LoadType.weight.rawValue)
|
||||
exercise.split = split
|
||||
var doc = SplitDocument(from: split)
|
||||
let existingNames = Set(doc.exercises.map { $0.name })
|
||||
let base = doc.exercises.count
|
||||
let newDocs = names
|
||||
.filter { !existingNames.contains($0) }
|
||||
.enumerated()
|
||||
.map { i, exName in
|
||||
ExerciseDocument(
|
||||
id: ULID.make(), name: exName, order: base + i,
|
||||
sets: 3, reps: 10, weight: 40,
|
||||
loadType: LoadType.weight.rawValue,
|
||||
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
|
||||
)
|
||||
}
|
||||
try? viewContext.save()
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
|
||||
// If a single exercise was added, open the edit sheet once the cache refreshes.
|
||||
// We rely on the observer to populate it — no direct entity reference needed.
|
||||
}
|
||||
|
||||
private func deleteExercise(_ exercise: Exercise) {
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.exercises.removeAll { $0.id == exercise.id }
|
||||
// Re-number orders after removal
|
||||
for i in doc.exercises.indices {
|
||||
doc.exercises[i].order = i
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SplitItem: View {
|
||||
@ObservedObject var split: Split
|
||||
var split: Split
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
||||
@@ -8,64 +8,44 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
import SwiftData
|
||||
|
||||
struct SplitListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [
|
||||
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
||||
],
|
||||
animation: .default
|
||||
)
|
||||
private var fetchedSplits: FetchedResults<Split>
|
||||
|
||||
@State private var splits: [Split] = []
|
||||
@State private var allowSorting: Bool = true
|
||||
@Query(sort: \Split.order) private var splits: [Split]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
|
||||
ForEach(splits) { split in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
} label: {
|
||||
SplitItem(split: split)
|
||||
.overlay(dragging ? Color.white.opacity(0.8) : Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.overlay {
|
||||
if fetchedSplits.isEmpty {
|
||||
if splits.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Splits Yet",
|
||||
systemImage: "dumbbell.fill",
|
||||
description: Text("Create a split to organize your workout routine.")
|
||||
label: {
|
||||
Label("No Splits Yet", systemImage: "dumbbell.fill")
|
||||
},
|
||||
description: {
|
||||
Text("Create a split to organize your workout routine.")
|
||||
},
|
||||
actions: {
|
||||
Button("Add Starter Splits") {
|
||||
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
splits = Array(fetchedSplits)
|
||||
}
|
||||
.onChange(of: fetchedSplits.count) { _, _ in
|
||||
splits = Array(fetchedSplits)
|
||||
}
|
||||
.onChange(of: splits) { _, _ in
|
||||
saveContext()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveContext() {
|
||||
if viewContext.hasChanges {
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
print("Error saving after reorder: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,8 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct SplitsView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
|
||||
var body: some View {
|
||||
@@ -32,8 +29,3 @@ struct SplitsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SplitsView()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
|
||||
@@ -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