wip
This commit is contained in:
BIN
Workouts Watch App/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
BIN
Workouts Watch App/Assets.xcassets/AppIcon.appiconset/1024.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "DumbBellIcon-light.png",
|
||||
"filename" : "1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 97 KiB |
@ -13,49 +13,45 @@ import SwiftData
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State var activeWorkouts: [Workout] = []
|
||||
@State var workouts: [Workout] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
if activeWorkouts.isEmpty {
|
||||
NoActiveWorkoutView()
|
||||
if workouts.isEmpty {
|
||||
NoWorkoutView()
|
||||
} else {
|
||||
ActiveWorkoutListView(workouts: activeWorkouts)
|
||||
ActiveWorkoutListView(workouts: workouts)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadActiveWorkouts()
|
||||
loadWorkouts()
|
||||
}
|
||||
}
|
||||
|
||||
func loadActiveWorkouts () {
|
||||
let completedStatus = WorkoutStatus.completed.rawValue
|
||||
func loadWorkouts () {
|
||||
do {
|
||||
self.activeWorkouts = try modelContext.fetch(FetchDescriptor<Workout>(
|
||||
predicate: #Predicate<Workout> { workout in
|
||||
workout.status != completedStatus
|
||||
},
|
||||
self.workouts = try modelContext.fetch(FetchDescriptor<Workout>(
|
||||
sortBy: [
|
||||
SortDescriptor(\Workout.start, order: .reverse)
|
||||
]
|
||||
))
|
||||
} catch {
|
||||
print("ERROR: failed to load active workouts \(error)")
|
||||
print("ERROR: failed to load workouts \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NoActiveWorkoutView: View {
|
||||
struct NoWorkoutView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "dumbbell.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.gray)
|
||||
|
||||
Text("No Active Workout")
|
||||
Text("No Workouts")
|
||||
.font(.headline)
|
||||
|
||||
Text("Start a workout in the main app")
|
||||
Text("Create a workout in the main app")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.gray)
|
||||
.multilineTextAlignment(.center)
|
||||
|
@ -30,4 +30,8 @@ struct HapticFeedback {
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
}
|
||||
|
||||
static func longTap() {
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -28,7 +28,7 @@ struct WorkoutCardView: View {
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(workout.statusName)
|
||||
Text("\(workout.statusName)~")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user