This commit is contained in:
2025-07-25 17:30:11 -04:00
parent 310c120ca3
commit 3fd6887ce7
55 changed files with 1062 additions and 649 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
Artwork/DumbBellIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
Artwork/DumbBellIcon.pxd Normal file

Binary file not shown.

View File

@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "DumbBellIcon-light.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "watchos", "platform" : "watchos",
"size" : "1024x1024" "size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@ -13,15 +13,7 @@ import SwiftData
struct ContentView: View { struct ContentView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
// Use string literal for completed status to avoid enum reference in predicate
// @Query(filter: #Predicate<Workout> { workout in
// workout.status?.rawValue != 3
// }, sort: \Workout.start, order: .reverse) var activeWorkouts: [Workout]
// @Query(sort: [SortDescriptor(\Workout.start)]) var allWorkouts: [Workout]
@State var activeWorkouts: [Workout] = [] @State var activeWorkouts: [Workout] = []
@State var splits: [Split] = []
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@ -32,37 +24,25 @@ struct ContentView: View {
} }
} }
.onAppear { .onAppear {
loadSplits()
loadActiveWorkouts() loadActiveWorkouts()
} }
} }
func loadActiveWorkouts () { func loadActiveWorkouts () {
let completedStatus = WorkoutStatus.completed.rawValue
do { do {
print("loading active workouts")
self.activeWorkouts = try modelContext.fetch(FetchDescriptor<Workout>( self.activeWorkouts = try modelContext.fetch(FetchDescriptor<Workout>(
predicate: #Predicate<Workout> { workout in
workout.status != completedStatus
},
sortBy: [ sortBy: [
SortDescriptor(\Workout.start) SortDescriptor(\Workout.start, order: .reverse)
] ]
)) ))
print("loaded active workouts \(activeWorkouts.count)")
} catch { } catch {
print("ERROR: failed to load active workouts \(error)") print("ERROR: failed to load active workouts \(error)")
} }
} }
func loadSplits () {
do {
self.splits = try modelContext.fetch(FetchDescriptor<Split>(
sortBy: [
SortDescriptor(\Split.order),
SortDescriptor(\Split.name)
]
))
} catch {
print("ERROR: failed to load splits \(error)")
}
}
} }
struct NoActiveWorkoutView: View { struct NoActiveWorkoutView: View {
@ -84,7 +64,7 @@ struct NoActiveWorkoutView: View {
} }
} }
//#Preview { #Preview {
// ContentView() ContentView()
// .modelContainer(AppContainer.preview) .modelContainer(AppContainer.preview)
//} }

View File

@ -135,20 +135,23 @@ final class AppContainer {
// Upper Body Workout (in progress) // Upper Body Workout (in progress)
let upperBodyWorkout = Workout(start: now, end: now, split: upperBodySplit) let upperBodyWorkout = Workout(start: now, end: now, split: upperBodySplit)
upperBodyWorkout.status = .inProgress upperBodyWorkout.status = 2
// upperBodyWorkout.status = .inProgress
upperBodyWorkout.end = nil upperBodyWorkout.end = nil
context.insert(upperBodyWorkout) context.insert(upperBodyWorkout)
// Lower Body Workout (scheduled for tomorrow) // Lower Body Workout (scheduled for tomorrow)
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) ?? now let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) ?? now
let lowerBodyWorkout = Workout(start: tomorrow, end: tomorrow, split: lowerBodySplit) let lowerBodyWorkout = Workout(start: tomorrow, end: tomorrow, split: lowerBodySplit)
lowerBodyWorkout.status = .notStarted lowerBodyWorkout.status = 1
// lowerBodyWorkout.status = .notStarted
context.insert(lowerBodyWorkout) context.insert(lowerBodyWorkout)
// Full Body Workout (completed yesterday) // Full Body Workout (completed yesterday)
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
let fullBodyWorkout = Workout(start: yesterday, end: yesterday, split: fullBodySplit) let fullBodyWorkout = Workout(start: yesterday, end: yesterday, split: fullBodySplit)
fullBodyWorkout.status = .completed fullBodyWorkout.status = 3
// fullBodyWorkout.status = .completed
context.insert(fullBodyWorkout) context.insert(fullBodyWorkout)
// Create workout logs for Upper Body workout (in progress) // Create workout logs for Upper Body workout (in progress)

View File

@ -0,0 +1,18 @@
//
// TimeInterval+minutesSecons.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:22PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
extension Int {
var secondsFormatted: String {
let minutes = self / 60
let seconds = self % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}

View File

@ -0,0 +1,36 @@
//
// ExerciseDoneCard.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:29PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseDoneCard: View {
let elapsedSeconds: Int
let onComplete: () -> Void
var body: some View {
VStack(spacing: 20) {
Button(action: onComplete) {
Text("Done in \(10 - elapsedSeconds)s")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
.padding(.horizontal)
}
.padding()
}
private var timeFormatted: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}

View File

@ -0,0 +1,55 @@
//
// ExerciseIntroView.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:19PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseIntroCard: View {
let log: WorkoutLog
var body: some View {
VStack(alignment: .center, spacing: 16) {
Text(log.exerciseName)
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
HStack(alignment: .bottom) {
Text("\(log.weight)")
Text("lbs")
.fontWeight(.light)
.padding([.trailing], 10)
Text("\(log.sets)")
Text("×")
.fontWeight(.light)
Text("\(log.reps)")
}
.font(.title3)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
Text(log.status?.name ?? "Not Started")
.foregroundStyle(Color.accentColor)
}
.padding()
// VStack(spacing: 20) {
// Text(title)
// .font(.title)
//
// Text(elapsedSeconds.secondsFormatted)
// .font(.system(size: 48, weight: .semibold, design: .monospaced))
// .foregroundStyle(Color.accentColor)
// }
// .padding()
}
}

