create ER diagram, refactor to conform to diagram, simplify session management

This commit is contained in:
Felix Förtsch
2024-10-17 14:41:14 +02:00
parent b7f5caf9dd
commit 97ecbcc6f4
23 changed files with 394 additions and 226 deletions

View File

@@ -195,6 +195,8 @@
);
mainGroup = 3F595B132C67A6AB00C4544B;
minimizedProjectReferenceProxies = 1;
packageReferences = (
);
preferredProjectObjectVersion = 77;
productRefGroup = 3F595B1D2C67A6AB00C4544B /* Products */;
projectDirPath = "";

View File

@@ -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
// }
//}

View File

@@ -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
}
}

View File

@@ -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)
}

View 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)
}
}

View 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()
}

View File

@@ -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
) {}

View File

@@ -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()
}

View File

@@ -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()) {

View File

@@ -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)

View File

@@ -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: {

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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)
}
}

View 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
}
}

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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")

View File

@@ -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)
}

View File

@@ -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)))

View File

@@ -35,5 +35,7 @@ extension WorkoutsPlusApp {
Equipment.self,
Workout.self,
WorkoutItem.self,
WorkoutSession.self])
WorkoutSession.self,
WorkoutSessionItem.self,
])
}

View 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
}
```