Files
workouts/Worksouts Watch App/Views/ExerciseProgressControlView.swift
2025-07-25 17:42:25 -04:00

335 lines
9.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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(
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 elapsedSeconds: Int
let onComplete: () -> Void
var body: some View {
VStack(spacing: 20) {
// Title based on state
Text(stateTitle)
.font(.title3)
.fontWeight(.bold)
// Timer display
Text(timeFormatted)
.font(.system(size: 48, weight: .semibold, design: .monospaced))
.foregroundStyle(state.isRest ? .orange : .accentColor)
// Only show Done button and countdown for the final state
if state.isDone {
// Countdown message
if elapsedSeconds < 10 {
Text("Completing automatically in \(10 - elapsedSeconds) seconds")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
} else {
Text("Auto-completing...")
.font(.caption)
.foregroundStyle(.secondary)
}
// Done button
Button(action: onComplete) {
Text("Done")
.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) in progress"
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)
//}