View File

@ -0,0 +1,30 @@
//
// ExerciseRestCard.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:28PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseRestCard: View {
let elapsedSeconds: Int
var body: some View {
VStack(spacing: 20) {
Text("Resting for")
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
Text(elapsedSeconds.secondsFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(Color.green)
}
.padding()
}
}

View File

@ -0,0 +1,28 @@
//
// ExerciseSetCard.swift
// Workouts
//
// Created by rzen on 7/23/25 at 4:26PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseSetCard: View {
let set: Int
let elapsedSeconds: Int
var body: some View {
VStack(spacing: 20) {
Text("Set \(set)")
.font(.title)
Text(elapsedSeconds.secondsFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(Color.accentColor)
}
.padding()
}
}

View File

@ -0,0 +1,140 @@
//
// ExerciseProgressControlView 2.swift
// Workouts
//
// Created by rzen on 7/23/25 at 9:15AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseProgressControlView: View {
let log: WorkoutLog
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var exerciseStates: [ExerciseState] = []
@State private var currentStateIndex: Int = 0
@State private var elapsedSeconds: Int = 0
@State private var timer: Timer? = nil
@State private var previousStateIndex: Int = 0
var body: some View {
TabView(selection: $currentStateIndex) {
ForEach(Array(exerciseStates.enumerated()), id: \.element.id) { index, state in
if state.isIntro {
ExerciseIntroCard(log: log)
.tag(index)
} else if state.isSet {
ExerciseSetCard(set: state.setNumber ?? 0, elapsedSeconds: elapsedSeconds)
.tag(index)
} else if state.isRest {
ExerciseRestCard(elapsedSeconds: elapsedSeconds)
.tag(index)
} else if state.isDone {
ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise)
.tag(index)
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentStateIndex) { oldValue, newValue in
if oldValue != newValue {
elapsedSeconds = 0
moveToNextState()
}
}
.onAppear {
setupExerciseStates()
currentStateIndex = log.currentStateIndex ?? 0
startTimer()
}
.onDisappear {
stopTimer()
}
}
private func setupExerciseStates() {
var states: [ExerciseState] = []
states.append(.intro)
for i in 1...log.sets {
states.append(.set(number: i))
if i < log.sets {
states.append(.rest(afterSet: i))
}
}
states.append(.done)
exerciseStates = states
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
elapsedSeconds += 1
// Check if we need to provide haptic feedback during rest periods
if currentStateIndex >= 0 && currentStateIndex < exerciseStates.count {
let currentState = exerciseStates[currentStateIndex]
if currentState.isRest {
provideRestHapticFeedback()
} else if currentState.isDone && elapsedSeconds >= 10 {
// Auto-complete after 10 seconds on the DONE state
completeExercise()
}
}
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func moveToNextState() {
if currentStateIndex < exerciseStates.count - 1 {
elapsedSeconds = 0
withAnimation {
currentStateIndex += 1
log.currentStateIndex = currentStateIndex
log.elapsedSeconds = elapsedSeconds
log.status = .inProgress
try? modelContext.save()
}
} else {
// We've reached the end (DONE state)
completeExercise()
}
}
private func provideRestHapticFeedback() {
// Provide haptic feedback based on elapsed time
if elapsedSeconds % 60 == 0 && elapsedSeconds > 0 {
// Triple tap every 60 seconds
HapticFeedback.tripleTap()
} else if elapsedSeconds % 30 == 0 && elapsedSeconds > 0 {
// Double tap every 30 seconds
HapticFeedback.doubleTap()
} else if elapsedSeconds % 10 == 0 && elapsedSeconds > 0 {
// Single tap every 10 seconds
HapticFeedback.success()
}
}
private func completeExercise() {
// Update the workout log status to completed
log.status = .completed
// reset index in case we wish to re-run the exercise
log.currentStateIndex = 0
// Provide "tada" haptic feedback
HapticFeedback.tripleTap()
// Dismiss this view to return to WorkoutDetailView
dismiss()
}
}

View File

@ -0,0 +1,72 @@
//
// ExerciseState.swift
// Workouts
//
// Created by rzen on 7/23/25 at 9:14AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
enum ExerciseState: Identifiable {
case intro
case set(number: Int)
case rest(afterSet: Int)
case done
var id: String {
switch self {
case .intro:
return "detail"
case .set(let number):
return "set_\(number)"
case .rest(let afterSet):
return "rest_\(afterSet)"
case .done:
return "done"
}
}
var setNumber: Int? {
switch self {
case .intro, .rest, .done: return nil
case .set (let number): return number
}
}
var afterSet: Int? {
switch self {
case .intro, .set, .done: return nil
case .rest (let afterSet): return afterSet
}
}
var isIntro: Bool {
if case .intro = self {
return true
}
return false
}
var isSet: Bool {
if case .set = self {
return true
}
return false
}
var isRest: Bool {
if case .rest = self {
return true
}
return false
}
var isDone: Bool {
if case .done = self {
return true
}
return false
}
}

View File

@ -0,0 +1,47 @@
//
// ExerciseStateView 2.swift
// Workouts
//
// Created by rzen on 7/23/25 at 9:15AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ExerciseStateView: View {
let title: String
let isRest: Bool
let isDone: Bool
let elapsedSeconds: Int
let onComplete: () -> Void
var body: some View {
VStack(spacing: 20) {
Text(title)
.font(.title)
Text(timeFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(isRest ? .orange : .accentColor)
if isDone {
Button(action: onComplete) {
Text("Done in \(10 - elapsedSeconds)s")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
.padding(.horizontal)
}
}
.padding()
}
private var timeFormatted: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}

View File

@ -0,0 +1,62 @@
//
// ActiveWorkoutListView.swift
// Workouts
//
// Created by rzen on 7/20/25 at 6:35 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct ActiveWorkoutListView: View {
@Environment(\.modelContext) private var modelContext
let workouts: [Workout]
var body: some View {
List {
ForEach(workouts) { workout in
NavigationLink {
WorkoutDetailView(workout: workout)
} label: {
WorkoutCardView(workout: workout)
}
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.padding(
EdgeInsets(
top: 4,
leading: 8,
bottom: 4,
trailing: 8
)
)
)
// .swipeActions (edge: .trailing, allowsFullSwipe: false) {
// Button {
// //
// } label: {
// Label("Delete", systemImage: "trash")
// .frame(height: 40)
// }
// .tint(.red)
// }
}
}
.listStyle(.carousel)
}
}
#Preview {
let container = AppContainer.preview
let split = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional")
let workout1 = Workout(start: Date(), end: Date(), split: split)
let split2 = Split(name: "Lower Body", color: "red", systemImage: "figure.run")
let workout2 = Workout(start: Date().addingTimeInterval(-3600), end: Date(), split: split2)
ActiveWorkoutListView(workouts: [workout1, workout2])
.modelContainer(container)
}

