diff --git a/Artwork/App Icon Template.psd b/Artwork/App Icon Template.psd new file mode 100644 index 0000000..4d4b265 Binary files /dev/null and b/Artwork/App Icon Template.psd differ diff --git a/Artwork/DumbBellIcon-light.png b/Artwork/DumbBellIcon-light.png new file mode 100644 index 0000000..7e8a0d9 Binary files /dev/null and b/Artwork/DumbBellIcon-light.png differ diff --git a/Artwork/DumbBellIcon.png b/Artwork/DumbBellIcon.png new file mode 100644 index 0000000..0e0c190 Binary files /dev/null and b/Artwork/DumbBellIcon.png differ diff --git a/Artwork/DumbBellIcon.pxd b/Artwork/DumbBellIcon.pxd new file mode 100644 index 0000000..be0b937 Binary files /dev/null and b/Artwork/DumbBellIcon.pxd differ diff --git a/Worksouts Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/Workouts Watch App/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Worksouts Watch App/Assets.xcassets/AccentColor.colorset/Contents.json rename to Workouts Watch App/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Worksouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 80% rename from Worksouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json index 49c81cd..65a5581 100644 --- a/Worksouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "DumbBellIcon-light.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" diff --git a/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/DumbBellIcon-light.png b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/DumbBellIcon-light.png new file mode 100644 index 0000000..7e8a0d9 Binary files /dev/null and b/Workouts Watch App/Assets.xcassets/AppIcon.appiconset/DumbBellIcon-light.png differ diff --git a/Worksouts Watch App/Assets.xcassets/Contents.json b/Workouts Watch App/Assets.xcassets/Contents.json similarity index 100% rename from Worksouts Watch App/Assets.xcassets/Contents.json rename to Workouts Watch App/Assets.xcassets/Contents.json diff --git a/Worksouts Watch App/ContentView.swift b/Workouts Watch App/ContentView.swift similarity index 58% rename from Worksouts Watch App/ContentView.swift rename to Workouts Watch App/ContentView.swift index 028f740..aaada46 100644 --- a/Worksouts Watch App/ContentView.swift +++ b/Workouts Watch App/ContentView.swift @@ -13,15 +13,7 @@ import SwiftData struct ContentView: View { @Environment(\.modelContext) private var modelContext - // Use string literal for completed status to avoid enum reference in predicate -// @Query(filter: #Predicate { 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 splits: [Split] = [] var body: some View { NavigationStack { @@ -32,37 +24,25 @@ struct ContentView: View { } } .onAppear { - loadSplits() loadActiveWorkouts() } } func loadActiveWorkouts () { + let completedStatus = WorkoutStatus.completed.rawValue do { - print("loading active workouts") self.activeWorkouts = try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { workout in + workout.status != completedStatus + }, sortBy: [ - SortDescriptor(\Workout.start) + SortDescriptor(\Workout.start, order: .reverse) ] )) - print("loaded active workouts \(activeWorkouts.count)") } catch { print("ERROR: failed to load active workouts \(error)") } } - - func loadSplits () { - do { - self.splits = try modelContext.fetch(FetchDescriptor( - sortBy: [ - SortDescriptor(\Split.order), - SortDescriptor(\Split.name) - ] - )) - } catch { - print("ERROR: failed to load splits \(error)") - } - } } struct NoActiveWorkoutView: View { @@ -84,7 +64,7 @@ struct NoActiveWorkoutView: View { } } -//#Preview { -// ContentView() -// .modelContainer(AppContainer.preview) -//} +#Preview { + ContentView() + .modelContainer(AppContainer.preview) +} diff --git a/Worksouts Watch App/Preview Content/Preview Assets.xcassets/Contents.json b/Workouts Watch App/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from Worksouts Watch App/Preview Content/Preview Assets.xcassets/Contents.json rename to Workouts Watch App/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/Worksouts Watch App/Schema/AppContainer.swift b/Workouts Watch App/Schema/AppContainer.swift similarity index 98% rename from Worksouts Watch App/Schema/AppContainer.swift rename to Workouts Watch App/Schema/AppContainer.swift index 1644978..dc290e8 100644 --- a/Worksouts Watch App/Schema/AppContainer.swift +++ b/Workouts Watch App/Schema/AppContainer.swift @@ -135,20 +135,23 @@ final class AppContainer { // Upper Body Workout (in progress) let upperBodyWorkout = Workout(start: now, end: now, split: upperBodySplit) - upperBodyWorkout.status = .inProgress + upperBodyWorkout.status = 2 +// upperBodyWorkout.status = .inProgress upperBodyWorkout.end = nil context.insert(upperBodyWorkout) // Lower Body Workout (scheduled for tomorrow) let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) ?? now let lowerBodyWorkout = Workout(start: tomorrow, end: tomorrow, split: lowerBodySplit) - lowerBodyWorkout.status = .notStarted + lowerBodyWorkout.status = 1 +// lowerBodyWorkout.status = .notStarted context.insert(lowerBodyWorkout) // Full Body Workout (completed yesterday) let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now let fullBodyWorkout = Workout(start: yesterday, end: yesterday, split: fullBodySplit) - fullBodyWorkout.status = .completed + fullBodyWorkout.status = 3 +// fullBodyWorkout.status = .completed context.insert(fullBodyWorkout) // Create workout logs for Upper Body workout (in progress) diff --git a/Worksouts Watch App/Schema/SchemaVersion.swift b/Workouts Watch App/Schema/SchemaVersion.swift similarity index 100% rename from Worksouts Watch App/Schema/SchemaVersion.swift rename to Workouts Watch App/Schema/SchemaVersion.swift diff --git a/Worksouts Watch App/Utils/AppLogger.swift b/Workouts Watch App/Utils/AppLogger.swift similarity index 100% rename from Worksouts Watch App/Utils/AppLogger.swift rename to Workouts Watch App/Utils/AppLogger.swift diff --git a/Worksouts Watch App/Utils/Color+color.swift b/Workouts Watch App/Utils/Color+color.swift similarity index 100% rename from Worksouts Watch App/Utils/Color+color.swift rename to Workouts Watch App/Utils/Color+color.swift diff --git a/Worksouts Watch App/Utils/Date+formatDate.swift b/Workouts Watch App/Utils/Date+formatDate.swift similarity index 100% rename from Worksouts Watch App/Utils/Date+formatDate.swift rename to Workouts Watch App/Utils/Date+formatDate.swift diff --git a/Worksouts Watch App/Utils/Date+formatDateET.swift b/Workouts Watch App/Utils/Date+formatDateET.swift similarity index 100% rename from Worksouts Watch App/Utils/Date+formatDateET.swift rename to Workouts Watch App/Utils/Date+formatDateET.swift diff --git a/Worksouts Watch App/Utils/Date+formatedDate.swift b/Workouts Watch App/Utils/Date+formatedDate.swift similarity index 100% rename from Worksouts Watch App/Utils/Date+formatedDate.swift rename to Workouts Watch App/Utils/Date+formatedDate.swift diff --git a/Worksouts Watch App/Utils/HapticFeedback.swift b/Workouts Watch App/Utils/HapticFeedback.swift similarity index 100% rename from Worksouts Watch App/Utils/HapticFeedback.swift rename to Workouts Watch App/Utils/HapticFeedback.swift diff --git a/Workouts Watch App/Utils/TimeInterval+formatted.swift b/Workouts Watch App/Utils/TimeInterval+formatted.swift new file mode 100644 index 0000000..c3353c6 --- /dev/null +++ b/Workouts Watch App/Utils/TimeInterval+formatted.swift @@ -0,0 +1,18 @@ +// +// TimeInterval+minutesSecons.swift +// Workouts +// +// Created by rzen on 7/23/25 at 4:22 PM. +// +// 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) + } +} diff --git a/Workouts Watch App/Views/Exercises/Cards/ExerciseDoneCard.swift b/Workouts Watch App/Views/Exercises/Cards/ExerciseDoneCard.swift new file mode 100644 index 0000000..e8aae94 --- /dev/null +++ b/Workouts Watch App/Views/Exercises/Cards/ExerciseDoneCard.swift @@ -0,0 +1,36 @@ +// +// ExerciseDoneCard.swift +// Workouts +// +// Created by rzen on 7/23/25 at 4:29 PM. +// +// 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) + } +} + diff --git a/Workouts Watch App/Views/Exercises/Cards/ExerciseIntroCard.swift b/Workouts Watch App/Views/Exercises/Cards/ExerciseIntroCard.swift new file mode 100644 index 0000000..15e7734 --- /dev/null +++ b/Workouts Watch App/Views/Exercises/Cards/ExerciseIntroCard.swift @@ -0,0 +1,55 @@ +// +// ExerciseIntroView.swift +// Workouts +// +// Created by rzen on 7/23/25 at 4:19 PM. +// +// 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() + } +} + diff --git a/Workouts Watch App/Views/Exercises/Cards/ExerciseRestCard.swift b/Workouts Watch App/Views/Exercises/Cards/ExerciseRestCard.swift new file mode 100644 index 0000000..7849cd4 --- /dev/null +++ b/Workouts Watch App/Views/Exercises/Cards/ExerciseRestCard.swift @@ -0,0 +1,30 @@ +// +// ExerciseRestCard.swift +// Workouts +// +// Created by rzen on 7/23/25 at 4:28 PM. +// +// 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() + } +} + diff --git a/Workouts Watch App/Views/Exercises/Cards/ExerciseSetCard.swift b/Workouts Watch App/Views/Exercises/Cards/ExerciseSetCard.swift new file mode 100644 index 0000000..18fb792 --- /dev/null +++ b/Workouts Watch App/Views/Exercises/Cards/ExerciseSetCard.swift @@ -0,0 +1,28 @@ +// +// ExerciseSetCard.swift +// Workouts +// +// Created by rzen on 7/23/25 at 4:26 PM. +// +// 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() + } +} + diff --git a/Workouts Watch App/Views/Exercises/ExerciseProgressControlView.swift b/Workouts Watch App/Views/Exercises/ExerciseProgressControlView.swift new file mode 100644 index 0000000..6d29e52 --- /dev/null +++ b/Workouts Watch App/Views/Exercises/ExerciseProgressControlView.swift @@ -0,0 +1,140 @@ +// +// ExerciseProgressControlView 2.swift +// Workouts +// +// Created by rzen on 7/23/25 at 9:15 AM. +// +// 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() + } +} diff --git a/Worksouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/Exercises/ExerciseProgressView.swift similarity index 100% rename from Worksouts Watch App/Views/ExerciseProgressView.swift rename to Workouts Watch App/Views/Exercises/ExerciseProgressView.swift diff --git a/Workouts Watch App/Views/Exercises/ExerciseState.swift b/Workouts Watch App/Views/Exercises/ExerciseState.swift new file mode 100644 index 0000000..6e61398 --- /dev/null +++ b/Workouts Watch App/Views/Exercises/ExerciseState.swift @@ -0,0 +1,72 @@ +// +// ExerciseState.swift +// Workouts +// +// Created by rzen on 7/23/25 at 9:14 AM. +// +// 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 + } +} diff --git a/Workouts Watch App/Views/Exercises/ExerciseStateView.swift b/Workouts Watch App/Views/Exercises/ExerciseStateView.swift new file mode 100644 index 0000000..8fb93e5 --- /dev/null +++ b/Workouts Watch App/Views/Exercises/ExerciseStateView.swift @@ -0,0 +1,47 @@ +// +// ExerciseStateView 2.swift +// Workouts +// +// Created by rzen on 7/23/25 at 9:15 AM. +// +// 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) + } +} diff --git a/Workouts Watch App/Views/Workouts/ActiveWorkoutListView.swift b/Workouts Watch App/Views/Workouts/ActiveWorkoutListView.swift new file mode 100644 index 0000000..16fcfd9 --- /dev/null +++ b/Workouts Watch App/Views/Workouts/ActiveWorkoutListView.swift @@ -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) +} diff --git a/Workouts Watch App/Views/Workouts/WorkoutCardView.swift b/Workouts Watch App/Views/Workouts/WorkoutCardView.swift new file mode 100644 index 0000000..616152d --- /dev/null +++ b/Workouts Watch App/Views/Workouts/WorkoutCardView.swift @@ -0,0 +1,36 @@ +// +// WorkoutCardView.swift +// Workouts +// +// Created by rzen on 7/22/25 at 9:54 PM. +// +// 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) + } + } +} diff --git a/Workouts Watch App/Views/Workouts/WorkoutDetailView.swift b/Workouts Watch App/Views/Workouts/WorkoutDetailView.swift new file mode 100644 index 0000000..d18bf7c --- /dev/null +++ b/Workouts Watch App/Views/Workouts/WorkoutDetailView.swift @@ -0,0 +1,50 @@ +// +// WorkoutDetailView.swift +// Workouts +// +// Created by rzen on 7/22/25 at 9:54 PM. +// +// 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() + } + } + } +} diff --git a/Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift b/Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift new file mode 100644 index 0000000..03b7128 --- /dev/null +++ b/Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift @@ -0,0 +1,34 @@ +// +// WorkoutLogCardView.swift +// Workouts +// +// Created by rzen on 7/22/25 at 9:56 PM. +// +// 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)") + } + } + } +} diff --git a/Workouts Watch App/Views/Workouts/WorkoutLogDetailView.swift b/Workouts Watch App/Views/Workouts/WorkoutLogDetailView.swift new file mode 100644 index 0000000..7279b03 --- /dev/null +++ b/Workouts Watch App/Views/Workouts/WorkoutLogDetailView.swift @@ -0,0 +1,69 @@ +// +// WorkoutLogDetailView.swift +// Workouts +// +// Created by rzen on 7/22/25 at 9:57 PM. +// +// 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 + } + } +} diff --git a/Worksouts Watch App/Views/WorkoutLogListView.swift b/Workouts Watch App/Views/Workouts/WorkoutLogListView.swift similarity index 100% rename from Worksouts Watch App/Views/WorkoutLogListView.swift rename to Workouts Watch App/Views/Workouts/WorkoutLogListView.swift diff --git a/Worksouts Watch App/Worksouts Watch App.entitlements b/Workouts Watch App/Workouts Watch App.entitlements similarity index 100% rename from Worksouts Watch App/Worksouts Watch App.entitlements rename to Workouts Watch App/Workouts Watch App.entitlements diff --git a/Worksouts Watch App/WorksoutsApp.swift b/Workouts Watch App/WorkoutsApp.swift similarity index 90% rename from Worksouts Watch App/WorksoutsApp.swift rename to Workouts Watch App/WorkoutsApp.swift index 1db47fe..3df6914 100644 --- a/Worksouts Watch App/WorksoutsApp.swift +++ b/Workouts Watch App/WorkoutsApp.swift @@ -11,7 +11,7 @@ import SwiftUI import SwiftData @main -struct Worksouts_Watch_AppApp: App { +struct Workouts_Watch_AppApp: App { let container = AppContainer.create() var body: some Scene { diff --git a/Workouts Watch App/__ATTIC__/ExerciseDetailView.swift b/Workouts Watch App/__ATTIC__/ExerciseDetailView.swift new file mode 100644 index 0000000..562a69c --- /dev/null +++ b/Workouts Watch App/__ATTIC__/ExerciseDetailView.swift @@ -0,0 +1,45 @@ +//// +//// ExerciseDetailView.swift +//// Workouts +//// +//// Created by rzen on 7/23/25 at 9:17 AM. +//// +//// 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() +// } +//} diff --git a/Workouts Watch App/__ATTIC__/xExerciseProgressControlView.swift b/Workouts Watch App/__ATTIC__/xExerciseProgressControlView.swift new file mode 100644 index 0000000..6805a9a --- /dev/null +++ b/Workouts Watch App/__ATTIC__/xExerciseProgressControlView.swift @@ -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) +////} diff --git a/Workouts.xcodeproj/project.pbxproj b/Workouts.xcodeproj/project.pbxproj index e248424..f93d5c5 100644 --- a/Workouts.xcodeproj/project.pbxproj +++ b/Workouts.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* 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 */; }; A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; }; A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; }; @@ -20,7 +20,7 @@ containerPortal = A45FA0892E21B3DC00581607 /* Project object */; proxyType = 1; remoteGlobalIDString = A45FA1F02E27171A00581607; - remoteInfo = "Worksouts Watch App"; + remoteInfo = "Workouts Watch App"; }; /* End PBXContainerItemProxy section */ @@ -31,7 +31,7 @@ dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstSubfolderSpec = 16; files = ( - A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */, + A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */, ); name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; @@ -40,7 +40,7 @@ /* Begin PBXFileReference section */ 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 */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -53,7 +53,7 @@ ); 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; membershipExceptions = ( _ATTIC_/ContentView_backup.swift, @@ -66,7 +66,7 @@ Views/Common/CheckboxStatus.swift, Views/WorkoutLog/WorkoutStatus.swift, ); - target = A45FA1F02E27171A00581607 /* Worksouts Watch App */; + target = A45FA1F02E27171A00581607 /* Workouts Watch App */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -75,14 +75,14 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 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; sourceTree = ""; }; - A45FA1F22E27171A00581607 /* Worksouts Watch App */ = { + A45FA1F22E27171A00581607 /* Workouts Watch App */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = "Worksouts Watch App"; + path = "Workouts Watch App"; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -112,9 +112,8 @@ A45FA0882E21B3DC00581607 = { isa = PBXGroup; children = ( - A45FA2C02E2D3C0900581607 /* Shared Models */, A45FA0932E21B3DD00581607 /* Workouts */, - A45FA1F22E27171A00581607 /* Worksouts Watch App */, + A45FA1F22E27171A00581607 /* Workouts Watch App */, A45FA0922E21B3DD00581607 /* Products */, ); sourceTree = ""; @@ -123,18 +122,11 @@ isa = PBXGroup; children = ( A45FA0912E21B3DD00581607 /* Workouts.app */, - A45FA1F12E27171A00581607 /* Worksouts Watch App.app */, + A45FA1F12E27171A00581607 /* Workouts Watch App.app */, ); name = Products; sourceTree = ""; }; - A45FA2C02E2D3C0900581607 /* Shared Models */ = { - isa = PBXGroup; - children = ( - ); - path = "Shared Models"; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -166,9 +158,9 @@ productReference = A45FA0912E21B3DD00581607 /* Workouts.app */; productType = "com.apple.product-type.application"; }; - A45FA1F02E27171A00581607 /* Worksouts Watch App */ = { + A45FA1F02E27171A00581607 /* Workouts Watch App */ = { isa = PBXNativeTarget; - buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Worksouts Watch App" */; + buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */; buildPhases = ( A45FA1ED2E27171A00581607 /* Sources */, A45FA1EE2E27171A00581607 /* Frameworks */, @@ -179,13 +171,13 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - A45FA1F22E27171A00581607 /* Worksouts Watch App */, + A45FA1F22E27171A00581607 /* Workouts Watch App */, ); - name = "Worksouts Watch App"; + name = "Workouts Watch App"; packageProductDependencies = ( ); - productName = "Worksouts Watch App"; - productReference = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */; + productName = "Workouts Watch App"; + productReference = A45FA1F12E27171A00581607 /* Workouts Watch App.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -225,7 +217,7 @@ projectRoot = ""; targets = ( A45FA0902E21B3DD00581607 /* Workouts */, - A45FA1F02E27171A00581607 /* Worksouts Watch App */, + A45FA1F02E27171A00581607 /* Workouts Watch App */, ); }; /* End PBXProject section */ @@ -267,7 +259,7 @@ /* Begin PBXTargetDependency section */ A45FA1FD2E27171B00581607 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = A45FA1F02E27171A00581607 /* Worksouts Watch App */; + target = A45FA1F02E27171A00581607 /* Workouts Watch App */; targetProxy = A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -461,14 +453,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 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; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"Worksouts Watch App/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\""; DEVELOPMENT_TEAM = C32Z8JNLG6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Worksouts; + INFOPLIST_KEY_CFBundleDisplayName = Workouts; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts; LD_RUNPATH_SEARCH_PATHS = ( @@ -492,14 +484,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 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; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"Worksouts Watch App/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\""; DEVELOPMENT_TEAM = C32Z8JNLG6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Worksouts; + INFOPLIST_KEY_CFBundleDisplayName = Workouts; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts; LD_RUNPATH_SEARCH_PATHS = ( @@ -539,7 +531,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Worksouts Watch App" */ = { + A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */ = { isa = XCConfigurationList; buildConfigurations = ( A45FA2002E27171B00581607 /* Debug */, diff --git a/Workouts.xcodeproj/xcuserdata/rzen.xcuserdatad/xcschemes/xcschememanagement.plist b/Workouts.xcodeproj/xcuserdata/rzen.xcuserdatad/xcschemes/xcschememanagement.plist index b931437..6f6e55e 100644 --- a/Workouts.xcodeproj/xcuserdata/rzen.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Workouts.xcodeproj/xcuserdata/rzen.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,11 +4,16 @@ SchemeUserState - Workouts.xcscheme_^#shared#^_ + Workouts Watch App.xcscheme_^#shared#^_ orderHint 0 + Workouts.xcscheme_^#shared#^_ + + orderHint + 1 + Worksouts Watch App.xcscheme_^#shared#^_ orderHint diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json b/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..69e2dac 100644 --- a/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Workouts/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "DumbBellIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "DumbBellIcon 1.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "DumbBellIcon 2.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png new file mode 100644 index 0000000..0e0c190 Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png new file mode 100644 index 0000000..0e0c190 Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png new file mode 100644 index 0000000..0e0c190 Binary files /dev/null and b/Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png differ diff --git a/Workouts/Models/Workout.swift b/Workouts/Models/Workout.swift index a2b72f6..e1f982f 100644 --- a/Workouts/Models/Workout.swift +++ b/Workouts/Models/Workout.swift @@ -5,8 +5,15 @@ import SwiftData final class Workout { var start: Date = 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) var split: Split? @@ -20,10 +27,25 @@ final class Workout { } 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())" } else { 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" + } + } } diff --git a/Workouts/Models/WorkoutLog.swift b/Workouts/Models/WorkoutLog.swift index 120effd..5c6fd98 100644 --- a/Workouts/Models/WorkoutLog.swift +++ b/Workouts/Models/WorkoutLog.swift @@ -11,6 +11,9 @@ final class WorkoutLog { var order: Int = 0 var exerciseName: String = "" + var currentStateIndex: Int? = nil + var elapsedSeconds: Int? = nil + var completed: Bool = false @Relationship(deleteRule: .nullify) diff --git a/Workouts/Schema/WorkoutsMigrationPlan.swift b/Workouts/Schema/WorkoutsMigrationPlan.swift index 82e4d8e..c9fb2e7 100644 --- a/Workouts/Schema/WorkoutsMigrationPlan.swift +++ b/Workouts/Schema/WorkoutsMigrationPlan.swift @@ -10,21 +10,6 @@ struct WorkoutsMigrationPlan: SchemaMigrationPlan { toVersion: SchemaV1.self, willMigrate: { context in print("migrating from v1 to v1") - let workouts = try? context.fetch(FetchDescriptor()) - 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 // No additional actions needed after migration diff --git a/Workouts/Views/Splits/SplitAddEditView.swift b/Workouts/Views/Splits/SplitAddEditView.swift index d2d57b1..6a69b3f 100644 --- a/Workouts/Views/Splits/SplitAddEditView.swift +++ b/Workouts/Views/Splits/SplitAddEditView.swift @@ -21,7 +21,7 @@ struct SplitAddEditView: View { var body: some View { NavigationStack { - Form { + Form { Section(header: Text("Name")) { TextField("Name", text: $model.name) .bold() diff --git a/Workouts/Views/Splits/SplitDetailView.swift b/Workouts/Views/Splits/SplitDetailView.swift new file mode 100644 index 0000000..8bdd2fb --- /dev/null +++ b/Workouts/Views/Splits/SplitDetailView.swift @@ -0,0 +1,198 @@ +// +// SplitDetailView.swift +// Workouts +// +// Created by rzen on 7/25/25 at 3:27 PM. +// +// 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(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() + } + } + + } +} diff --git a/Workouts/Views/Splits/SplitsView.swift b/Workouts/Views/Splits/SplitsView.swift index 2ee1e99..6e33c65 100644 --- a/Workouts/Views/Splits/SplitsView.swift +++ b/Workouts/Views/Splits/SplitsView.swift @@ -25,7 +25,7 @@ struct SplitsView: View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in NavigationLink { - ExerciseListView(split: split) + SplitDetailView(split: split) } label: { SplitItem( name: split.name, diff --git a/Workouts/Views/WorkoutLog/WorkoutLogListView.swift b/Workouts/Views/WorkoutLog/WorkoutLogListView.swift index 6314c84..bac8df8 100644 --- a/Workouts/Views/WorkoutLog/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLog/WorkoutLogListView.swift @@ -40,7 +40,7 @@ struct WorkoutLogListView: View { CheckboxListItem( status: workoutLogStatus, 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) { 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 notStartedLogs = workout.logs?.filter({ $0.status == .notStarted }) { if notStartedLogs.count == workout.logs?.count ?? 0 { - workout.status = .notStarted + workout.status = WorkoutStatus.notStarted.rawValue } } if let _ = workout.logs?.first(where: { $0.status == .inProgress }) { - workout.status = .inProgress + workout.status = WorkoutStatus.inProgress.rawValue } } else { - workout.status = .completed + workout.status = WorkoutStatus.completed.rawValue workout.end = Date() } try? modelContext.save() diff --git a/Workouts/Views/WorkoutLog/WorkoutStatus.swift b/Workouts/Views/WorkoutLog/WorkoutStatus.swift index cace670..2c534b2 100644 --- a/Workouts/Views/WorkoutLog/WorkoutStatus.swift +++ b/Workouts/Views/WorkoutLog/WorkoutStatus.swift @@ -7,6 +7,8 @@ // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // +import Foundation + enum WorkoutStatus: Int, Codable { case notStarted = 1 case inProgress = 2 diff --git a/Workouts/Views/Workouts/WorkoutEditView.swift b/Workouts/Views/Workouts/WorkoutEditView.swift index 93bf519..bee7ab1 100644 --- a/Workouts/Views/Workouts/WorkoutEditView.swift +++ b/Workouts/Views/Workouts/WorkoutEditView.swift @@ -25,12 +25,12 @@ struct WorkoutEditView: View { } Section (header: Text("Status")) { - Text("\(workout.status?.name ?? WorkoutStatus.unnamed)") + Text("\(workout.statusName)") } Section (header: Text("Start/End")) { DatePicker("Started", selection: $workout.start) - if workout.status == .completed { + if workout.status == WorkoutStatus.completed.rawValue { DatePicker("Ended", selection: $workoutEndDate) } } @@ -46,7 +46,7 @@ struct WorkoutEditView: View { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { try? modelContext.save() - if workout.status == .completed { + if workout.status == WorkoutStatus.completed.rawValue { workout.end = workoutEndDate } dismiss() diff --git a/Workouts/Views/Workouts/WorkoutListView.swift b/Workouts/Views/Workouts/WorkoutListView.swift index cfac4cf..ac915e0 100644 --- a/Workouts/Views/Workouts/WorkoutListView.swift +++ b/Workouts/Views/Workouts/WorkoutListView.swift @@ -37,8 +37,8 @@ struct WorkoutListView: View { CalendarListItem( date: workout.start, 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)" )", - subtitle2: "\(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.statusName)" ) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { diff --git a/Worksouts Watch App/Views/ActiveWorkoutListView.swift b/Worksouts Watch App/Views/ActiveWorkoutListView.swift deleted file mode 100644 index de33445..0000000 --- a/Worksouts Watch App/Views/ActiveWorkoutListView.swift +++ /dev/null @@ -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) -//} diff --git a/Worksouts Watch App/Views/ExerciseProgressControlView.swift b/Worksouts Watch App/Views/ExerciseProgressControlView.swift deleted file mode 100644 index 639a39e..0000000 --- a/Worksouts Watch App/Views/ExerciseProgressControlView.swift +++ /dev/null @@ -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) -//}