From 97ecbcc6f4a0451421bcf59beba058a33e18fec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Thu, 17 Oct 2024 14:41:14 +0200 Subject: [PATCH] create ER diagram, refactor to conform to diagram, simplify session management --- WorkoutsPlus.xcodeproj/project.pbxproj | 2 + .../ActiveWorkout/ActiveWorkoutSession.swift | 102 +++++------------- .../ActiveWorkoutSessionControls.swift | 12 +-- WorkoutsPlus/Components/DurationPicker.swift | 25 ----- .../Components/NumberOnlyTextField.swift | 59 ++++++++++ WorkoutsPlus/Components/RestListItem.swift | 25 +++++ WorkoutsPlus/Components/SetListItem.swift | 4 +- WorkoutsPlus/Configuration/Defaults.swift | 5 +- WorkoutsPlus/ContentView.swift | 36 +++++-- WorkoutsPlus/Debug/DebugList.swift | 2 +- WorkoutsPlus/Home/Miniplayer.swift | 8 +- WorkoutsPlus/Home/WorkoutLog.swift | 6 +- WorkoutsPlus/Models/Workout.swift | 6 ++ WorkoutsPlus/Models/WorkoutItem.swift | 20 ++-- WorkoutsPlus/Models/WorkoutSession.swift | 31 +++--- WorkoutsPlus/Models/WorkoutSessionItem.swift | 25 +++++ WorkoutsPlus/README.md | 1 + WorkoutsPlus/Settings/Settings.swift | 22 +++- WorkoutsPlus/Workout/WorkoutDetail.swift | 87 ++++++++++++--- WorkoutsPlus/Workout/WorkoutLibrary.swift | 79 ++++++++------ WorkoutsPlus/Workout/WorkoutListItem.swift | 23 ++-- WorkoutsPlus/WorkoutsPlusApp.swift | 4 +- WorkoutsPlus/er-diagram.md | 36 +++++++ 23 files changed, 394 insertions(+), 226 deletions(-) delete mode 100644 WorkoutsPlus/Components/DurationPicker.swift create mode 100644 WorkoutsPlus/Components/NumberOnlyTextField.swift create mode 100644 WorkoutsPlus/Components/RestListItem.swift create mode 100644 WorkoutsPlus/Models/WorkoutSessionItem.swift create mode 100644 WorkoutsPlus/er-diagram.md diff --git a/WorkoutsPlus.xcodeproj/project.pbxproj b/WorkoutsPlus.xcodeproj/project.pbxproj index 27bb659..7b433f3 100644 --- a/WorkoutsPlus.xcodeproj/project.pbxproj +++ b/WorkoutsPlus.xcodeproj/project.pbxproj @@ -195,6 +195,8 @@ ); mainGroup = 3F595B132C67A6AB00C4544B; minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); preferredProjectObjectVersion = 77; productRefGroup = 3F595B1D2C67A6AB00C4544B /* Products */; projectDirPath = ""; diff --git a/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSession.swift b/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSession.swift index 5fc1aec..c814b47 100644 --- a/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSession.swift +++ b/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSession.swift @@ -8,50 +8,21 @@ struct ActiveWorkoutSession: View { @State var isTimerRunning: Bool = true @Query(sort: \WorkoutSession.name) var workoutSessions: [WorkoutSession] - @State var activeWorkoutSession: WorkoutSession? - @Default(\.activeWorkoutSessionId) var activeWorkoutSessionId + @Binding var activeWorkoutSession: WorkoutSession? @Query(sort: \Workout.name) var workouts: [Workout] - @State var activeWorkout: Workout? - @Default(\.activeWorkoutId) var activeWorkoutId + @State var activeWorkout: Workout var body: some View { VStack { List { - Section(header: Text("Workout"), footer: Text(activeWorkoutSession?.creationDate.ISO8601Format() ?? "Unknown Date")) { - NavigationLink(destination: { - ItemPicker(selectedItem: $activeWorkout, items: workouts) - }) { - Text(activeWorkout?.name ?? "Select Workout") - } - .onChange(of: activeWorkout) { _, newWorkout in - if let newWorkout { - activeWorkoutId = newWorkout.id.uuidString - activeWorkoutSession?.workout = newWorkout - } - } + Section(header: Text("Workout")) { + Text(activeWorkout.name) } - - if let activeWorkout { - Section(header: Text("Exercises")) { - ForEach(getActiveWorkoutItems(activeWorkout: activeWorkout)) { workoutItem in - ActiveWorkoutSessionListItem(workoutItem: workoutItem) - } + Section(header: Text("Exercises")) { + ForEach(getActiveWorkoutItems(activeWorkout: activeWorkout)) { workoutItem in + ActiveWorkoutSessionListItem(workoutItem: workoutItem) } - } else { - ContentUnavailableView { - Label("Select a Workout", systemImage: "arrow.up") - } - } - } - // MARK: Workout Controls - if (isWorkingOut) { - if activeWorkoutSession != nil { - ActiveWorkoutSessionControls( - session: Binding( - get: { self.activeWorkoutSession! }, - set: { self.activeWorkoutSession = $0 } - )) } } } @@ -70,12 +41,11 @@ struct ActiveWorkoutSession: View { .bold() .fontDesign(.rounded) .tint(.red) - } else { + } + else { Button(action: { isWorkingOut = true - if let activeWorkout { - activeWorkoutSession?.start(with: activeWorkout) - } + activeWorkoutSession = activeWorkout.start() }) { HStack { Image(systemName: "play.fill") @@ -87,25 +57,6 @@ struct ActiveWorkoutSession: View { .tint(.green) } } - .onAppear { - if let activeWorkoutSession = getItem(from: workoutSessions, by: activeWorkoutSessionId) { - self.activeWorkoutSession = activeWorkoutSession - if let workout = getItem(from: workouts, by: activeWorkoutId) { - self.activeWorkout = workout - } - } else { - createNewWorkoutSession() - } - } - } - - private func createNewWorkoutSession() { - let newWorkoutSession = WorkoutSession() - activeWorkoutSessionId = newWorkoutSession.id.uuidString - if let selectedWorkout = getItem(from: workouts, by: activeWorkoutId) { - newWorkoutSession.workout = selectedWorkout - } - self.activeWorkoutSession = newWorkoutSession } private func getActiveWorkoutItems(activeWorkout: Workout?) -> [WorkoutItem] { @@ -120,10 +71,12 @@ struct ActiveWorkoutSession: View { } } -#Preview("RR Selected") { - @Previewable @State var activeWorkout = Workout.sampleData.first! +#Preview { + @Previewable @State var activeWorkoutSession: WorkoutSession? + @Previewable @State var workout = Workout.sampleData.first! + NavigationStack { - ActiveWorkoutSession(activeWorkout: activeWorkout) + ActiveWorkoutSession(activeWorkoutSession: $activeWorkoutSession, activeWorkout: workout) } .onAppear { Defaults.shared.isWorkingOut = false @@ -131,18 +84,13 @@ struct ActiveWorkoutSession: View { .modelContainer(SampleData.shared.modelContainer) } -#Preview("No Workout Selected") { - NavigationStack { - ActiveWorkoutSession() - } - .onAppear { - Defaults.shared.isWorkingOut = false - } - .modelContainer(SampleData.shared.modelContainer) -} - -#Preview("No Workout Data available") { - NavigationStack { - ActiveWorkoutSession() - } -} +//#Preview("Empty modelContainer") { +// @Previewable @State var activeWorkoutSession: WorkoutSession? +// +// NavigationStack { +// ActiveWorkoutSession(activeWorkoutSession: $activeWorkoutSession) +// } +// .onAppear { +// Defaults.shared.isWorkingOut = false +// } +//} diff --git a/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSessionControls.swift b/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSessionControls.swift index b2d5382..e2d6363 100644 --- a/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSessionControls.swift +++ b/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSessionControls.swift @@ -63,8 +63,7 @@ struct ActiveWorkoutSessionControls: View { } #Preview("isWorkingOut = true") { - @Previewable @State var activeWorkoutSession = WorkoutSession() - activeWorkoutSession.workout = Workout.sampleData.first! + @Previewable @State var activeWorkoutSession = WorkoutSession(start: Workout.sampleData.first!) // For some reason the return keyword is required here to avoid the error "Type of expression is ambiguous without a type annotation" return ActiveWorkoutSessionControls(session: $activeWorkoutSession) @@ -72,12 +71,3 @@ struct ActiveWorkoutSessionControls: View { Defaults.shared.isWorkingOut = true } } - -#Preview("isWorkingOut = false") { - @Previewable @State var activeWorkoutSession = WorkoutSession() - - return ActiveWorkoutSessionControls(session: $activeWorkoutSession) - .onAppear() { - Defaults.shared.isWorkingOut = false - } -} diff --git a/WorkoutsPlus/Components/DurationPicker.swift b/WorkoutsPlus/Components/DurationPicker.swift deleted file mode 100644 index c5a1566..0000000 --- a/WorkoutsPlus/Components/DurationPicker.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// DurationPicker.swift -// WorkoutsPlus -// -// Created by Felix Förtsch on 21.09.24. -// - -import SwiftUI - -struct DurationPicker: View { - @Binding var duration: String - - var body: some View { -// DatePicker("Duration", selection: $duration, displayedComponents: [.hourAndMinute, .date]) - VStack { - TextField("Duration in seconds", text: $duration) - .keyboardType(.numberPad) - } - } -} - -#Preview { - @Previewable @State var duration = "120" - DurationPicker(duration: $duration) -} diff --git a/WorkoutsPlus/Components/NumberOnlyTextField.swift b/WorkoutsPlus/Components/NumberOnlyTextField.swift new file mode 100644 index 0000000..0f8614e --- /dev/null +++ b/WorkoutsPlus/Components/NumberOnlyTextField.swift @@ -0,0 +1,59 @@ +// +// DurationPicker.swift +// WorkoutsPlus +// +// Created by Felix Förtsch on 06.10.24. +// + +import SwiftUI + +struct NumbersOnlyTextField: View { + @Binding var value: Double + @FocusState private var isFocused: Bool + + var placeholder = "00:00" + var formatString = "%.0f" + + var body: some View { + VStack { + HStack { + ZStack { + Rectangle() + .fill(.quaternary) + .cornerRadius(8) + .frame(width: placeholderWidth()) + + TextField(placeholder, text: Binding( + get: { String(format: formatString, value) }, + set: { newValue in + if let doubleValue = Double(newValue.filter { "0123456789.".contains($0) }) { + value = doubleValue + } + } + )) + .keyboardType(.decimalPad) + .frame(width: placeholderWidth()) + .multilineTextAlignment(.center) + .focused($isFocused) + } + } + .onTapGesture { + isFocused = false + } + } + } + + private func placeholderWidth() -> CGFloat { + let font = UIFont.systemFont(ofSize: UIFont.systemFontSize) + let attributes = [NSAttributedString.Key.font: font] + let size = (placeholder as NSString).size(withAttributes: attributes) + return size.width + 20 + } +} + +#Preview { + @Previewable @State var value: Double = 0 + List { + NumbersOnlyTextField(value: $value) + } +} diff --git a/WorkoutsPlus/Components/RestListItem.swift b/WorkoutsPlus/Components/RestListItem.swift new file mode 100644 index 0000000..c986aee --- /dev/null +++ b/WorkoutsPlus/Components/RestListItem.swift @@ -0,0 +1,25 @@ +// +// RestListItem.swift +// WorkoutsPlus +// +// Created by Felix Förtsch on 02.10.24. +// + +import SwiftUI + +struct RestListItem: View { + var restTime: TimeInterval = 15 + + var body: some View { + HStack { + Image(systemName: "pause") + Text("Rest for \(Int(restTime.rounded())) seconds") + .font(.system(size: 14, weight: .regular, design: .default)) + .textCase(.uppercase) + } + } +} + +#Preview { + RestListItem() +} diff --git a/WorkoutsPlus/Components/SetListItem.swift b/WorkoutsPlus/Components/SetListItem.swift index 8badf2b..564dacf 100644 --- a/WorkoutsPlus/Components/SetListItem.swift +++ b/WorkoutsPlus/Components/SetListItem.swift @@ -14,7 +14,7 @@ struct SetListItem: View { var body: some View { HStack { HStack { - Text(String(set.reps)) + Text(String(set.plannedReps)) .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) .frame(width: 20, height: 10) @@ -27,7 +27,7 @@ struct SetListItem: View { .fontWeight(.bold) Spacer() Stepper( - value: $set.reps, + value: $set.plannedReps, in: 0...100, step: 1 ) {} diff --git a/WorkoutsPlus/Configuration/Defaults.swift b/WorkoutsPlus/Configuration/Defaults.swift index b119dee..5bdb768 100644 --- a/WorkoutsPlus/Configuration/Defaults.swift +++ b/WorkoutsPlus/Configuration/Defaults.swift @@ -22,8 +22,9 @@ public class Defaults: ObservableObject { @AppStorage("sets") public var sets = 8 @AppStorage("reps") public var reps = 8 - @AppStorage("activeWorkoutSessionId") public var activeWorkoutSessionId: String = "" - @AppStorage("activeWorkoutId") public var activeWorkoutId: String = "" + // TODO: Maybe store session and workout ids to recover from app close + // @AppStorage("activeWorkoutSessionId") public var activeWorkoutSessionId: String = "" + // @AppStorage("activeWorkoutId") public var activeWorkoutId: String = "" public static let shared = Defaults() } diff --git a/WorkoutsPlus/ContentView.swift b/WorkoutsPlus/ContentView.swift index 725e0b3..d54985f 100644 --- a/WorkoutsPlus/ContentView.swift +++ b/WorkoutsPlus/ContentView.swift @@ -14,6 +14,8 @@ struct ContentView: View { @Default(\.isOnboarding) var isOnboarding @Default(\.isWorkingOut) var isWorkingOut + @State var activeWorkoutSession: WorkoutSession? + var body: some View { NavigationStack { List { @@ -42,9 +44,14 @@ struct ContentView: View { // .frame(height: 200) } } - Section { - NavigationLink(destination: ActiveWorkoutSession()) { - if (isWorkingOut) { + // MARK: Workout Controls + if let activeWorkoutSession { + Section { + NavigationLink( + destination: ActiveWorkoutSession( + activeWorkoutSession: $activeWorkoutSession, + activeWorkout: activeWorkoutSession.workout) + ) { HStack { Label("Back to Session", systemImage: "memories") .symbolEffect(.rotate) @@ -52,14 +59,25 @@ struct ContentView: View { .font(.subheadline) .foregroundStyle(.secondary) } - } else { - Label("Start Workout Session", systemImage: "play") } + ActiveWorkoutSessionControls( + session: Binding( + get: { self.activeWorkoutSession! }, + set: { self.activeWorkoutSession = $0 } + )) } + } else { + Section(footer: Text("Starts the next planned workout immediately.")) { + Label("Quick Start", systemImage: "play") + } + } + + + Section { NavigationLink(destination: WorkoutLog()) { Label("Workout Log", systemImage: "calendar.badge.clock") } - NavigationLink(destination: WorkoutLibrary()) { + NavigationLink(destination: WorkoutLibrary(activeWorkoutSession: $activeWorkoutSession)) { Label("Workouts", systemImage: "figure.run.square.stack") } NavigationLink(destination: ExerciseLibrary()) { @@ -67,9 +85,11 @@ struct ContentView: View { } } Section { - Label("Add Food", systemImage: "plus") Label("Food Log", systemImage: "list.bullet.clipboard") - Label("Food Library", systemImage: "fork.knife") + NavigationLink(destination: Label("Add Food", systemImage: "plus")) { + Label("Food Library", systemImage: "fork.knife") + } + } Section { NavigationLink(destination: Settings()) { diff --git a/WorkoutsPlus/Debug/DebugList.swift b/WorkoutsPlus/Debug/DebugList.swift index 4be5393..0fd898e 100644 --- a/WorkoutsPlus/Debug/DebugList.swift +++ b/WorkoutsPlus/Debug/DebugList.swift @@ -34,7 +34,7 @@ struct DebugList: View { Section(header: Text("WorkoutItems")) { ForEach(workoutItems) { workoutItem in VStack(alignment: .leading) { - Text("\(workoutItem.name), pos: \(workoutItem.position), reps: \(workoutItem.reps)") + Text("\(workoutItem.name), pos: \(workoutItem.position), reps: \(workoutItem.plannedReps)") Text(workoutItem.id.uuidString) .font(.system(size: 12, weight: .bold)) .fontDesign(.monospaced) diff --git a/WorkoutsPlus/Home/Miniplayer.swift b/WorkoutsPlus/Home/Miniplayer.swift index 3a85e28..21d945a 100644 --- a/WorkoutsPlus/Home/Miniplayer.swift +++ b/WorkoutsPlus/Home/Miniplayer.swift @@ -13,7 +13,7 @@ import SwiftData // .safeAreaInset(edge: .bottom) { } struct MiniPlayer: View { @Default(\.isWorkingOut) var isWorkingOut - @Default(\.activeWorkoutId) var selectedWorkoutId + // @Query private var workouts: [Workout] @@ -61,9 +61,9 @@ struct MiniPlayer: View { Text("Start Workout") .font(.headline) // TODO: Replace this with the upcoming/planned workout - Text(selectedWorkoutId) - .font(.subheadline) - .foregroundColor(.secondary) +// Text(selectedWorkoutId) +// .font(.subheadline) +// .foregroundColor(.secondary) } Spacer() Button(action: { diff --git a/WorkoutsPlus/Home/WorkoutLog.swift b/WorkoutsPlus/Home/WorkoutLog.swift index 0dd12f9..37f3b7a 100644 --- a/WorkoutsPlus/Home/WorkoutLog.swift +++ b/WorkoutsPlus/Home/WorkoutLog.swift @@ -74,11 +74,9 @@ struct WorkoutLog: View { Section(header: Text("Workout Sessions")) { ForEach(workoutSessions) { session in VStack(alignment: .leading) { - Text(session.creationDate.ISO8601Format()) - if let workout = session.workout { - Text(workout.name) + Text(session.startDate.ISO8601Format()) + Text(session.workout.name) .font(.subheadline) - } } }.onDelete(perform: deleteWorkoutSession) } diff --git a/WorkoutsPlus/Models/Workout.swift b/WorkoutsPlus/Models/Workout.swift index 5ec6440..a4181b0 100644 --- a/WorkoutsPlus/Models/Workout.swift +++ b/WorkoutsPlus/Models/Workout.swift @@ -17,6 +17,8 @@ final class Workout: Nameable, Hashable { // The name of my workout is: Recommended Routine, My Marathon Workout @Attribute(.unique) var name: String + var defaultRestTime: TimeInterval = 60 + var useDefaultRestTime: Bool = false // Icon var workoutIconSystemName = "figure.run" @@ -33,6 +35,10 @@ final class Workout: Nameable, Hashable { self.name = name } + func start() -> WorkoutSession { + return WorkoutSession(start: self) + } + func add(workoutItem: WorkoutItem) { self.workoutItems.append(workoutItem) updateWorkoutItemsPositions() diff --git a/WorkoutsPlus/Models/WorkoutItem.swift b/WorkoutsPlus/Models/WorkoutItem.swift index 92d52f5..d34de3e 100644 --- a/WorkoutsPlus/Models/WorkoutItem.swift +++ b/WorkoutsPlus/Models/WorkoutItem.swift @@ -41,9 +41,10 @@ final class WorkoutItem: Nameable, Positionable { var position: Int = 0 var set: [WorkoutItem] = [] - var exercise: Exercise? // Do Push-up | Run Marathon - var reps: Int // 8 times | 1 time - var value: Double? // With 10 | 42,187 + // Exercise has to be optional to allow Rest and Set to be a WorkoutItem (without being an Exercise). + var exerciseData: Exercise? // Do Push-up | Run Marathon + var plannedReps: Int // 8 times | 1 time + var plannedValue: Double // With 10 | 42,187 var metric: ExerciseMetric? // kg (weight) | km (distance) enum WorkoutItemType: Codable { @@ -53,15 +54,15 @@ final class WorkoutItem: Nameable, Positionable { } init(_ exercise: Exercise) { - self.exercise = exercise + self.exerciseData = exercise self.workoutItemType = .exercise // Push-up self.name = exercise.name // 8x - self.reps = 1 + self.plannedReps = 1 // 0 - self.value = 0 + self.plannedValue = 0 // kg self.metric = exercise.metric } @@ -69,7 +70,8 @@ final class WorkoutItem: Nameable, Positionable { init(set: [WorkoutItem] = []) { self.workoutItemType = .set self.name = "Set" - self.reps = 3 + self.plannedReps = 3 + self.plannedValue = 0 set.forEach(addChild) } @@ -82,8 +84,8 @@ final class WorkoutItem: Nameable, Positionable { init(rest: Double) { self.workoutItemType = .rest self.name = "Rest" - self.reps = 1 - self.value = rest + self.plannedReps = 1 + self.plannedValue = rest self.metric = .time } } diff --git a/WorkoutsPlus/Models/WorkoutSession.swift b/WorkoutsPlus/Models/WorkoutSession.swift index 1bddbb6..498b1d3 100644 --- a/WorkoutsPlus/Models/WorkoutSession.swift +++ b/WorkoutsPlus/Models/WorkoutSession.swift @@ -13,22 +13,24 @@ final class WorkoutSession: Nameable { var id = UUID() var name = "" // The Workout is what *should* happen - var workout: Workout? { + var workout: Workout { didSet { self.name = workout?.name ?? "Unknown Workout" } } + init(start with: Workout) { + self.workout = with + } + // State // var isPaused: Bool // var isCancelled: Bool // var isDeleted: Bool // var isSynced: Bool - // Time - var creationDate = Date.now // My workout session started at: - var startDate: Date? = nil + var startDate: Date = Date.now // My workout session was completed at: var stopDate: Date? = nil // My workout session took me x seconds. @@ -39,17 +41,15 @@ final class WorkoutSession: Nameable { // Exercise Progress var currentExercise = 0 - init () { } - func isActive() -> Bool { - return startDate != nil && stopDate == nil + return stopDate == nil } // MARK: -- Workout Controls - func start(with workout: Workout) { - self.workout = workout - startDate = Date.now - } + // func start(with workout: Workout) { + // self.workout = workout + // startDate = Date.now + // } func pause() { // TODO: Implement proper Pause @@ -57,21 +57,18 @@ final class WorkoutSession: Nameable { // Call stop() to terminate the workout. func stop() { - guard let startDate = startDate else { return } isCompleted = true stopDate = Date.now duration = stopDate!.timeIntervalSince(startDate) } func prevExercise() { - guard workout != nil else { return } if currentExercise > 0 { currentExercise -= 1 } } func nextExercise() { - guard let workout = workout else { return } if currentExercise < workout.getWorkoutItems().count - 1 { currentExercise += 1 } @@ -79,7 +76,6 @@ final class WorkoutSession: Nameable { // MARK: -- Workout Information func getFormattedDuration() -> String { - guard let startDate = startDate else { return "00:00:00" } let elapsedTime = Date.now.timeIntervalSince(startDate) let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] @@ -88,7 +84,6 @@ final class WorkoutSession: Nameable { } func getTotalExerciseCount() -> Double { - guard let workout = workout else { return 0 } return Double(workout.getWorkoutItems().count) } @@ -101,12 +96,10 @@ final class WorkoutSession: Nameable { } func getCurrentExerciseName() -> String { - guard let workout = workout else { return "Unknown Workout" } return workout.getWorkoutItems()[Int(currentExercise)].name } func getCurrentExerciseMetric() -> String { - guard let workout = workout else { return "Unknown Workout" } - return String(workout.getWorkoutItems()[Int(currentExercise)].reps) + return String(workout.getWorkoutItems()[Int(currentExercise)].plannedReps) } } diff --git a/WorkoutsPlus/Models/WorkoutSessionItem.swift b/WorkoutsPlus/Models/WorkoutSessionItem.swift new file mode 100644 index 0000000..b90fd48 --- /dev/null +++ b/WorkoutsPlus/Models/WorkoutSessionItem.swift @@ -0,0 +1,25 @@ +// +// WorkoutSessionItem.swift +// WorkoutsPlus +// +// Created by Felix Förtsch on 15.10.24. +// + +import Foundation +import SwiftData + +@Model +final class WorkoutSessionItem { + var id = UUID() + + var workoutSession: WorkoutSession + + var exerciseData: WorkoutItem + var actualReps: Int? + var actualValue: Double? + + init(workoutSession: WorkoutSession, planned: WorkoutItem) { + self.workoutSession = workoutSession + self.exerciseData = planned + } +} diff --git a/WorkoutsPlus/README.md b/WorkoutsPlus/README.md index 314bc1c..4d11254 100644 --- a/WorkoutsPlus/README.md +++ b/WorkoutsPlus/README.md @@ -13,6 +13,7 @@ - TODO: Russian System (333 3333 33333, 444 4444 444444, usw -> https://www.youtube.com/watch?v=GZmtjlPTU1g) abbilden (ob System flexibel genug ist) - Loops +- Special Section: “Special Exercises” → Squat, Grease the Groove ## Trainingspläne diff --git a/WorkoutsPlus/Settings/Settings.swift b/WorkoutsPlus/Settings/Settings.swift index 6ffe2e3..eb581ba 100644 --- a/WorkoutsPlus/Settings/Settings.swift +++ b/WorkoutsPlus/Settings/Settings.swift @@ -22,12 +22,12 @@ struct Settings: View { Section( header: Text("User"), footer: Text("Trainer Mode enables you to add and manage trainees.")) { - Text(userId) - TextField("name", text: $userId) - Toggle(isOn: $isTrainerMode) { - Text("Trainer Mode") + Text(userId) + TextField("name", text: $userId) + Toggle(isOn: $isTrainerMode) { + Text("Trainer Mode") + } } - } Section(header: Text("Defaults")) { StepperListItem(itemName: "Rep Count", itemValue: $reps) } @@ -41,6 +41,18 @@ struct Settings: View { } Button("Reset App", role: .destructive, action: resetApp) } + + Section { + NavigationLink("Credits") { + Text( + """ + # Software Components + ## Duration Picker + https://github.com/mac-gallagher/DurationPicker + """ + ) + } + } } .navigationTitle("Settings") } diff --git a/WorkoutsPlus/Workout/WorkoutDetail.swift b/WorkoutsPlus/Workout/WorkoutDetail.swift index 66691a9..2edaf72 100644 --- a/WorkoutsPlus/Workout/WorkoutDetail.swift +++ b/WorkoutsPlus/Workout/WorkoutDetail.swift @@ -12,19 +12,47 @@ struct WorkoutDetail: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext + @Default(\.isWorkingOut) var isWorkingOut + + @Binding var activeWorkoutSession: WorkoutSession? + @State var workout: Workout @State private var isPresentingWorkoutItemLibrarySheet = false + var numberFormatter: NumberFormatter { + let f = NumberFormatter() + f.numberStyle = .decimal + f.maximumFractionDigits = 0 + return f + } + + @State var value: Decimal? = 60 + @State var textFieldIsActive: Bool = false + + @State var defaultRestTime: Double = 45 + + @State private var selectedMinutes = 0 + @State private var selectedSeconds = 0 + var body: some View { List { - Section(header: Text("Name & Icon")) { - NavigationLink(destination: WorkoutIconSelector(workout: workout)) { - TextField("Workout Name", text: $workout.name) - Image(systemName: workout.workoutIconSystemName) - .scaledToFit() - .foregroundStyle(workout.workoutIconColorName.color) - } - } + Section( + content: { + NavigationLink(destination: WorkoutIconSelector(workout: workout)) { + TextField("Workout Name", text: $workout.name) + Image(systemName: workout.workoutIconSystemName) + .scaledToFit() + .foregroundStyle(workout.workoutIconColorName.color) + } + HStack { + Text("Default Rest Time (min:s)") + Spacer() + // TODO: This is f-in horrible. But I need a break rn. + NumbersOnlyTextField(value: $defaultRestTime) + } + }, + header: { Text("Name & Icon") }, + footer: { Text("Setting a Default Rest Time inserts the time between each exercise.") }) Section( header: Text("Exercises"), footer: Text("Drag and drop to re-arrange or swipe to delete exercises.")) { @@ -34,14 +62,41 @@ struct WorkoutDetail: View { .onDelete(perform: deleteWorkoutItem) .onMove(perform: move) .environment(\.editMode, .constant(.active)) // Always active drag mode - AddItemButton(label: "Exercise", action: presentWorkoutItemLibrarySheet) - } + AddItemButton(label: "Exercise", action: presentWorkoutItemLibrarySheet) + } } .navigationTitle("\(workout.name)") .toolbar { // TODO: Add proper Sharing for workouts. - ToolbarItem() { ShareLink(item: URL(filePath: "felixfoertsch.de")!) } - ToolbarItem() { EditButton() } +// ToolbarItem() { ShareLink(item: URL(filePath: "felixfoertsch.de")!) } + if (isWorkingOut) { + Button(action: { + isWorkingOut = false + activeWorkoutSession?.stop() + }) { + HStack { + Image(systemName: "stop.fill") + Text("Stop") + } + } + .bold() + .fontDesign(.rounded) + .tint(.red) + } + else { + Button(action: { + isWorkingOut = true + activeWorkoutSession = workout.start() + }) { + HStack { + Image(systemName: "play.fill") + Text("Start") + } + } + .bold() + .fontDesign(.rounded) + .tint(.green) + } } .sheet(isPresented: $isPresentingWorkoutItemLibrarySheet) { WorkoutItemLibrarySheet(workout: workout) @@ -79,15 +134,19 @@ struct WorkoutDetail: View { } #Preview { + @Previewable @State var activeWorkoutSession: WorkoutSession? + NavigationStack { - WorkoutDetail(workout: Workout.sampleData.first!) + WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: Workout.sampleData.first!) .modelContainer(SampleData.shared.modelContainer) } } #Preview("Debug") { + @Previewable @State var activeWorkoutSession: WorkoutSession? + TabView { - WorkoutDetail(workout: Workout.sampleData.first!) + WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: Workout.sampleData.first!) .tabItem { Image(systemName: "figure.run.square.stack") Text("Workouts") diff --git a/WorkoutsPlus/Workout/WorkoutLibrary.swift b/WorkoutsPlus/Workout/WorkoutLibrary.swift index 0ed1ad9..4a2df1a 100644 --- a/WorkoutsPlus/Workout/WorkoutLibrary.swift +++ b/WorkoutsPlus/Workout/WorkoutLibrary.swift @@ -10,6 +10,8 @@ import SwiftData struct WorkoutLibrary: View { @Environment(\.modelContext) private var modelContext + @Binding var activeWorkoutSession: WorkoutSession? + @Query(sort: \Workout.name) private var workouts: [Workout] @State private var newWorkout: Workout = Workout(name: "") @@ -25,41 +27,48 @@ struct WorkoutLibrary: View { } var body: some View { - - Group { - List { - ForEach(filteredItems) { workout in - NavigationLink { - WorkoutDetail(workout: workout) - } label: { - Image(systemName: workout.workoutIconSystemName) - .foregroundStyle(workout.workoutIconColorName.color) - Text(workout.name) + + Group { + List { + ForEach(filteredItems) { workout in + NavigationLink { + WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: workout) + } label: { + Button(action: { + activeWorkoutSession = workout.start() + }) { + Image(systemName: "play.fill") + .foregroundStyle(.green) } + // TODO: Decide if icon should appear here/create custom view + // Image(systemName: workout.workoutIconSystemName) + // .foregroundStyle(workout.workoutIconColorName.color) + Text(workout.name) } - .onDelete(perform: deleteWorkout) - if filteredItems.isEmpty { - ContentUnavailableView.search(text: searchText) - } - if isAddingWorkout { - // TODO: On tap-out of the text field, it should lose focus - TextField("New Workout", text: $newWorkoutName, onCommit: { - save(workout: newWorkout) - }) - .textInputAutocapitalization(.words) - .focused($isInputFieldFocused) - } - // TODO: When pressing the button again, it should check if something is being added already and if yes, save it. - AddItemButton(label: "Workout", action: addWorkout) } - .searchable(text: $searchText) - } - .navigationTitle("Workouts") - .toolbar { - ToolbarItem() { - EditButton() + .onDelete(perform: deleteWorkout) + if filteredItems.isEmpty { + ContentUnavailableView.search(text: searchText) } + if isAddingWorkout { + // TODO: On tap-out of the text field, it should lose focus + TextField("New Workout", text: $newWorkoutName, onCommit: { + save(workout: newWorkout) + }) + .textInputAutocapitalization(.words) + .focused($isInputFieldFocused) + } + // TODO: When pressing the button again, it should check if something is being added already and if yes, save it. + AddItemButton(label: "Workout", action: addWorkout) } + .searchable(text: $searchText) + } + .navigationTitle("Workouts") + .toolbar { + ToolbarItem() { + EditButton() + } + } } private func addWorkout() { @@ -93,16 +102,18 @@ struct WorkoutLibrary: View { } #Preview("With Sample Data") { + @Previewable @State var activeWorkoutSession: WorkoutSession? NavigationStack { - WorkoutLibrary() + WorkoutLibrary(activeWorkoutSession: $activeWorkoutSession) } - .modelContainer(SampleData.shared.modelContainer) + .modelContainer(SampleData.shared.modelContainer) } #Preview("Empty Database") { + @Previewable @State var activeWorkoutSession: WorkoutSession? NavigationStack { - WorkoutLibrary() + WorkoutLibrary(activeWorkoutSession: $activeWorkoutSession) } - .modelContainer(for: Workout.self, inMemory: true) + .modelContainer(for: Workout.self, inMemory: true) } diff --git a/WorkoutsPlus/Workout/WorkoutListItem.swift b/WorkoutsPlus/Workout/WorkoutListItem.swift index ff38cc2..2270d6e 100644 --- a/WorkoutsPlus/Workout/WorkoutListItem.swift +++ b/WorkoutsPlus/Workout/WorkoutListItem.swift @@ -7,6 +7,7 @@ import SwiftUI +// Recursive structure struct WorkoutListItem: View { var workout: Workout @State var workoutItem: WorkoutItem @@ -17,19 +18,20 @@ struct WorkoutListItem: View { } var body: some View { - Button(action: { - // workout.addExercise(from: exercise) - }) { - switch workoutItem.workoutItemType { - case .set: - SetListItem(workout: workout, set: $workoutItem) - case .exercise: - Text(workoutItem.name) - case .rest: + switch workoutItem.workoutItemType { + case .exercise: + HStack { Text(workoutItem.name) + Spacer() + NumbersOnlyTextField(value: $workoutItem.plannedValue) } + case .rest: + // Trivial base case for rest items + RestListItem(restTime: workoutItem.plannedValue) + case .set: + // Recursive case + SetListItem(workout: workout, set: $workoutItem) } - } } @@ -40,6 +42,7 @@ struct WorkoutListItem: View { WorkoutItem(Exercise("Squat")), WorkoutItem(Exercise("Squat"))])) WorkoutListItem(Workout(name: "RR"), WorkoutItem(Exercise("Push-ups"))) + WorkoutListItem(Workout(name: "RR"), WorkoutItem(rest: 15)) WorkoutListItem(Workout(name: "RR"), WorkoutItem(Exercise("Push-ups"))) WorkoutListItem(Workout(name: "RR"), WorkoutItem(Exercise("Sprint", .distance))) WorkoutListItem(Workout(name: "RR"), WorkoutItem(Exercise("Run", .time))) diff --git a/WorkoutsPlus/WorkoutsPlusApp.swift b/WorkoutsPlus/WorkoutsPlusApp.swift index 898774d..01a9a87 100644 --- a/WorkoutsPlus/WorkoutsPlusApp.swift +++ b/WorkoutsPlus/WorkoutsPlusApp.swift @@ -35,5 +35,7 @@ extension WorkoutsPlusApp { Equipment.self, Workout.self, WorkoutItem.self, - WorkoutSession.self]) + WorkoutSession.self, + WorkoutSessionItem.self, + ]) } diff --git a/WorkoutsPlus/er-diagram.md b/WorkoutsPlus/er-diagram.md new file mode 100644 index 0000000..bfa798a --- /dev/null +++ b/WorkoutsPlus/er-diagram.md @@ -0,0 +1,36 @@ +```mermaid +erDiagram + Exercise 1 .. 0+ Equipment : uses + Exercise 1 -- 0+ WorkoutItem : "provides data for" + Workout 1 .. 0+ WorkoutItem : collects + Workout 1 .. 0+ ViewModel : "provides data for" + WorkoutItem 1 -- 1 WorkoutSessionItem : "provides data for" + WorkoutSession 1 -- 1+ WorkoutSessionItem : collects + ViewModel 1 -- 1+ WorkoutSession : "creates, starts" + + Exercise { + string name + Equipment[] equipment + } + Workout { + string name + } + WorkoutSession { + Workout workout + time workoutSessionDuration + } + WorkoutItem { + Exercise exerciseData + int plannedReps + double plannedValue + } + WorkoutSessionItem { + WorkoutItem exerciseData + int actualReps + double actualValue + } + ViewModel { + Workout workout + WorkoutSession workoutSession + } +```