View File

@ -0,0 +1,36 @@
//
// WorkoutCardView.swift
// Workouts
//
// Created by rzen on 7/22/25 at 9:54PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct WorkoutCardView: View {
let workout: Workout
var body: some View {
VStack(alignment: .leading) {
if let split = workout.split {
Image(systemName: split.systemImage)
.font(.system(size: 48))
.foregroundStyle(split.getColor())
} else {
Image(systemName: "dumbbell.fill")
.font(.system(size: 24))
.foregroundStyle(.gray)
}
Text(workout.split?.name ?? "Workout")
.font(.headline)
.foregroundStyle(.white)
Text(workout.statusName)
.font(.caption)
.foregroundStyle(Color.accentColor)
}
}
}

View File

@ -0,0 +1,50 @@
//
// WorkoutDetailView.swift
// Workouts
//
// Created by rzen on 7/22/25 at 9:54PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct WorkoutDetailView: View {
let workout: Workout
var body: some View {
VStack(alignment: .center, spacing: 8) {
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
List {
ForEach(logs) { log in
NavigationLink {
ExerciseProgressControlView(log: log)
} label: {
WorkoutLogCardView(log: log)
}
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.padding(
EdgeInsets(
top: 4,
leading: 8,
bottom: 4,
trailing: 8
)
)
)
}
}
.listStyle(.carousel)
} else {
Text("No exercises in this workout")
.font(.body)
.foregroundStyle(.secondary)
.padding()
Spacer()
}
}
}
}

View File

@ -0,0 +1,34 @@
//
// WorkoutLogCardView.swift
// Workouts
//
// Created by rzen on 7/22/25 at 9:56PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct WorkoutLogCardView: View {
let log: WorkoutLog
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(log.exerciseName)
.font(.headline)
.lineLimit(1)
Text(log.status?.name ?? "Not Started")
.font(.caption)
.foregroundStyle(Color.accentColor)
HStack(spacing: 12) {
Text("\(log.weight) lbs")
Spacer()
Text("\(log.sets) × \(log.reps)")
}
}
}
}

View File

@ -0,0 +1,69 @@
//
// WorkoutLogDetailView.swift
// Workouts
//
// Created by rzen on 7/22/25 at 9:57PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct WorkoutLogDetailView: View {
let log: WorkoutLog
var body: some View {
NavigationLink {
ExerciseProgressControlView(log: log)
} label: {
VStack(alignment: .center) {
Text(log.exerciseName)
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
HStack (alignment: .bottom) {
Text("\(log.weight)")
Text( "lbs")
.fontWeight(.light)
.padding([.trailing], 10)
Text("\(log.sets)")
Text("×")
.fontWeight(.light)
Text("\(log.reps)")
}
.font(.title3)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
Text(log.status?.name ?? "Not Started")
.foregroundStyle(Color.accentColor)
Text("Tap to start")
.foregroundStyle(Color.accentColor)
}
.padding()
}
.buttonStyle(.plain)
}
private func statusColor(for status: WorkoutStatus?) -> Color {
guard let status = status else { return .secondary }
switch status {
case .notStarted:
return .secondary
case .inProgress:
return .blue
case .completed:
return .green
case .skipped:
return .red
}
}
}

View File

