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

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,317 @@
import SwiftUI
import SwiftData
import WatchKit
// Enum to track the current phase of the exercise
enum ExercisePhase {
case notStarted
case exercising(setNumber: Int)
case resting(setNumber: Int, elapsedSeconds: Int)
case completed
}
struct ExerciseProgressView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let log: WorkoutLog
@State private var phase: ExercisePhase = .notStarted
@State private var hapticSeconds: Int = 0
@State private var restSeconds: Int = 0
@State private var hapticTimer: Timer? = nil
@State private var restTimer: Timer? = nil
var body: some View {
ScrollView {
VStack(spacing: 15) {
exerciseHeader
switch phase {
case .notStarted:
startPhaseView
case .exercising(let setNumber):
exercisingPhaseView(setNumber: setNumber)
case .resting(let setNumber, let elapsedSeconds):
restingPhaseView(setNumber: setNumber, elapsedSeconds: elapsedSeconds)
case .completed:
completedPhaseView
}
}
.padding()
}
.navigationTitle(log.exerciseName)
.navigationBarTitleDisplayMode(.inline)
.onDisappear {
stopTimers()
}
.gesture(
DragGesture(minimumDistance: 50)
.onEnded { gesture in
if gesture.translation.width < 0 {
// Swipe left - progress to next phase
handleSwipeLeft()
} else if gesture.translation.height < 0 && gesture.translation.height < -50 {
// Swipe up - cancel current set
handleSwipeUp()
}
}
)
}
// MARK: - View Components
private var exerciseHeader: some View {
VStack(alignment: .leading, spacing: 5) {
Text(log.exerciseName)
.font(.headline)
.foregroundColor(.primary)
Text("\(log.sets) sets × \(log.reps) reps")
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(log.weight) lbs")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 5)
}
private var startPhaseView: some View {
VStack(spacing: 20) {
Text("Ready to start?")
.font(.headline)
Button(action: startFirstSet) {
Text("Start First Set")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}
private func exercisingPhaseView(setNumber: Int) -> some View {
VStack(spacing: 20) {
Text("Set \(setNumber) of \(log.sets)")
.font(.headline)
Text("Exercising...")
.foregroundColor(.secondary)
HStack(spacing: 20) {
Button(action: completeSet) {
Text("Complete")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
Button(action: cancelSet) {
Text("Cancel")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
}
Text("Or swipe left to complete")
.font(.caption)
.foregroundColor(.secondary)
Text("Swipe up to cancel")
.font(.caption)
.foregroundColor(.secondary)
}
}
private func restingPhaseView(setNumber: Int, elapsedSeconds: Int) -> some View {
VStack(spacing: 20) {
Text("Rest after Set \(setNumber)")
.font(.headline)
Text("Rest time: \(formatSeconds(elapsedSeconds))")
.foregroundColor(.secondary)
if setNumber < (log.sets) {
Button(action: { startNextSet(after: setNumber) }) {
Text("Start Set \(setNumber + 1)")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
Text("Or swipe left to start next set")
.font(.caption)
.foregroundColor(.secondary)
} else {
Button(action: completeExercise) {
Text("Complete Exercise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
Text("Or swipe left to complete")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private var completedPhaseView: some View {
VStack(spacing: 20) {
Text("Exercise Completed!")
.font(.headline)
.foregroundColor(.green)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 50))
.foregroundColor(.green)
Button(action: { dismiss() }) {
Text("Return to Workout")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}
// MARK: - Action Handlers
private func handleSwipeLeft() {
switch phase {
case .notStarted:
startFirstSet()
case .exercising:
completeSet()
case .resting(let setNumber, _):
if setNumber < (log.sets) {
startNextSet(after: setNumber)
} else {
completeExercise()
}
case .completed:
dismiss()
}
}
private func handleSwipeUp() {
if case .exercising = phase {
cancelSet()
}
}
private func startFirstSet() {
phase = .exercising(setNumber: 1)
startHapticTimer()
}
private func startNextSet(after completedSetNumber: Int) {
stopTimers()
let nextSetNumber = completedSetNumber + 1
phase = .exercising(setNumber: nextSetNumber)
startHapticTimer()
}
private func completeSet() {
stopTimers()
if case .exercising(let setNumber) = phase {
// Start rest timer
phase = .resting(setNumber: setNumber, elapsedSeconds: 0)
startRestTimer()
startHapticTimer()
// Play completion haptic
HapticFeedback.success()
}
}
private func cancelSet() {
// Just go back to the previous state
stopTimers()
phase = .notStarted
}
private func completeExercise() {
stopTimers()
// Update workout log
log.completed = true
log.status = .completed
try? modelContext.save()
// Show completion screen
phase = .completed
// Play completion haptic
HapticFeedback.success()
}
// MARK: - Timer Management
private func startHapticTimer() {
hapticSeconds = 0
hapticTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
hapticSeconds += 1
// Provide haptic feedback based on time intervals
if hapticSeconds % 60 == 0 {
// Triple tap every 60 seconds
HapticFeedback.tripleTap()
} else if hapticSeconds % 30 == 0 {
// Double tap every 30 seconds
HapticFeedback.doubleTap()
} else if hapticSeconds % 10 == 0 {
// Light tap every 10 seconds
HapticFeedback.click()
}
}
}
private func startRestTimer() {
restSeconds = 0
restTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
restSeconds += 1
if case .resting(let setNumber, _) = phase {
phase = .resting(setNumber: setNumber, elapsedSeconds: restSeconds)
}
}
}
private func stopTimers() {
hapticTimer?.invalidate()
hapticTimer = nil
restTimer?.invalidate()
restTimer = nil
}
// MARK: - Helper Functions
private func formatSeconds(_ seconds: Int) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
//#Preview {
// let config = ModelConfiguration(isStoredInMemoryOnly: true)
// let container = try! ModelContainer(for: SchemaV1.models, configurations: config)
//
// // Create sample data
// let exercise = Exercise(name: "Bench Press", sets: 3, reps: 8, weight: 60.0)
// let workout = Workout(name: "Chest Day", date: Date())
// let log = WorkoutLog(exercise: exercise, workout: workout)
//
// NavigationStack {
// ExerciseProgressView(log: log)
// .modelContainer(container)
// }
//}

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)
}
}