wip
This commit is contained in:
BIN
Artwork/App Icon Template.psd
Normal file
BIN
Artwork/App Icon Template.psd
Normal file
Binary file not shown.
BIN
Artwork/DumbBellIcon-light.png
Normal file
BIN
Artwork/DumbBellIcon-light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
BIN
Artwork/DumbBellIcon.png
Normal file
BIN
Artwork/DumbBellIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
Artwork/DumbBellIcon.pxd
Normal file
BIN
Artwork/DumbBellIcon.pxd
Normal file
Binary file not shown.
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "DumbBellIcon-light.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "watchos",
|
"platform" : "watchos",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
@ -13,15 +13,7 @@ import SwiftData
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
// Use string literal for completed status to avoid enum reference in predicate
|
|
||||||
// @Query(filter: #Predicate<Workout> { workout in
|
|
||||||
// workout.status?.rawValue != 3
|
|
||||||
// }, sort: \Workout.start, order: .reverse) var activeWorkouts: [Workout]
|
|
||||||
|
|
||||||
// @Query(sort: [SortDescriptor(\Workout.start)]) var allWorkouts: [Workout]
|
|
||||||
|
|
||||||
@State var activeWorkouts: [Workout] = []
|
@State var activeWorkouts: [Workout] = []
|
||||||
@State var splits: [Split] = []
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@ -32,37 +24,25 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadSplits()
|
|
||||||
loadActiveWorkouts()
|
loadActiveWorkouts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadActiveWorkouts () {
|
func loadActiveWorkouts () {
|
||||||
|
let completedStatus = WorkoutStatus.completed.rawValue
|
||||||
do {
|
do {
|
||||||
print("loading active workouts")
|
|
||||||
self.activeWorkouts = try modelContext.fetch(FetchDescriptor<Workout>(
|
self.activeWorkouts = try modelContext.fetch(FetchDescriptor<Workout>(
|
||||||
|
predicate: #Predicate<Workout> { workout in
|
||||||
|
workout.status != completedStatus
|
||||||
|
},
|
||||||
sortBy: [
|
sortBy: [
|
||||||
SortDescriptor(\Workout.start)
|
SortDescriptor(\Workout.start, order: .reverse)
|
||||||
]
|
]
|
||||||
))
|
))
|
||||||
print("loaded active workouts \(activeWorkouts.count)")
|
|
||||||
} catch {
|
} catch {
|
||||||
print("ERROR: failed to load active workouts \(error)")
|
print("ERROR: failed to load active workouts \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSplits () {
|
|
||||||
do {
|
|
||||||
self.splits = try modelContext.fetch(FetchDescriptor<Split>(
|
|
||||||
sortBy: [
|
|
||||||
SortDescriptor(\Split.order),
|
|
||||||
SortDescriptor(\Split.name)
|
|
||||||
]
|
|
||||||
))
|
|
||||||
} catch {
|
|
||||||
print("ERROR: failed to load splits \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoActiveWorkoutView: View {
|
struct NoActiveWorkoutView: View {
|
||||||
@ -84,7 +64,7 @@ struct NoActiveWorkoutView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//#Preview {
|
#Preview {
|
||||||
// ContentView()
|
ContentView()
|
||||||
// .modelContainer(AppContainer.preview)
|
.modelContainer(AppContainer.preview)
|
||||||
//}
|
}
|
@ -135,20 +135,23 @@ final class AppContainer {
|
|||||||
|
|
||||||
// Upper Body Workout (in progress)
|
// Upper Body Workout (in progress)
|
||||||
let upperBodyWorkout = Workout(start: now, end: now, split: upperBodySplit)
|
let upperBodyWorkout = Workout(start: now, end: now, split: upperBodySplit)
|
||||||
upperBodyWorkout.status = .inProgress
|
upperBodyWorkout.status = 2
|
||||||
|
// upperBodyWorkout.status = .inProgress
|
||||||
upperBodyWorkout.end = nil
|
upperBodyWorkout.end = nil
|
||||||
context.insert(upperBodyWorkout)
|
context.insert(upperBodyWorkout)
|
||||||
|
|
||||||
// Lower Body Workout (scheduled for tomorrow)
|
// Lower Body Workout (scheduled for tomorrow)
|
||||||
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) ?? now
|
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) ?? now
|
||||||
let lowerBodyWorkout = Workout(start: tomorrow, end: tomorrow, split: lowerBodySplit)
|
let lowerBodyWorkout = Workout(start: tomorrow, end: tomorrow, split: lowerBodySplit)
|
||||||
lowerBodyWorkout.status = .notStarted
|
lowerBodyWorkout.status = 1
|
||||||
|
// lowerBodyWorkout.status = .notStarted
|
||||||
context.insert(lowerBodyWorkout)
|
context.insert(lowerBodyWorkout)
|
||||||
|
|
||||||
// Full Body Workout (completed yesterday)
|
// Full Body Workout (completed yesterday)
|
||||||
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
|
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now
|
||||||
let fullBodyWorkout = Workout(start: yesterday, end: yesterday, split: fullBodySplit)
|
let fullBodyWorkout = Workout(start: yesterday, end: yesterday, split: fullBodySplit)
|
||||||
fullBodyWorkout.status = .completed
|
fullBodyWorkout.status = 3
|
||||||
|
// fullBodyWorkout.status = .completed
|
||||||
context.insert(fullBodyWorkout)
|
context.insert(fullBodyWorkout)
|
||||||
|
|
||||||
// Create workout logs for Upper Body workout (in progress)
|
// Create workout logs for Upper Body workout (in progress)
|
18
Workouts Watch App/Utils/TimeInterval+formatted.swift
Normal file
18
Workouts Watch App/Utils/TimeInterval+formatted.swift
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// TimeInterval+minutesSecons.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/23/25 at 4:22 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Int {
|
||||||
|
var secondsFormatted: String {
|
||||||
|
let minutes = self / 60
|
||||||
|
let seconds = self % 60
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// ExerciseDoneCard.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/23/25 at 4:29 PM.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// ExerciseIntroView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/23/25 at 4:19 PM.
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// ExerciseRestCard.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/23/25 at 4:28 PM.
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// ExerciseSetCard.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/23/25 at 4:26 PM.
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
|||||||
|
//
|
||||||
|
// ExerciseProgressControlView 2.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/23/25 at 9:15 AM.
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
72
Workouts Watch App/Views/Exercises/ExerciseState.swift
Normal file
72
Workouts Watch App/Views/Exercises/ExerciseState.swift
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
//
|
||||||
|
// ExerciseState.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/23/25 at 9:14 AM.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
47
Workouts Watch App/Views/Exercises/ExerciseStateView.swift
Normal file
47
Workouts Watch App/Views/Exercises/ExerciseStateView.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// ExerciseStateView 2.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/23/25 at 9:15 AM.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
//
|
||||||
|
// ActiveWorkoutListView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/20/25 at 6:35 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ActiveWorkoutListView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
let workouts: [Workout]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(workouts) { workout in
|
||||||
|
NavigationLink {
|
||||||
|
WorkoutDetailView(workout: workout)
|
||||||
|
} label: {
|
||||||
|
WorkoutCardView(workout: workout)
|
||||||
|
}
|
||||||
|
.listRowBackground(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.secondary.opacity(0.2))
|
||||||
|
.padding(
|
||||||
|
EdgeInsets(
|
||||||
|
top: 4,
|
||||||
|
leading: 8,
|
||||||
|
bottom: 4,
|
||||||
|
trailing: 8
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// .swipeActions (edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
// Button {
|
||||||
|
// //
|
||||||
|
// } label: {
|
||||||
|
// Label("Delete", systemImage: "trash")
|
||||||
|
// .frame(height: 40)
|
||||||
|
// }
|
||||||
|
// .tint(.red)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.carousel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let container = AppContainer.preview
|
||||||
|
let split = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional")
|
||||||
|
let workout1 = Workout(start: Date(), end: Date(), split: split)
|
||||||
|
|
||||||
|
let split2 = Split(name: "Lower Body", color: "red", systemImage: "figure.run")
|
||||||
|
let workout2 = Workout(start: Date().addingTimeInterval(-3600), end: Date(), split: split2)
|
||||||
|
|
||||||
|
ActiveWorkoutListView(workouts: [workout1, workout2])
|
||||||
|
.modelContainer(container)
|
||||||
|
}
|
36
Workouts Watch App/Views/Workouts/WorkoutCardView.swift
Normal file
36
Workouts Watch App/Views/Workouts/WorkoutCardView.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// WorkoutCardView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/22/25 at 9:54 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WorkoutCardView: View {
|
||||||
|
let workout: Workout
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let split = workout.split {
|
||||||
|
Image(systemName: split.systemImage)
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(split.getColor())
|
||||||
|
} else {
|
||||||
|
Image(systemName: "dumbbell.fill")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundStyle(.gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(workout.split?.name ?? "Workout")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(workout.statusName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
Workouts Watch App/Views/Workouts/WorkoutDetailView.swift
Normal file
50
Workouts Watch App/Views/Workouts/WorkoutDetailView.swift
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
//
|
||||||
|
// WorkoutDetailView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/22/25 at 9:54 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WorkoutDetailView: View {
|
||||||
|
let workout: Workout
|
||||||
|
|
||||||
|
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)
|
||||||
|
} label: {
|
||||||
|
WorkoutLogCardView(log: log)
|
||||||
|
}
|
||||||
|
.listRowBackground(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.secondary.opacity(0.2))
|
||||||
|
.padding(
|
||||||
|
EdgeInsets(
|
||||||
|
top: 4,
|
||||||
|
leading: 8,
|
||||||
|
bottom: 4,
|
||||||
|
trailing: 8
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.carousel)
|
||||||
|
} else {
|
||||||
|
Text("No exercises in this workout")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift
Normal file
34
Workouts Watch App/Views/Workouts/WorkoutLogCardView.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// WorkoutLogCardView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/22/25 at 9:56 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WorkoutLogCardView: View {
|
||||||
|
let log: WorkoutLog
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(log.exerciseName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(log.status?.name ?? "Not Started")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("\(log.weight) lbs")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(log.sets) × \(log.reps)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
69
Workouts Watch App/Views/Workouts/WorkoutLogDetailView.swift
Normal file
69
Workouts Watch App/Views/Workouts/WorkoutLogDetailView.swift
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
//
|
||||||
|
// WorkoutLogDetailView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/22/25 at 9:57 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WorkoutLogDetailView: View {
|
||||||
|
let log: WorkoutLog
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink {
|
||||||
|
ExerciseProgressControlView(log: log)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .center) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
Text("Tap to start")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(for status: WorkoutStatus?) -> Color {
|
||||||
|
guard let status = status else { return .secondary }
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case .notStarted:
|
||||||
|
return .secondary
|
||||||
|
case .inProgress:
|
||||||
|
return .blue
|
||||||
|
case .completed:
|
||||||
|
return .green
|
||||||
|
case .skipped:
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Worksouts_Watch_AppApp: App {
|
struct Workouts_Watch_AppApp: App {
|
||||||
let container = AppContainer.create()
|
let container = AppContainer.create()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
45
Workouts Watch App/__ATTIC__/ExerciseDetailView.swift
Normal file
45
Workouts Watch App/__ATTIC__/ExerciseDetailView.swift
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
////
|
||||||
|
//// ExerciseDetailView.swift
|
||||||
|
//// Workouts
|
||||||
|
////
|
||||||
|
//// Created by rzen on 7/23/25 at 9:17 AM.
|
||||||
|
////
|
||||||
|
//// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
////
|
||||||
|
//
|
||||||
|
//import SwiftUI
|
||||||
|
//
|
||||||
|
//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)
|
||||||
|
// }
|
||||||
|
// .padding()
|
||||||
|
// }
|
||||||
|
//}
|
@ -0,0 +1,35 @@
|
|||||||
|
////
|
||||||
|
//// 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
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//// Detail view shown as the first item in the exercise progress carousel
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//// 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)
|
||||||
|
////}
|
@ -7,7 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Workouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; };
|
A45FA2732E29B12500581607 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2722E29B12500581607 /* Yams */; };
|
||||||
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; };
|
A45FA27F2E2A930900581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA27E2E2A930900581607 /* SwiftUIReorderableForEach */; };
|
||||||
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; };
|
A45FA2822E2A933B00581607 /* SwiftUIReorderableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = A45FA2812E2A933B00581607 /* SwiftUIReorderableForEach */; };
|
||||||
@ -20,7 +20,7 @@
|
|||||||
containerPortal = A45FA0892E21B3DC00581607 /* Project object */;
|
containerPortal = A45FA0892E21B3DC00581607 /* Project object */;
|
||||||
proxyType = 1;
|
proxyType = 1;
|
||||||
remoteGlobalIDString = A45FA1F02E27171A00581607;
|
remoteGlobalIDString = A45FA1F02E27171A00581607;
|
||||||
remoteInfo = "Worksouts Watch App";
|
remoteInfo = "Workouts Watch App";
|
||||||
};
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
@ -31,7 +31,7 @@
|
|||||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||||
dstSubfolderSpec = 16;
|
dstSubfolderSpec = 16;
|
||||||
files = (
|
files = (
|
||||||
A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */,
|
A45FA1FE2E27171B00581607 /* Workouts Watch App.app in Embed Watch Content */,
|
||||||
);
|
);
|
||||||
name = "Embed Watch Content";
|
name = "Embed Watch Content";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A45FA1F12E27171A00581607 /* Worksouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Worksouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
A45FA1F12E27171A00581607 /* Workouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -53,7 +53,7 @@
|
|||||||
);
|
);
|
||||||
target = A45FA0902E21B3DD00581607 /* Workouts */;
|
target = A45FA0902E21B3DD00581607 /* Workouts */;
|
||||||
};
|
};
|
||||||
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Worksouts Watch App" target */ = {
|
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Workouts Watch App" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
_ATTIC_/ContentView_backup.swift,
|
_ATTIC_/ContentView_backup.swift,
|
||||||
@ -66,7 +66,7 @@
|
|||||||
Views/Common/CheckboxStatus.swift,
|
Views/Common/CheckboxStatus.swift,
|
||||||
Views/WorkoutLog/WorkoutStatus.swift,
|
Views/WorkoutLog/WorkoutStatus.swift,
|
||||||
);
|
);
|
||||||
target = A45FA1F02E27171A00581607 /* Worksouts Watch App */;
|
target = A45FA1F02E27171A00581607 /* Workouts Watch App */;
|
||||||
};
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
@ -75,14 +75,14 @@
|
|||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */,
|
A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */,
|
||||||
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Worksouts Watch App" target */,
|
A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Workouts Watch App" target */,
|
||||||
);
|
);
|
||||||
path = Workouts;
|
path = Workouts;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
A45FA1F22E27171A00581607 /* Worksouts Watch App */ = {
|
A45FA1F22E27171A00581607 /* Workouts Watch App */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = "Worksouts Watch App";
|
path = "Workouts Watch App";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
@ -112,9 +112,8 @@
|
|||||||
A45FA0882E21B3DC00581607 = {
|
A45FA0882E21B3DC00581607 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A45FA2C02E2D3C0900581607 /* Shared Models */,
|
|
||||||
A45FA0932E21B3DD00581607 /* Workouts */,
|
A45FA0932E21B3DD00581607 /* Workouts */,
|
||||||
A45FA1F22E27171A00581607 /* Worksouts Watch App */,
|
A45FA1F22E27171A00581607 /* Workouts Watch App */,
|
||||||
A45FA0922E21B3DD00581607 /* Products */,
|
A45FA0922E21B3DD00581607 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -123,18 +122,11 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A45FA0912E21B3DD00581607 /* Workouts.app */,
|
A45FA0912E21B3DD00581607 /* Workouts.app */,
|
||||||
A45FA1F12E27171A00581607 /* Worksouts Watch App.app */,
|
A45FA1F12E27171A00581607 /* Workouts Watch App.app */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
A45FA2C02E2D3C0900581607 /* Shared Models */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
);
|
|
||||||
path = "Shared Models";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@ -166,9 +158,9 @@
|
|||||||
productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
|
productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
A45FA1F02E27171A00581607 /* Worksouts Watch App */ = {
|
A45FA1F02E27171A00581607 /* Workouts Watch App */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Worksouts Watch App" */;
|
buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
A45FA1ED2E27171A00581607 /* Sources */,
|
A45FA1ED2E27171A00581607 /* Sources */,
|
||||||
A45FA1EE2E27171A00581607 /* Frameworks */,
|
A45FA1EE2E27171A00581607 /* Frameworks */,
|
||||||
@ -179,13 +171,13 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
A45FA1F22E27171A00581607 /* Worksouts Watch App */,
|
A45FA1F22E27171A00581607 /* Workouts Watch App */,
|
||||||
);
|
);
|
||||||
name = "Worksouts Watch App";
|
name = "Workouts Watch App";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = "Worksouts Watch App";
|
productName = "Workouts Watch App";
|
||||||
productReference = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */;
|
productReference = A45FA1F12E27171A00581607 /* Workouts Watch App.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
@ -225,7 +217,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
A45FA0902E21B3DD00581607 /* Workouts */,
|
A45FA0902E21B3DD00581607 /* Workouts */,
|
||||||
A45FA1F02E27171A00581607 /* Worksouts Watch App */,
|
A45FA1F02E27171A00581607 /* Workouts Watch App */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@ -267,7 +259,7 @@
|
|||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
A45FA1FD2E27171B00581607 /* PBXTargetDependency */ = {
|
A45FA1FD2E27171B00581607 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = A45FA1F02E27171A00581607 /* Worksouts Watch App */;
|
target = A45FA1F02E27171A00581607 /* Workouts Watch App */;
|
||||||
targetProxy = A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */;
|
targetProxy = A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
@ -461,14 +453,14 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Worksouts Watch App/Worksouts Watch App.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Worksouts Watch App/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Worksouts;
|
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@ -492,14 +484,14 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Worksouts Watch App/Worksouts Watch App.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Workouts Watch App/Workouts Watch App.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Worksouts Watch App/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Workouts Watch App/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
DEVELOPMENT_TEAM = C32Z8JNLG6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Worksouts;
|
INFOPLIST_KEY_CFBundleDisplayName = Workouts;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@ -539,7 +531,7 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Worksouts Watch App" */ = {
|
A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Workouts Watch App" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
A45FA2002E27171B00581607 /* Debug */,
|
A45FA2002E27171B00581607 /* Debug */,
|
||||||
|
@ -4,11 +4,16 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>SchemeUserState</key>
|
<key>SchemeUserState</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>Workouts.xcscheme_^#shared#^_</key>
|
<key>Workouts Watch App.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>Workouts.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
<key>Worksouts Watch App.xcscheme_^#shared#^_</key>
|
<key>Worksouts Watch App.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "DumbBellIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@ -12,6 +13,7 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "DumbBellIcon 1.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@ -23,6 +25,7 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "DumbBellIcon 2.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png
Normal file
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png
Normal file
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon 2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png
Normal file
BIN
Workouts/Assets.xcassets/AppIcon.appiconset/DumbBellIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
@ -5,8 +5,15 @@ import SwiftData
|
|||||||
final class Workout {
|
final class Workout {
|
||||||
var start: Date = Date()
|
var start: Date = Date()
|
||||||
var end: Date?
|
var end: Date?
|
||||||
var status: WorkoutStatus? = WorkoutStatus.notStarted
|
var status: Int = 1
|
||||||
|
// var status: WorkoutStatus = WorkoutStatus.notStarted
|
||||||
|
|
||||||
|
//case notStarted = 1
|
||||||
|
//case inProgress = 2
|
||||||
|
//case completed = 3
|
||||||
|
//case skipped = 4
|
||||||
|
|
||||||
|
|
||||||
@Relationship(deleteRule: .nullify)
|
@Relationship(deleteRule: .nullify)
|
||||||
var split: Split?
|
var split: Split?
|
||||||
|
|
||||||
@ -20,10 +27,25 @@ final class Workout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
if status == .completed, let endDate = end {
|
if status == 3, let endDate = end {
|
||||||
|
// if status == .completed, let endDate = end {
|
||||||
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
||||||
} else {
|
} else {
|
||||||
return start.formattedDate()
|
return start.formattedDate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var statusName: String {
|
||||||
|
if status == 1 {
|
||||||
|
return "Not Started"
|
||||||
|
} else if status == 2 {
|
||||||
|
return "In Progress"
|
||||||
|
} else if status == 3 {
|
||||||
|
return "Completed"
|
||||||
|
} else if status == 4 {
|
||||||
|
return "Skipped"
|
||||||
|
} else {
|
||||||
|
return "In progress"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,9 @@ final class WorkoutLog {
|
|||||||
var order: Int = 0
|
var order: Int = 0
|
||||||
var exerciseName: String = ""
|
var exerciseName: String = ""
|
||||||
|
|
||||||
|
var currentStateIndex: Int? = nil
|
||||||
|
var elapsedSeconds: Int? = nil
|
||||||
|
|
||||||
var completed: Bool = false
|
var completed: Bool = false
|
||||||
|
|
||||||
@Relationship(deleteRule: .nullify)
|
@Relationship(deleteRule: .nullify)
|
||||||
|
@ -10,21 +10,6 @@ struct WorkoutsMigrationPlan: SchemaMigrationPlan {
|
|||||||
toVersion: SchemaV1.self,
|
toVersion: SchemaV1.self,
|
||||||
willMigrate: { context in
|
willMigrate: { context in
|
||||||
print("migrating from v1 to v1")
|
print("migrating from v1 to v1")
|
||||||
let workouts = try? context.fetch(FetchDescriptor<Workout>())
|
|
||||||
workouts?.forEach { workout in
|
|
||||||
if let status = workout.status {
|
|
||||||
|
|
||||||
} else {
|
|
||||||
workout.status = .notStarted
|
|
||||||
}
|
|
||||||
|
|
||||||
// if let endDate = workout.end {
|
|
||||||
//
|
|
||||||
// } else {
|
|
||||||
// workout.end = Date()
|
|
||||||
// }
|
|
||||||
workout.end = Date()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
didMigrate: { _ in
|
didMigrate: { _ in
|
||||||
// No additional actions needed after migration
|
// No additional actions needed after migration
|
||||||
|
@ -21,7 +21,7 @@ struct SplitAddEditView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section(header: Text("Name")) {
|
Section(header: Text("Name")) {
|
||||||
TextField("Name", text: $model.name)
|
TextField("Name", text: $model.name)
|
||||||
.bold()
|
.bold()
|
||||||
|
198
Workouts/Views/Splits/SplitDetailView.swift
Normal file
198
Workouts/Views/Splits/SplitDetailView.swift
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
//
|
||||||
|
// SplitDetailView.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Created by rzen on 7/25/25 at 3:27 PM.
|
||||||
|
//
|
||||||
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct SplitDetailView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State var split: Split
|
||||||
|
|
||||||
|
@State private var showingAddSheet: Bool = false
|
||||||
|
@State private var itemToEdit: Exercise? = nil
|
||||||
|
@State private var itemToDelete: Exercise? = nil
|
||||||
|
@State private var createdWorkout: Workout? = nil
|
||||||
|
@State private var showingDeleteConfirmation: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section (header: Text("What is a Split?")) {
|
||||||
|
Text("A “split” is simply how you divide (or “split up”) your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section (header: Text("Exercises")) {
|
||||||
|
List {
|
||||||
|
if let assignments = split.exercises, !assignments.isEmpty {
|
||||||
|
let sortedAssignments = assignments.sorted(by: { $0.order == $1.order ? $0.name < $1.name : $0.order < $1.order })
|
||||||
|
|
||||||
|
ForEach(sortedAssignments) { item in
|
||||||
|
ListItem(
|
||||||
|
title: item.name,
|
||||||
|
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs \(item.order)"
|
||||||
|
)
|
||||||
|
.swipeActions {
|
||||||
|
Button {
|
||||||
|
itemToDelete = item
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
Button {
|
||||||
|
itemToEdit = item
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.indigo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onMove(perform: { indices, destination in
|
||||||
|
var exerciseArray = Array(sortedAssignments)
|
||||||
|
exerciseArray.move(fromOffsets: indices, toOffset: destination)
|
||||||
|
for (index, exercise) in exerciseArray.enumerated() {
|
||||||
|
exercise.order = index
|
||||||
|
}
|
||||||
|
if let modelContext = exerciseArray.first?.modelContext {
|
||||||
|
do {
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
print("Error saving after reordering: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingAddSheet = true
|
||||||
|
} label: {
|
||||||
|
ListItem(title: "Add Exercise")
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Text("No exercises added yet.")
|
||||||
|
Button(action: { showingAddSheet.toggle() }) {
|
||||||
|
ListItem(title: "Add Exercise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button ("Delete This Split", role: .destructive) {
|
||||||
|
showingDeleteConfirmation = true
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.navigationTitle("\(split.name)")
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button("Start This Split") {
|
||||||
|
let workout = Workout(start: Date(), end: Date(), split: split)
|
||||||
|
modelContext.insert(workout)
|
||||||
|
if let exercises = split.exercises {
|
||||||
|
for assignment in exercises {
|
||||||
|
let workoutLog = WorkoutLog(
|
||||||
|
workout: workout,
|
||||||
|
exerciseName: assignment.name,
|
||||||
|
date: Date(),
|
||||||
|
order: assignment.order,
|
||||||
|
sets: assignment.sets,
|
||||||
|
reps: assignment.reps,
|
||||||
|
weight: assignment.weight
|
||||||
|
)
|
||||||
|
modelContext.insert(workoutLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? modelContext.save()
|
||||||
|
|
||||||
|
// Set the created workout to trigger navigation
|
||||||
|
createdWorkout = workout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(item: $createdWorkout, destination: { workout in
|
||||||
|
WorkoutLogListView(workout: workout)
|
||||||
|
})
|
||||||
|
.sheet (isPresented: $showingAddSheet) {
|
||||||
|
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||||
|
let splitId = split.persistentModelID
|
||||||
|
print("exerciseNames: \(exerciseNames)")
|
||||||
|
if exerciseNames.count == 1 {
|
||||||
|
itemToEdit = Exercise(
|
||||||
|
split: split,
|
||||||
|
exerciseName: exerciseNames.first ?? "Exercise.unnamed",
|
||||||
|
order: 0,
|
||||||
|
sets: 3,
|
||||||
|
reps: 10,
|
||||||
|
weight: 40
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
for exerciseName in exerciseNames {
|
||||||
|
var duplicateExercise: [Exercise]? = nil
|
||||||
|
do {
|
||||||
|
duplicateExercise = try modelContext.fetch(FetchDescriptor<Exercise>(predicate: #Predicate{ exercise in
|
||||||
|
exerciseName == exercise.name && splitId == exercise.split?.persistentModelID
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
print("ERROR: failed to fetch \(exerciseName)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let dup = duplicateExercise, dup.count > 0 {
|
||||||
|
print("Skipping duplicate \(exerciseName) found \(dup.count) duplicate(s)")
|
||||||
|
} else {
|
||||||
|
print("Creating \(exerciseName) for \(split.name)")
|
||||||
|
modelContext.insert(Exercise(
|
||||||
|
split: split,
|
||||||
|
exerciseName: exerciseName,
|
||||||
|
order: 0,
|
||||||
|
sets: 3,
|
||||||
|
reps: 10,
|
||||||
|
weight: 40
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? modelContext.save()
|
||||||
|
}, allowMultiSelect: true)
|
||||||
|
}
|
||||||
|
.sheet(item: $itemToEdit) { item in
|
||||||
|
ExerciseAddEditView(model: item)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete Exercise?",
|
||||||
|
isPresented: .constant(itemToDelete != nil),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
if let item = itemToDelete {
|
||||||
|
withAnimation {
|
||||||
|
modelContext.delete(item)
|
||||||
|
try? modelContext.save()
|
||||||
|
itemToDelete = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete This Split?",
|
||||||
|
isPresented: $showingDeleteConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
modelContext.delete(split)
|
||||||
|
try? modelContext.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -25,7 +25,7 @@ struct SplitsView: View {
|
|||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||||
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
|
SortableForEach($splits, allowReordering: $allowSorting) { split, dragging in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ExerciseListView(split: split)
|
SplitDetailView(split: split)
|
||||||
} label: {
|
} label: {
|
||||||
SplitItem(
|
SplitItem(
|
||||||
name: split.name,
|
name: split.name,
|
||||||
|
@ -40,7 +40,7 @@ struct WorkoutLogListView: View {
|
|||||||
CheckboxListItem(
|
CheckboxListItem(
|
||||||
status: workoutLogStatus,
|
status: workoutLogStatus,
|
||||||
title: log.exerciseName,
|
title: log.exerciseName,
|
||||||
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs"
|
subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
||||||
)
|
)
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
.swipeActions(edge: .leading, allowsFullSwipe: false) {
|
||||||
let status = log.status ?? WorkoutStatus.notStarted
|
let status = log.status ?? WorkoutStatus.notStarted
|
||||||
@ -89,6 +89,21 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
.onMove(perform: { indices, destination in
|
||||||
|
var workoutLogArray = Array(sortedWorkoutLogs)
|
||||||
|
workoutLogArray.move(fromOffsets: indices, toOffset: destination)
|
||||||
|
for (index, log) in workoutLogArray.enumerated() {
|
||||||
|
log.order = index
|
||||||
|
}
|
||||||
|
if let modelContext = workoutLogArray.first?.modelContext {
|
||||||
|
do {
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
print("Error saving after reordering: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -171,14 +186,14 @@ struct WorkoutLogListView: View {
|
|||||||
if let _ = workout.logs?.first(where: { $0.status != .completed }) {
|
if let _ = workout.logs?.first(where: { $0.status != .completed }) {
|
||||||
if let notStartedLogs = workout.logs?.filter({ $0.status == .notStarted }) {
|
if let notStartedLogs = workout.logs?.filter({ $0.status == .notStarted }) {
|
||||||
if notStartedLogs.count == workout.logs?.count ?? 0 {
|
if notStartedLogs.count == workout.logs?.count ?? 0 {
|
||||||
workout.status = .notStarted
|
workout.status = WorkoutStatus.notStarted.rawValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let _ = workout.logs?.first(where: { $0.status == .inProgress }) {
|
if let _ = workout.logs?.first(where: { $0.status == .inProgress }) {
|
||||||
workout.status = .inProgress
|
workout.status = WorkoutStatus.inProgress.rawValue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
workout.status = .completed
|
workout.status = WorkoutStatus.completed.rawValue
|
||||||
workout.end = Date()
|
workout.end = Date()
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
|
@ -7,6 +7,8 @@
|
|||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
enum WorkoutStatus: Int, Codable {
|
enum WorkoutStatus: Int, Codable {
|
||||||
case notStarted = 1
|
case notStarted = 1
|
||||||
case inProgress = 2
|
case inProgress = 2
|
||||||
|
@ -25,12 +25,12 @@ struct WorkoutEditView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section (header: Text("Status")) {
|
Section (header: Text("Status")) {
|
||||||
Text("\(workout.status?.name ?? WorkoutStatus.unnamed)")
|
Text("\(workout.statusName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section (header: Text("Start/End")) {
|
Section (header: Text("Start/End")) {
|
||||||
DatePicker("Started", selection: $workout.start)
|
DatePicker("Started", selection: $workout.start)
|
||||||
if workout.status == .completed {
|
if workout.status == WorkoutStatus.completed.rawValue {
|
||||||
DatePicker("Ended", selection: $workoutEndDate)
|
DatePicker("Ended", selection: $workoutEndDate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,7 +46,7 @@ struct WorkoutEditView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
if workout.status == .completed {
|
if workout.status == WorkoutStatus.completed.rawValue {
|
||||||
workout.end = workoutEndDate
|
workout.end = workoutEndDate
|
||||||
}
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
|
@ -37,8 +37,8 @@ struct WorkoutListView: View {
|
|||||||
CalendarListItem(
|
CalendarListItem(
|
||||||
date: workout.start,
|
date: workout.start,
|
||||||
title: workout.split?.name ?? Split.unnamed,
|
title: workout.split?.name ?? Split.unnamed,
|
||||||
subtitle: "\(workout.status == .completed ? workout.start.humanTimeInterval(to: (workout.end ?? Date())) : "\(workout.start.formattedDate()) - \(workout.status?.name ?? WorkoutStatus.unnamed)" )",
|
subtitle: "\(workout.status == WorkoutStatus.completed.rawValue ? workout.start.humanTimeInterval(to: (workout.end ?? Date())) : "\(workout.start.formattedDate()) - \(workout.statusName)" )",
|
||||||
subtitle2: "\(workout.status?.name ?? WorkoutStatus.unnamed)"
|
subtitle2: "\(workout.statusName)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
@ -1,219 +0,0 @@
|
|||||||
//
|
|
||||||
// ActiveWorkoutListView.swift
|
|
||||||
// Workouts
|
|
||||||
//
|
|
||||||
// Created by rzen on 7/20/25 at 6:35 PM.
|
|
||||||
//
|
|
||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct ActiveWorkoutListView: View {
|
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
let workouts: [Workout]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
ForEach(workouts) { workout in
|
|
||||||
NavigationLink {
|
|
||||||
WorkoutDetailView(workout: workout)
|
|
||||||
} label: {
|
|
||||||
WorkoutCardView(workout: workout)
|
|
||||||
}
|
|
||||||
// .listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.secondary.opacity(0.2))
|
|
||||||
.padding(
|
|
||||||
EdgeInsets(
|
|
||||||
top: 4,
|
|
||||||
leading: 8,
|
|
||||||
bottom: 4,
|
|
||||||
trailing: 8
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.carousel)
|
|
||||||
// .navigationTitle("Workouts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WorkoutCardView: View {
|
|
||||||
let workout: Workout
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
// Split icon
|
|
||||||
if let split = workout.split {
|
|
||||||
Image(systemName: split.systemImage)
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundStyle(split.getColor())
|
|
||||||
} else {
|
|
||||||
Image(systemName: "dumbbell.fill")
|
|
||||||
.font(.system(size: 24))
|
|
||||||
.foregroundStyle(.gray)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VStack(alignment: .leading, spacing: 4) {
|
|
||||||
// Split name
|
|
||||||
Text(workout.split?.name ?? "Workout")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
|
|
||||||
// Workout status
|
|
||||||
Text(workout.status?.name ?? "Not Started")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.accentColor)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Spacer()
|
|
||||||
}
|
|
||||||
// .padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WorkoutDetailView: View {
|
|
||||||
let workout: Workout
|
|
||||||
|
|
||||||
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)
|
|
||||||
} label: {
|
|
||||||
WorkoutLogCardView(log: log)
|
|
||||||
}
|
|
||||||
.listRowBackground(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.secondary.opacity(0.2))
|
|
||||||
.padding(
|
|
||||||
EdgeInsets(
|
|
||||||
top: 4,
|
|
||||||
leading: 8,
|
|
||||||
bottom: 4,
|
|
||||||
trailing: 8
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.carousel)
|
|
||||||
} else {
|
|
||||||
Text("No exercises in this workout")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WorkoutLogCardView: View {
|
|
||||||
let log: WorkoutLog
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
// Exercise name
|
|
||||||
Text(log.exerciseName)
|
|
||||||
.font(.headline)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
// Status
|
|
||||||
Text(log.status?.name ?? "Not Started")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.accentColor)
|
|
||||||
|
|
||||||
// Sets, Reps, Weight
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Text("\(log.weight) lbs")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text("\(log.sets) × \(log.reps)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WorkoutLogDetailView: View {
|
|
||||||
let log: WorkoutLog
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
|
|
||||||
NavigationLink {
|
|
||||||
ExerciseProgressControlView(log: log)
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .center) {
|
|
||||||
Text(log.exerciseName)
|
|
||||||
.font(.title)
|
|
||||||
.lineLimit(1) // Ensures it stays on one line
|
|
||||||
.minimumScaleFactor(0.5) // Scales down to 50% if needed
|
|
||||||
.layoutPriority(1) // Prioritize this view in tight layouts
|
|
||||||
|
|
||||||
HStack (alignment: .bottom) {
|
|
||||||
Text("\(log.weight)")
|
|
||||||
Text( "lbs")
|
|
||||||
.fontWeight(.light)
|
|
||||||
.padding([.trailing], 10)
|
|
||||||
|
|
||||||
Text("\(log.sets)")
|
|
||||||
Text("×")
|
|
||||||
.fontWeight(.light)
|
|
||||||
Text("\(log.reps)")
|
|
||||||
// Text("\(log.weight) lbs × \(log.sets) × \(log.reps)")
|
|
||||||
}
|
|
||||||
.font(.title3)
|
|
||||||
.lineLimit(1) // Ensures it stays on one line
|
|
||||||
.minimumScaleFactor(0.5) // Scales down to 50% if needed
|
|
||||||
.layoutPriority(1) // Prioritize this view in tight layouts
|
|
||||||
|
|
||||||
Text(log.status?.name ?? "Not Started")
|
|
||||||
.foregroundStyle(Color.accentColor)
|
|
||||||
|
|
||||||
Text("Tap to start")
|
|
||||||
.foregroundStyle(Color.accentColor)
|
|
||||||
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statusColor(for status: WorkoutStatus?) -> Color {
|
|
||||||
guard let status = status else { return .secondary }
|
|
||||||
|
|
||||||
switch status {
|
|
||||||
case .notStarted:
|
|
||||||
return .secondary
|
|
||||||
case .inProgress:
|
|
||||||
return .blue
|
|
||||||
case .completed:
|
|
||||||
return .green
|
|
||||||
case .skipped:
|
|
||||||
return .red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// let container = AppContainer.preview
|
|
||||||
// let split = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional")
|
|
||||||
// let workout1 = Workout(start: Date(), end: nil, split: split)
|
|
||||||
// workout1.status = .inProgress
|
|
||||||
//
|
|
||||||
// let split2 = Split(name: "Lower Body", color: "red", systemImage: "figure.run")
|
|
||||||
// let workout2 = Workout(start: Date().addingTimeInterval(-3600), end: nil, split: split2)
|
|
||||||
// workout2.status = .notStarted
|
|
||||||
//
|
|
||||||
// return ActiveWorkoutListView(workouts: [workout1, workout2])
|
|
||||||
// .modelContainer(container)
|
|
||||||
//}
|
|
@ -1,334 +0,0 @@
|
|||||||
//
|
|
||||||
// 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(
|
|
||||||
title: state.isRest ? "Resting..." : state.isDone ? "Done" : "Set \(currentStateIndex)",
|
|
||||||
isRest: state.isRest,
|
|
||||||
isDone: state.isDone,
|
|
||||||
elapsedSeconds: elapsedSeconds,
|
|
||||||
onComplete: {
|
|
||||||
//
|
|
||||||
})
|
|
||||||
// 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 title: String
|
|
||||||
let isRest: Bool
|
|
||||||
let isDone: Bool
|
|
||||||
let elapsedSeconds: Int
|
|
||||||
let onComplete: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
// Title based on state
|
|
||||||
|
|
||||||
Text(title)
|
|
||||||
.font(.title)
|
|
||||||
|
|
||||||
// Timer display
|
|
||||||
Text(timeFormatted)
|
|
||||||
.font(.system(size: 48, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundStyle(isRest ? .orange : .accentColor)
|
|
||||||
|
|
||||||
// Only show Done button and countdown for the final state
|
|
||||||
if isDone {
|
|
||||||
// Done button
|
|
||||||
Button(action: onComplete) {
|
|
||||||
Text("Done in \(10 - elapsedSeconds)s")
|
|
||||||
.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)"
|
|
||||||
// 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)
|
|
||||||
//}
|
|
Reference in New Issue
Block a user