@ -11,7 +11,7 @@ import SwiftUI
import SwiftData import SwiftData
@main @main
struct Worksouts_Watch_AppApp: App { struct Workouts_Watch_AppApp: App {
let container = AppContainer.create() let container = AppContainer.create()
var body: some Scene { var body: some Scene {

View File

@ -0,0 +1,45 @@
////
//// ExerciseDetailView.swift
//// Workouts
////
//// Created by rzen on 7/23/25 at 9:17AM.
////
//// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
////
//
//import SwiftUI
//
//struct ExerciseDetailView: View {
// let log: WorkoutLog
// let onStart: () -> Void
//
// var body: some View {
// VStack(alignment: .center, spacing: 16) {
// Text(log.exerciseName)
// .font(.title)
// .lineLimit(1)
// .minimumScaleFactor(0.5)
// .layoutPriority(1)
//
// HStack(alignment: .bottom) {
// Text("\(log.weight)")
// Text("lbs")
// .fontWeight(.light)
// .padding([.trailing], 10)
//
// Text("\(log.sets)")
// Text("×")
// .fontWeight(.light)
// Text("\(log.reps)")
// }
// .font(.title3)
// .lineLimit(1)
// .minimumScaleFactor(0.5)
// .layoutPriority(1)
//
// Text(log.status?.name ?? "Not Started")
// .foregroundStyle(Color.accentColor)
// }
// .padding()
// }
//}

View File

@ -0,0 +1,35 @@
////
//// ExerciseProgressControlView.swift
//// Workouts
////
//// Created by rzen on 7/20/25 at 7:19 PM.
////
//// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
////
//
//import SwiftUI
//import SwiftData
//
//
//
//
//
//
//// Detail view shown as the first item in the exercise progress carousel
//
//
//// Helper extension to safely access array elements
////extension Array {
//// subscript(safe index: Index) -> Element? {
//// return indices.contains(index) ? self[index] : nil
//// }
////}
//
////#Preview {
//// let container = AppContainer.preview
//// let workout = Workout(start: Date(), end: nil, split: nil)
//// let log = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135)
////
//// ExerciseProgressControlView(log: log)
//// .modelContainer(container)
////}

View File

@ -7,7 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; }; A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; };
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; }; A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; };
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; }; A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; };
@ -20,7 +20,7 @@
containerPortal = A45FA0892E21B3DC00581607 /* Project object */; containerPortal = A45FA0892E21B3DC00581607 /* Project object */;
proxyType = 1; proxyType = 1;
remoteGlobalIDString = A45FA1F02E27171A00581607; remoteGlobalIDString = A45FA1F02E27171A00581607;
remoteInfo = "Worksouts Watch App"; remoteInfo = "Workouts Watch App";
}; };
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
@ -31,7 +31,7 @@
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16; dstSubfolderSpec = 16;
files = ( files = (
A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */, A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */,
); );
name = "Embed Watch Content"; name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -40,7 +40,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; }; A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
A45FA1F12E27171A00581607 /* Worksouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Worksouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; A45FA1F12E27171A00581607 /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -53,7 +53,7 @@
); );
target = A45FA0902E21B3DD00581607 /* Workouts */; target = A45FA0902E21B3DD00581607 /* Workouts */;
}; };
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Worksouts Watch App" target */ = { A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Workouts Watch App" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
_ATTIC_/ContentView_backup.swift, _ATTIC_/ContentView_backup.swift,
@ -66,7 +66,7 @@
Views/Common/CheckboxStatus.swift, Views/Common/CheckboxStatus.swift,
Views/WorkoutLog/WorkoutStatus.swift, Views/WorkoutLog/WorkoutStatus.swift,
); );
target = A45FA1F02E27171A00581607 /* Worksouts Watch App */; target = A45FA1F02E27171A00581607 /* Workouts Watch App */;
}; };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -75,14 +75,14 @@
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */, A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */,
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Worksouts Watch App" target */, A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Workouts Watch App" target */,
); );
path = Workouts; path = Workouts;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A45FA1F22E27171A00581607 /* Worksouts Watch App */ = { A45FA1F22E27171A00581607 /* Workouts Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
path = "Worksouts Watch App"; path = "Workouts Watch App";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
@ -112,9 +112,8 @@
A45FA0882E21B3DC00581607 = { A45FA0882E21B3DC00581607 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A45FA2C02E2D3C0900581607 /* Shared Models */,
A45FA0932E21B3DD00581607 /* Workouts */, A45FA0932E21B3DD00581607 /* Workouts */,
A45FA1F22E27171A00581607 /* Worksouts Watch App */, A45FA1F22E27171A00581607 /* Workouts Watch App */,
A45FA0922E21B3DD00581607 /* Products */, A45FA0922E21B3DD00581607 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -123,18 +122,11 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A45FA0912E21B3DD00581607 /* Workouts.app */, A45FA0912E21B3DD00581607 /* Workouts.app */,
A45FA1F12E27171A00581607 /* Worksouts Watch App.app */, A45FA1F12E27171A00581607 /* Workouts Watch App.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A45FA2C02E2D3C0900581607 /* Shared Models */ = {
isa = PBXGroup;
children = (
);
path = "Shared Models";
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -166,9 +158,9 @@
productReference = A45FA0912E21B3DD00581607 /* Workouts.app */; productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
A45FA1F02E27171A00581607 /* Worksouts Watch App */ = { A45FA1F02E27171A00581607 /* Workouts Watch App */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Worksouts Watch App" */; buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */;
buildPhases = ( buildPhases = (
A45FA1ED2E27171A00581607 /* Sources */, A45FA1ED2E27171A00581607 /* Sources */,
A45FA1EE2E27171A00581607 /* Frameworks */, A45FA1EE2E27171A00581607 /* Frameworks */,
@ -179,13 +171,13 @@
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
A45FA1F22E27171A00581607 /* Worksouts Watch App */, A45FA1F22E27171A00581607 /* Workouts Watch App */,
); );
name = "Worksouts Watch App"; name = "Workouts Watch App";
packageProductDependencies = ( packageProductDependencies = (
); );
productName = "Worksouts Watch App"; productName = "Workouts Watch App";
productReference = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */; productReference = A45FA1F12E27171A00581607 /* Workouts Watch App.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@ -225,7 +217,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
A45FA0902E21B3DD00581607 /* Workouts */, A45FA0902E21B3DD00581607 /* Workouts */,
A45FA1F02E27171A00581607 /* Worksouts Watch App */, A45FA1F02E27171A00581607 /* Workouts Watch App */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -267,7 +259,7 @@
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
A45FA1FD2E27171B00581607 /* PBXTargetDependency */ = { A45FA1FD2E27171B00581607 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = A45FA1F02E27171A00581607 /* Worksouts Watch App */; target = A45FA1F02E27171A00581607 /* Workouts Watch App */;
targetProxy = A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */; targetProxy = A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */;
}; };
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
@ -461,14 +453,14 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Worksouts Watch App/Worksouts Watch App.entitlements"; CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Worksouts Watch App/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6; DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Worksouts; INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -492,14 +484,14 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Worksouts Watch App/Worksouts Watch App.entitlements"; CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Worksouts Watch App/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6; DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Worksouts; INFOPLIST_KEY_CFBundleDisplayName = Workouts;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -539,7 +531,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Worksouts Watch App" */ = { A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A45FA2002E27171B00581607 /* Debug */, A45FA2002E27171B00581607 /* Debug */,

View File

@ -4,11 +4,16 @@
<dict> <dict>
<key>SchemeUserState</key> <key>SchemeUserState</key>
<dict> <dict>
<key>Workouts.xcscheme_^#shared#^_</key> <key>Workouts Watch App.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>0</integer>
</dict> </dict>
<key>Workouts.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>Worksouts Watch App.xcscheme_^#shared#^_</key> <key>Worksouts Watch App.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>

View File

@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "DumbBellIcon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@ -12,6 +13,7 @@
"value" : "dark" "value" : "dark"
} }
], ],
"filename" : "DumbBellIcon 1.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@ -23,6 +25,7 @@
"value" : "tinted" "value" : "tinted"
} }
], ],
"filename" : "DumbBellIcon 2.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -5,7 +5,14 @@ import SwiftData
final class Workout { final class Workout {
var start: Date = Date() var start: Date = Date()
var end: Date? var end: Date?
var status: WorkoutStatus? = WorkoutStatus.notStarted var status: Int = 1
// var status: WorkoutStatus = WorkoutStatus.notStarted
//case notStarted = 1
//case inProgress = 2
//case completed = 3
//case skipped = 4
@Relationship(deleteRule: .nullify) @Relationship(deleteRule: .nullify)
var split: Split? var split: Split?
@ -20,10 +27,25 @@ final class Workout {
} }
var label: String { var label: String {
if status == .completed, let endDate = end { if status == 3, let endDate = end {
// if status == .completed, let endDate = end {
return "\(start.formattedDate())\(endDate.formattedDate())" return "\(start.formattedDate())\(endDate.formattedDate())"
} else { } else {
return start.formattedDate() return start.formattedDate()
} }
} }
var statusName: String {
if status == 1 {
return "Not Started"
} else if status == 2 {
return "In Progress"
} else if status == 3 {
return "Completed"
} else if status == 4 {
return "Skipped"
} else {
return "In progress"
}
}
} }

