This commit is contained in:
2025-08-08 21:09:11 -04:00
parent 2f044c3d9c
commit 7bcc5d656c
38 changed files with 776 additions and 159 deletions

View File

@ -12,17 +12,31 @@ import SwiftUI
struct ExerciseDoneCard: View {
let elapsedSeconds: Int
let onComplete: () -> Void
let onOneMoreSet: () -> Void
var body: some View {
VStack(spacing: 20) {
VStack(spacing: 12) {
Text("Exercise Complete!")
.font(.headline)
.foregroundColor(.green)
Button(action: onComplete) {
Text("Done in \(10 - elapsedSeconds)s")
.font(.headline)
.font(.subheadline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.green)
.padding(.horizontal)
Button(action: onOneMoreSet) {
Text("One More Set")
.font(.subheadline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.blue)
.padding(.horizontal)
}
.padding()
}

View File

@ -11,12 +11,13 @@ import SwiftUI
struct ExerciseRestCard: View {
let elapsedSeconds: Int
let afterSet: Int
var body: some View {
VStack(spacing: 20) {
Text("Resting for")
.font(.title)
.lineLimit(1)
Text("Resting after set #\(afterSet)")
.font(.title3)
.lineLimit(2)
.minimumScaleFactor(0.5)
.layoutPriority(1)

View File

@ -19,6 +19,7 @@ struct ExerciseProgressControlView: View {
@State private var elapsedSeconds: Int = 0
@State private var timer: Timer? = nil
@State private var previousStateIndex: Int = 0
@State private var hapticCounter: Int = 0
var body: some View {
TabView(selection: $currentStateIndex) {
@ -32,21 +33,40 @@ struct ExerciseProgressControlView: View {
.tag(index)
} else if state.isRest {
ExerciseRestCard(elapsedSeconds: elapsedSeconds)
ExerciseRestCard(elapsedSeconds: elapsedSeconds, afterSet: state.afterSet ?? 0)
.tag(index)
} else if state.isDone {
ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise)
ExerciseDoneCard(elapsedSeconds: elapsedSeconds, onComplete: completeExercise, onOneMoreSet: addOneMoreSet)
.tag(index)
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.gesture(
DragGesture()
.onEnded { value in
// Detect swipe left when on the Done card (last index)
if currentStateIndex == exerciseStates.count - 1 && value.translation.width < -50 {
// User swiped left from Done card - add one more set
addOneMoreSet()
}
}
)
.onChange(of: currentStateIndex) { oldValue, newValue in
if oldValue != newValue {
elapsedSeconds = 0
moveToNextState()
hapticCounter = 0 // Reset haptic pattern when changing phases
// Update the log's current state but don't auto-advance
log.currentStateIndex = currentStateIndex
log.elapsedSeconds = elapsedSeconds
try? modelContext.save()
// Update status based on current state
if currentStateIndex > 0 && currentStateIndex < exerciseStates.count - 1 {
log.status = .inProgress
}
}
}
.onAppear {
@ -54,6 +74,12 @@ struct ExerciseProgressControlView: View {
currentStateIndex = log.currentStateIndex ?? 0
startTimer()
}
.onChange(of: log.sets) { oldValue, newValue in
// Reconstruct exercise states if sets count changed
if oldValue != newValue {
setupExerciseStates()
}
}
.onDisappear {
stopTimer()
}
@ -76,11 +102,11 @@ struct ExerciseProgressControlView: View {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
elapsedSeconds += 1
// Check if we need to provide haptic feedback during rest periods
// Check if we need to provide haptic feedback
if currentStateIndex >= 0 && currentStateIndex < exerciseStates.count {
let currentState = exerciseStates[currentStateIndex]
if currentState.isRest {
provideRestHapticFeedback()
if currentState.isRest || currentState.isSet {
provideHapticFeedback()
} else if currentState.isDone && elapsedSeconds >= 10 {
// Auto-complete after 10 seconds on the DONE state
completeExercise()
@ -94,34 +120,53 @@ struct ExerciseProgressControlView: View {
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()
private func provideHapticFeedback() {
// Provide haptic feedback every 15 seconds in a cycling pattern: 1 2 3 long
if elapsedSeconds % 15 == 0 && elapsedSeconds > 0 {
hapticCounter += 1
switch hapticCounter % 4 {
case 1:
// First 15 seconds: single tap
HapticFeedback.click()
case 2:
// Second 15 seconds: double tap
HapticFeedback.doubleTap()
case 3:
// Third 15 seconds: triple tap
HapticFeedback.tripleTap()
case 0:
// Fourth 15 seconds: long tap, then reset pattern
HapticFeedback.longTap()
default:
break
}
} 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 addOneMoreSet() {
// Increment total sets
log.sets += 1
// Reconstruct exercise states (will trigger onChange)
setupExerciseStates()
// Calculate the state index for the additional set
// States: intro(0) set1(1) rest1(2) ... setN(2N-1) done(2N)
// For the additional set, we want to go to setN which is at index 2N-1
let additionalSetStateIndex = (log.sets * 2) - 1
log.status = .inProgress
log.currentStateIndex = additionalSetStateIndex
log.elapsedSeconds = 0
elapsedSeconds = 0
hapticCounter = 0
// Update the current state index for the TabView
currentStateIndex = additionalSetStateIndex
try? modelContext.save()
}
private func completeExercise() {

View File

@ -28,7 +28,7 @@ struct WorkoutCardView: View {
.font(.headline)
.foregroundStyle(.white)
Text(workout.statusName)
Text("\(workout.statusName)~")
.font(.caption)
.foregroundStyle(Color.accentColor)
}

View File

@ -8,17 +8,24 @@
//
import SwiftUI
import SwiftData
struct WorkoutDetailView: View {
let workout: Workout
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var showingCompletedDialog = false
@State private var selectedLog: WorkoutLog? = nil
@State private var navigateToExercise = false
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)
Button {
handleExerciseTap(log)
} label: {
WorkoutLogCardView(log: log)
}
@ -46,5 +53,88 @@ struct WorkoutDetailView: View {
Spacer()
}
}
.navigationDestination(isPresented: $navigateToExercise) {
if let selectedLog = selectedLog {
ExerciseProgressControlView(log: selectedLog)
}
}
.alert("Exercise Completed", isPresented: $showingCompletedDialog) {
Button("Cancel", role: .cancel) {
// Do nothing, just dismiss
}
Button("Restart") {
if let log = selectedLog {
restartExercise(log)
}
}
Button("One More Set") {
if let log = selectedLog {
addOneMoreSet(log)
}
}
} message: {
Text("This exercise is already completed. What would you like to do?")
}
}
private func handleExerciseTap(_ log: WorkoutLog) {
selectedLog = log
switch log.status {
case .notStarted:
// Start from beginning
log.currentStateIndex = 0
try? modelContext.save()
navigateToExercise = true
case .inProgress:
// If we're in a rest state, advance to the next set
if let currentStateIndex = log.currentStateIndex, isRestState(currentStateIndex) {
log.currentStateIndex = currentStateIndex + 1
try? modelContext.save()
}
// Continue from current (possibly updated) position
navigateToExercise = true
case .completed:
// Show dialog for completed exercise
showingCompletedDialog = true
default:
// Default to not started behavior
log.currentStateIndex = 0
try? modelContext.save()
navigateToExercise = true
}
}
private func restartExercise(_ log: WorkoutLog) {
log.status = .notStarted
log.currentStateIndex = 0
log.elapsedSeconds = 0
try? modelContext.save()
navigateToExercise = true
}
private func addOneMoreSet(_ log: WorkoutLog) {
// Increment total sets
log.sets += 1
// Calculate the state index for the additional set
// States: intro(0) set1(1) rest1(2) ... setN(2N-1) done(2N)
// For the additional set, we want to go to setN+1 which is at index 2N+1
let additionalSetStateIndex = (log.sets * 2) - 1
log.status = .inProgress
log.currentStateIndex = additionalSetStateIndex
log.elapsedSeconds = 0
try? modelContext.save()
navigateToExercise = true
}
private func isRestState(_ stateIndex: Int) -> Bool {
// Rest states are at even indices > 0
return stateIndex > 0 && stateIndex % 2 == 0
}
}

View File

@ -18,7 +18,7 @@ struct WorkoutLogCardView: View {
.font(.headline)
.lineLimit(1)
Text(log.status?.name ?? "Not Started")
Text(getStatusText(for: log))
.font(.caption)
.foregroundStyle(Color.accentColor)
@ -31,4 +31,41 @@ struct WorkoutLogCardView: View {
}
}
}
private func getStatusText(for log: WorkoutLog) -> String {
guard let status = log.status else {
return "Not Started"
}
if status == .inProgress, let currentStateIndex = log.currentStateIndex {
let currentSet = getCurrentSetNumber(stateIndex: currentStateIndex, totalSets: log.sets)
if currentSet > 0 {
return "In Progress, Set #\(currentSet)"
}
}
return status.name
}
private func getCurrentSetNumber(stateIndex: Int, totalSets: Int) -> Int {
// Exercise states are structured as: intro(0) set1(1) rest1(2) set2(3) rest2(4) ... done
// For each set number n, set state index = 2n-1, rest state index = 2n
if stateIndex <= 0 {
return 0 // intro or invalid
}
// Check if we're in a rest state (even indices > 0)
let isRestState = stateIndex > 0 && stateIndex % 2 == 0
if isRestState {
// During rest, show the next set number
let nextSetNumber = (stateIndex / 2) + 1
return min(nextSetNumber, totalSets)
} else {
// During set, show current set number
let currentSetNumber = (stateIndex + 1) / 2
return min(currentSetNumber, totalSets)
}
}
}