335 lines
9.5 KiB
Swift
335 lines
9.5 KiB
Swift
//
|
||
// 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)
|
||
//}
|