View File

@ -11,6 +11,9 @@ final class WorkoutLog {
var order: Int = 0 var order: Int = 0
var exerciseName: String = "" var exerciseName: String = ""
var currentStateIndex: Int? = nil
var elapsedSeconds: Int? = nil
var completed: Bool = false var completed: Bool = false
@Relationship(deleteRule: .nullify) @Relationship(deleteRule: .nullify)

View File

@ -10,21 +10,6 @@ struct WorkoutsMigrationPlan: SchemaMigrationPlan {
toVersion: SchemaV1.self, toVersion: SchemaV1.self,
willMigrate: { context in willMigrate: { context in
print("migrating from v1 to v1") print("migrating from v1 to v1")
let workouts = try? context.fetch(FetchDescriptor<Workout>())
workouts?.forEach { workout in
if let status = workout.status {
} else {
workout.status = .notStarted
}
// if let endDate = workout.end {
//
// } else {
// workout.end = Date()
// }
workout.end = Date()
}
}, },
didMigrate: { _ in didMigrate: { _ in
// No additional actions needed after migration // No additional actions needed after migration

View File

@ -0,0 +1,198 @@
//
// SplitDetailView.swift
// Workouts
//
// Created by rzen on 7/25/25 at 3:27PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct SplitDetailView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State 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
@State private var showingDeleteConfirmation: Bool = false
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)
}
Section (header: Text("Exercises")) {
List {
if let assignments = split.exercises, !assignments.isEmpty {
let sortedAssignments = assignments.sorted(by: { $0.order == $1.order ? $0.name < $1.name : $0.order < $1.order })
ForEach(sortedAssignments) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs \(item.order)"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
.onMove(perform: { indices, destination in
var exerciseArray = Array(sortedAssignments)
exerciseArray.move(fromOffsets: indices, toOffset: destination)
for (index, exercise) in exerciseArray.enumerated() {
exercise.order = index
}
if let modelContext = exerciseArray.first?.modelContext {
do {
try modelContext.save()
} catch {
print("Error saving after reordering: \(error)")
}
}
})
Button {
showingAddSheet = true
} label: {
ListItem(title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
}
}
Button ("Delete This Split", role: .destructive) {
showingDeleteConfirmation = true
}
.tint(.red)
}
.navigationTitle("\(split.name)")
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Start This Split") {
let workout = Workout(start: Date(), end: Date(), split: split)
modelContext.insert(workout)
if let exercises = split.exercises {
for assignment in exercises {
let workoutLog = WorkoutLog(
workout: workout,
exerciseName: assignment.name,
date: Date(),
order: assignment.order,
sets: assignment.sets,
reps: assignment.reps,
weight: assignment.weight
)
modelContext.insert(workoutLog)
}
}
try? modelContext.save()
// Set the created workout to trigger navigation
createdWorkout = workout
}
}
}
.navigationDestination(item: $createdWorkout, destination: { workout in
WorkoutLogListView(workout: workout)
})
.sheet (isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
let splitId = split.persistentModelID
print("exerciseNames: \(exerciseNames)")
if exerciseNames.count == 1 {
itemToEdit = Exercise(
split: split,
exerciseName: exerciseNames.first ?? "Exercise.unnamed",
order: 0,
sets: 3,
reps: 10,
weight: 40
)
} else {
for exerciseName in exerciseNames {
var duplicateExercise: [Exercise]? = nil
do {
duplicateExercise = try modelContext.fetch(FetchDescriptor<Exercise>(predicate: #Predicate{ exercise in
exerciseName == exercise.name && splitId == exercise.split?.persistentModelID
}))
} catch {
print("ERROR: failed to fetch \(exerciseName)")
}
if let dup = duplicateExercise, dup.count > 0 {
print("Skipping duplicate \(exerciseName) found \(dup.count) duplicate(s)")
} else {
print("Creating \(exerciseName) for \(split.name)")
modelContext.insert(Exercise(
split: split,
exerciseName: exerciseName,
order: 0,
sets: 3,
reps: 10,
weight: 40
))
}
}
}
try? modelContext.save()
}, allowMultiSelect: true)
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(model: item)
}
.confirmationDialog(
"Delete Exercise?",
isPresented: .constant(itemToDelete != nil),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
modelContext.delete(item)
try? modelContext.save()
itemToDelete = nil
}
}
}
}
.confirmationDialog(
"Delete This Split?",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
modelContext.delete(split)
try? modelContext.save()
dismiss()
}
}
}
}

