create ER diagram, refactor to conform to diagram, simplify session management
This commit is contained in:
@@ -195,6 +195,8 @@
|
||||
);
|
||||
mainGroup = 3F595B132C67A6AB00C4544B;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 3F595B1D2C67A6AB00C4544B /* Products */;
|
||||
projectDirPath = "";
|
||||
|
||||
@@ -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<Workout>(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
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
59
WorkoutsPlus/Components/NumberOnlyTextField.swift
Normal file
59
WorkoutsPlus/Components/NumberOnlyTextField.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
25
WorkoutsPlus/Components/RestListItem.swift
Normal file
25
WorkoutsPlus/Components/RestListItem.swift
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
25
WorkoutsPlus/Models/WorkoutSessionItem.swift
Normal file
25
WorkoutsPlus/Models/WorkoutSessionItem.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: "")
|
||||
@@ -26,40 +28,47 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -35,5 +35,7 @@ extension WorkoutsPlusApp {
|
||||
Equipment.self,
|
||||
Workout.self,
|
||||
WorkoutItem.self,
|
||||
WorkoutSession.self])
|
||||
WorkoutSession.self,
|
||||
WorkoutSessionItem.self,
|
||||
])
|
||||
}
|
||||
|
||||
36
WorkoutsPlus/er-diagram.md
Normal file
36
WorkoutsPlus/er-diagram.md
Normal file
@@ -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
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user