View File

@ -25,7 +25,7 @@ struct SplitsView: View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
NavigationLink { NavigationLink {
ExerciseListView(split: split) SplitDetailView(split: split)
} label: { } label: {
SplitItem( SplitItem(
name: split.name, name: split.name,

View File

@ -40,7 +40,7 @@ struct WorkoutLogListView: View {
CheckboxListItem( CheckboxListItem(
status: workoutLogStatus, status: workoutLogStatus,
title: log.exerciseName, title: log.exerciseName,
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs" subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
) )
.swipeActions(edge: .leading, allowsFullSwipe: false) { .swipeActions(edge: .leading, allowsFullSwipe: false) {
let status = log.status ?? WorkoutStatus.notStarted let status = log.status ?? WorkoutStatus.notStarted
@ -89,6 +89,21 @@ struct WorkoutLogListView: View {
} }
} }
.onMove(perform: { indices, destination in
var workoutLogArray = Array(sortedWorkoutLogs)
workoutLogArray.move(fromOffsets: indices, toOffset: destination)
for (index, log) in workoutLogArray.enumerated() {
log.order = index
}
if let modelContext = workoutLogArray.first?.modelContext {
do {
try modelContext.save()
} catch {
print("Error saving after reordering: \(error)")
}
}
})
} }
} }
} }
@ -171,14 +186,14 @@ struct WorkoutLogListView: View {
if let _ = workout.logs?.first(where: { $0.status != .completed }) { if let _ = workout.logs?.first(where: { $0.status != .completed }) {
if let notStartedLogs = workout.logs?.filter({ $0.status == .notStarted }) { if let notStartedLogs = workout.logs?.filter({ $0.status == .notStarted }) {
if notStartedLogs.count == workout.logs?.count ?? 0 { if notStartedLogs.count == workout.logs?.count ?? 0 {
workout.status = .notStarted workout.status = WorkoutStatus.notStarted.rawValue
} }
} }
if let _ = workout.logs?.first(where: { $0.status == .inProgress }) { if let _ = workout.logs?.first(where: { $0.status == .inProgress }) {
workout.status = .inProgress workout.status = WorkoutStatus.inProgress.rawValue
} }
} else { } else {
workout.status = .completed workout.status = WorkoutStatus.completed.rawValue
workout.end = Date() workout.end = Date()
} }
try? modelContext.save() try? modelContext.save()

View File

@ -7,6 +7,8 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved. // Copyright 2025 Rouslan Zenetl. All Rights Reserved.
// //
import Foundation
enum WorkoutStatus: Int, Codable { enum WorkoutStatus: Int, Codable {
case notStarted = 1 case notStarted = 1
case inProgress = 2 case inProgress = 2

View File

@ -25,12 +25,12 @@ struct WorkoutEditView: View {
} }
Section (header: Text("Status")) { Section (header: Text("Status")) {
Text("\(workout.status?.name ?? WorkoutStatus.unnamed)") Text("\(workout.statusName)")
} }
Section (header: Text("Start/End")) { Section (header: Text("Start/End")) {
DatePicker("Started", selection: $workout.start) DatePicker("Started", selection: $workout.start)
if workout.status == .completed { if workout.status == WorkoutStatus.completed.rawValue {
DatePicker("Ended", selection: $workoutEndDate) DatePicker("Ended", selection: $workoutEndDate)
} }
} }
@ -46,7 +46,7 @@ struct WorkoutEditView: View {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { Button("Save") {
try? modelContext.save() try? modelContext.save()
if workout.status == .completed { if workout.status == WorkoutStatus.completed.rawValue {
workout.end = workoutEndDate workout.end = workoutEndDate
} }
dismiss() dismiss()

View File

@ -37,8 +37,8 @@ struct WorkoutListView: View {
CalendarListItem( CalendarListItem(
date: workout.start, date: workout.start,
title: workout.split?.name ?? Split.unnamed, title: workout.split?.name ?? Split.unnamed,
subtitle: "\(workout.status == .completed ? workout.start.humanTimeInterval(to: (workout.end ?? Date())) : "\(workout.start.formattedDate()) - \(workout.status?.name ?? WorkoutStatus.unnamed)" )", subtitle: "\(workout.status == WorkoutStatus.completed.rawValue ? workout.start.humanTimeInterval(to: (workout.end ?? Date())) : "\(workout.start.formattedDate()) - \(workout.statusName)" )",
subtitle2: "\(workout.status?.name ?? WorkoutStatus.unnamed)" subtitle2: "\(workout.statusName)"
) )
} }
.swipeActions(edge: .trailing, allowsFullSwipe: false) { .swipeActions(edge: .trailing, allowsFullSwipe: false) {

View File

@ -1,219 +0,0 @@
//
// ActiveWorkoutListView.swift
// Workouts
//
// Created by rzen on 7/20/25 at 6:35 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct ActiveWorkoutListView: View {
@Environment(\.modelContext) private var modelContext
let workouts: [Workout]
var body: some View {
List {
ForEach(workouts) { workout in
NavigationLink {
WorkoutDetailView(workout: workout)
} label: {
WorkoutCardView(workout: workout)
}
// .listRowSeparator(.hidden)
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.padding(
EdgeInsets(
top: 4,
leading: 8,
bottom: 4,
trailing: 8
)
)
)
}
}
.listStyle(.carousel)
// .navigationTitle("Workouts")
}
}
struct WorkoutCardView: View {
let workout: Workout
var body: some View {
VStack(alignment: .leading) {
// Split icon
if let split = workout.split {
Image(systemName: split.systemImage)
.font(.system(size: 48))
.foregroundStyle(split.getColor())
} else {
Image(systemName: "dumbbell.fill")
.font(.system(size: 24))
.foregroundStyle(.gray)
}
// VStack(alignment: .leading, spacing: 4) {
// Split name
Text(workout.split?.name ?? "Workout")
.font(.headline)
.foregroundStyle(.white)
// Workout status
Text(workout.status?.name ?? "Not Started")
.font(.caption)
.foregroundStyle(Color.accentColor)
// }
// Spacer()
}
// .padding(.vertical, 8)
}
}
struct WorkoutDetailView: View {
let workout: Workout
var body: some View {
VStack(alignment: .center, spacing: 8) {
if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty {
List {
ForEach(logs) { log in
NavigationLink {
ExerciseProgressControlView(log: log)
} label: {
WorkoutLogCardView(log: log)
}
.listRowBackground(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.padding(
EdgeInsets(
top: 4,
leading: 8,
bottom: 4,
trailing: 8
)
)
)
}
}
.listStyle(.carousel)
} else {
Text("No exercises in this workout")
.font(.body)
.foregroundStyle(.secondary)
.padding()
Spacer()
}
}
}
}
struct WorkoutLogCardView: View {
let log: WorkoutLog
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Exercise name
Text(log.exerciseName)
.font(.headline)
.lineLimit(1)
// Status
Text(log.status?.name ?? "Not Started")
.font(.caption)
.foregroundStyle(Color.accentColor)
// Sets, Reps, Weight
HStack(spacing: 12) {
Text("\(log.weight) lbs")
Spacer()
Text("\(log.sets) × \(log.reps)")
}
}
}
}
struct WorkoutLogDetailView: View {
let log: WorkoutLog
var body: some View {
NavigationLink {
ExerciseProgressControlView(log: log)
} label: {
VStack(alignment: .center) {
Text(log.exerciseName)
.font(.title)
.lineLimit(1) // Ensures it stays on one line
.minimumScaleFactor(0.5) // Scales down to 50% if needed
.layoutPriority(1) // Prioritize this view in tight layouts
HStack (alignment: .bottom) {
Text("\(log.weight)")
Text( "lbs")
.fontWeight(.light)
.padding([.trailing], 10)
Text("\(log.sets)")
Text("×")
.fontWeight(.light)
Text("\(log.reps)")
// Text("\(log.weight) lbs × \(log.sets) × \(log.reps)")
}
.font(.title3)
.lineLimit(1) // Ensures it stays on one line
.minimumScaleFactor(0.5) // Scales down to 50% if needed
.layoutPriority(1) // Prioritize this view in tight layouts
Text(log.status?.name ?? "Not Started")
.foregroundStyle(Color.accentColor)
Text("Tap to start")
.foregroundStyle(Color.accentColor)
}
.padding()
}
.buttonStyle(.plain)
}
private func statusColor(for status: WorkoutStatus?) -> Color {
guard let status = status else { return .secondary }
switch status {
case .notStarted:
return .secondary
case .inProgress:
return .blue
case .completed:
return .green
case .skipped:
return .red
}
}
}
//#Preview {
// let container = AppContainer.preview
// let split = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional")
// let workout1 = Workout(start: Date(), end: nil, split: split)
// workout1.status = .inProgress
//
// let split2 = Split(name: "Lower Body", color: "red", systemImage: "figure.run")
// let workout2 = Workout(start: Date().addingTimeInterval(-3600), end: nil, split: split2)
// workout2.status = .notStarted
//
// return ActiveWorkoutListView(workouts: [workout1, workout2])
// .modelContainer(container)
//}

View File

@ -1,334 +0,0 @@
//
// ExerciseProgressControlView.swift
// Workouts
//
// Created by rzen on 7/20/25 at 7:19 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
enum ExerciseState: Identifiable {
case detail
case set(number: Int)
case rest(afterSet: Int)
case done
var id: String {
switch self {
case .detail:
return "detail"
case .set(let number):
return "set_\(number)"
case .rest(let afterSet):
return "rest_\(afterSet)"
case .done:
return "done"
}
}
var isRest: Bool {
if case .rest = self {
return true
}
return false
}
var isSet: Bool {
if case .set = self {
return true
}
return false
}
var isDone: Bool {
if case .done = self {
return true
}
return false
}
var isDetail: Bool {
if case .detail = self {
return true
}
return false
}
}
struct ExerciseProgressControlView: View {
let log: WorkoutLog
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var exerciseStates: [ExerciseState] = []
@State private var currentStateIndex: Int = 0
@State private var elapsedSeconds: Int = 0
@State private var timer: Timer? = nil
@State private var previousStateIndex: Int = 0
var body: some View {
TabView(selection: $currentStateIndex) {
ForEach(Array(exerciseStates.enumerated()), id: \.element.id) { index, state in
if state.isDetail {
ExerciseDetailView(log: log, onStart: { moveToNextState() })
.tag(index)
} else {
ExerciseStateView(
title: state.isRest ? "Resting..." : state.isDone ? "Done" : "Set \(currentStateIndex)",
isRest: state.isRest,
isDone: state.isDone,
elapsedSeconds: elapsedSeconds,
onComplete: {
//
})
// ExerciseStateView(
//// state: state,
//
// elapsedSeconds: elapsedSeconds,
// onComplete: {
// moveToNextState()
// }
// )
.tag(index)
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: currentStateIndex) { oldValue, newValue in
if oldValue != newValue {
// Reset timer when user swipes to a new state
elapsedSeconds = 0
}
}
.onAppear {
setupExerciseStates()
startTimer()
}
.onDisappear {
stopTimer()
}
}
private func setupExerciseStates() {
var states: [ExerciseState] = []
// Add the detail view as the first state
states.append(.detail)
// Create alternating set and rest states based on the log's set count
for i in 1...log.sets {
states.append(.set(number: i))
// Add rest after each set except the last one
if i < log.sets {
states.append(.rest(afterSet: i))
}
}
// Add the final DONE state
states.append(.done)
exerciseStates = states
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
elapsedSeconds += 1
// Check if we need to provide haptic feedback during rest periods
if let currentState = exerciseStates[safe: currentStateIndex] {
if currentState.isRest {
provideRestHapticFeedback()
} else if currentState.isDone && elapsedSeconds >= 10 {
// Auto-complete after 10 seconds on the DONE state
completeExercise()
}
}
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func moveToNextState() {
if currentStateIndex < exerciseStates.count - 1 {
withAnimation {
currentStateIndex += 1
elapsedSeconds = 0
}
} else {
// We've reached the end (DONE state)
completeExercise()
}
}
private func provideRestHapticFeedback() {
// Provide haptic feedback based on elapsed time
if elapsedSeconds % 60 == 0 && elapsedSeconds > 0 {
// Triple tap every 60 seconds
HapticFeedback.tripleTap()
} else if elapsedSeconds % 30 == 0 && elapsedSeconds > 0 {
// Double tap every 30 seconds
HapticFeedback.doubleTap()
} else if elapsedSeconds % 10 == 0 && elapsedSeconds > 0 {
// Single tap every 10 seconds
HapticFeedback.success()
}
}
private func completeExercise() {
// Update the workout log status to completed
log.status = .completed
// Provide "tada" haptic feedback
HapticFeedback.tripleTap()
// Dismiss this view to return to WorkoutDetailView
dismiss()
}
}
struct ExerciseStateView: View {
// let state: ExerciseState
let title: String
let isRest: Bool
let isDone: Bool
let elapsedSeconds: Int
let onComplete: () -> Void
var body: some View {
VStack(spacing: 20) {
// Title based on state
Text(title)
.font(.title)
// Timer display
Text(timeFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(isRest ? .orange : .accentColor)
// Only show Done button and countdown for the final state
if isDone {
// Done button
Button(action: onComplete) {
Text("Done in \(10 - elapsedSeconds)s")
.font(.headline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
.padding(.horizontal)
}
}
.padding()
}
// private var stateTitle: String {
// switch state {
// case .set(let number):
// return "Set \(number)"
// case .rest:
// return "Resting..."
// case .done:
// return "Exercise Complete"
// case .detail:
// return "Swipe to Start"
// }
// }
// private var buttonTitle: String {
// switch state {
// case .set:
// return "Complete Set"
// case .rest:
// return "Start Next Set"
// case .done:
// return "DONE"
// }
// }
// private var buttonColor: Color {
// switch state {
// case .set:
// return .accentColor
// case .rest:
// return .orange
// case .done:
// return .green
// }
// }
private var timeFormatted: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}
// Detail view shown as the first item in the exercise progress carousel
struct ExerciseDetailView: View {
let log: WorkoutLog
let onStart: () -> Void
var body: some View {
VStack(alignment: .center, spacing: 16) {
Text(log.exerciseName)
.font(.title)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
HStack(alignment: .bottom) {
Text("\(log.weight)")
Text("lbs")
.fontWeight(.light)
.padding([.trailing], 10)
Text("\(log.sets)")
Text("×")
.fontWeight(.light)
Text("\(log.reps)")
}
.font(.title3)
.lineLimit(1)
.minimumScaleFactor(0.5)
.layoutPriority(1)
Text(log.status?.name ?? "Not Started")
.foregroundStyle(Color.accentColor)
// Spacer()
//
// Button(action: onStart) {
// Text("Start Exercise")
// .font(.headline)
// .frame(maxWidth: .infinity)
// }
// .buttonStyle(.borderedProminent)
// .tint(.accentColor)
}
.padding()
}
}
// Helper extension to safely access array elements
extension Array {
subscript(safe index: Index) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}
//#Preview {
// let container = AppContainer.preview
// let workout = Workout(start: Date(), end: nil, split: nil)
// let log = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135)
//
// ExerciseProgressControlView(log: log)
// .modelContainer(container)
//}