change folders to the "feature" mindset

This commit is contained in:
Felix Förtsch
2024-10-21 15:03:47 +02:00
parent 97ecbcc6f4
commit d1a87957f6
34 changed files with 121 additions and 139 deletions

View File

@@ -0,0 +1,24 @@
//
// Equipment.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 01.10.24.
//
import Foundation
import SwiftData
@Model
final class Equipment: Nameable {
static var systemImage = "dumbbell.fill"
var id = UUID()
var creationDate: Date = Date.now
@Attribute(.unique) var name: String
var exercises: [Exercise] = []
init(name: String) {
self.name = name
}
}

View File

@@ -0,0 +1,75 @@
//
// Exercise.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 25.08.24.
//
// TODO: The model currently has an issue. I think An exercise always (!) has reps 1x 30s or 8x 1 0 kg Push-up. I stumbled upon the issue through the fact that bodyweight exercises can be loaded and for the user it makes sense that a Push-up is the same a Push-up with 10 kg load.
// var equipment: [Equipment]
// var isPartOfProgression: Bool = false
// var type: ExerciseType = [.cardio, .flexibility, .strength, .balance]
// var focus: ExerciseFocus? // Strength, Flexibility, Speed, etc
// TODO: add a way to save suggested metrics (distance, reps, etc)
import Foundation
import SwiftData
@Model
final class Exercise: Nameable {
static var systemImage = "figure.run"
var id = UUID()
var creationDate: Date = Date.now
@Attribute(.unique) var name: String
var exerciseDescription: String = ""
var isPartOfProgression: Bool = false
var metric: ExerciseMetric
var equipment: [Equipment] = []
init(_ name: String, _ metric: ExerciseMetric = .none) {
self.name = name
self.metric = metric
}
}
enum ExerciseMetric: String, Codable, CaseIterable, Identifiable {
case none = ""
case distance = "Distance"
case time = "Time"
case weight = "Weight"
var id: Self { self }
}
extension Exercise {
static let sampleDataRecommendedRoutine: [Exercise] = [
Exercise("Shoulder Band Warm-up"),
Exercise("Squat Sky Reaches"),
Exercise("GMB Wrist Prep", .time),
Exercise("Dead Bugs"),
Exercise("Pull-up Progression", .weight),
Exercise("Dip Progression", .weight),
Exercise("Squat Progression", .weight),
Exercise("Hinge Progression", .weight),
Exercise("Row Progression", .weight),
Exercise("Push-up Progression", .weight),
Exercise("Handstand Practice", .time),
Exercise("Support Practice", .time)
]
static let sampleDataRings: [Exercise] = [
Exercise("Dips", .weight),
Exercise("Chin-ups", .weight),
Exercise("Push-ups", .weight),
Exercise("Inverted Rows", .weight),
Exercise("Hanging Knee Raises", .weight),
Exercise("Pistol Squats", .weight),
Exercise("Hanging Leg Curls", .weight),
Exercise("Sissy Squats")
]
}

View File

@@ -0,0 +1,46 @@
//
// ExerciseDetail.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
struct ExerciseDetail: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State var exercise: Exercise
var body: some View {
Form {
TextField("Exercise Name", text: $exercise.name)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
saveItem()
dismiss()
}
}
}
}
.navigationTitle("Exercise Details")
}
private func saveItem() {
if modelContext.hasChanges {
do {
try modelContext.save()
} catch {
print("Failed to save exercise: \(error.localizedDescription)")
}
}
}
}
#Preview {
NavigationStack {
ExerciseDetail(exercise: Exercise.sampleDataRecommendedRoutine.first!)
}
}

View File

@@ -0,0 +1,108 @@
//
// AddExercise.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 21.09.24.
//
import SwiftUI
import SwiftData
struct ExerciseEditor: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
var isPresentedAsSheet: Bool = false
@State var exercise: Exercise?
@State private var name: String = ""
@State private var description: String = ""
@State private var metric: ExerciseMetric = .none
@State private var isPartOfProgression: Bool = false
var body: some View {
NavigationStack {
VStack {
ScrollViewReader { proxy in
VStack {
Form {
Section {
TextField("Exercise Name", text: $name)
// TODO: Add Autocomplete
TextEditorWithPlaceholder(text: $description, placeholder: "Description (optional)")
}
Section(footer: Text("""
Examples:
• Pull-up → None
• Weighted Pull-up → Weight
• Sprint → Time or Distance
""")) {
Picker("Exercise Metric", selection: $metric) {
ForEach(ExerciseMetric.allCases) { metric in
Text(metric.rawValue.isEmpty ? "None" : metric.rawValue)
.tag(metric)
}
}
.pickerStyle(SegmentedPickerStyle())
}
Section(footer: Text("Feature coming soon.")) {
Toggle(isOn: $isPartOfProgression) {
Text("Exercise is Part of a Progression")
.foregroundStyle(.gray)
}
.disabled(true)
}
}
.navigationTitle("Edit")
.toolbar {
if isPresentedAsSheet {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", role: .cancel) {
dismiss()
}
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
withAnimation {
save()
dismiss()
}
}
}
}
}
.onAppear {
if let exercise {
self.name = exercise.name
self.description = exercise.exerciseDescription
self.metric = exercise.metric
self.isPartOfProgression = exercise.isPartOfProgression
}
}
}
}
}
}
private func save() {
let exerciseToSave: Exercise
if let exercise = exercise {
exerciseToSave = exercise
} else {
exerciseToSave = Exercise(name)
modelContext.insert(exerciseToSave)
}
exerciseToSave.name = name
exerciseToSave.exerciseDescription = description
exerciseToSave.metric = metric
exerciseToSave.isPartOfProgression = isPartOfProgression
}
}
#Preview {
ExerciseEditor()
}

View File

@@ -0,0 +1,104 @@
//
// ExerciseLibraryView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
struct ExerciseLibrary: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Exercise.name) private var exercises: [Exercise]
@State private var newExercise: Exercise = Exercise("")
@State private var newExerciseName: String = ""
@State private var isAddingExercise: Bool = false
@FocusState private var isInputFieldFocused: Bool
@State private var searchText: String = ""
var filteredItems: [Exercise] {
if searchText.isEmpty {
return exercises
} else {
return exercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var body: some View {
Group {
List {
Section {
ForEach(filteredItems) { exercise in
NavigationLink {
ExerciseEditor(exercise: exercise)
} label: {
HStack {
Text(exercise.name)
Spacer()
// Text(exercise.metric.rawValue)
// .font(.footnote)
// .foregroundStyle(.gray)
}
}
}
.onDelete(perform: deleteExercise)
if filteredItems.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}
Section {
AddItemButton(label: "Exercise", action: addExercise)
}
}
.searchable(text: $searchText)
}
.navigationTitle("Exercises")
.toolbar {
ToolbarItem {
EditButton()
}
ToolbarItem {
Button(action: addExercise) {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $isAddingExercise) {
ExerciseEditor(isPresentedAsSheet: true)
}
}
private func addExercise() {
withAnimation {
isAddingExercise = true
isInputFieldFocused = true
}
}
private func deleteExercise(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(exercises[index])
}
try? modelContext.save()
}
}
}
#Preview("With Sample Data") {
NavigationStack {
ExerciseLibrary()
}
.modelContainer(SampleData.shared.modelContainer)
}
#Preview("Empty Database") {
NavigationStack {
ExerciseLibrary()
}
.modelContainer(for: WorkoutItem.self, inMemory: true)
}

View File

@@ -0,0 +1,20 @@
//
// Food.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 28.09.24.
//
import Foundation
//enum FoodUnit: String, CaseIterable, CustomStringConvertible {
// case gram = "g"
// case milliliter = "ml"
//
// var description: String { rawValue }
//}
//struct FoodValue: ValueType {
// var value: String = ""
// var unit: FoodUnit = .gram
//}

View File

@@ -0,0 +1,27 @@
//
// Random.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 01.10.24.
//
//import Foundation
//
//enum Unit: String, CaseIterable, Codable {
// case none = ""
//
// case kilogram = "kg"
// case gram = "g"
//
// case liter = "l"
// case milliliter = "ml"
//
// case kilometer = "km"
// case meter = "m"
//
// case hour = "h"
// case minute = "min"
// case second = "s"
//
// var description: String { rawValue }
//}

View File

@@ -0,0 +1,129 @@
//
// ActivityLog.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 09.09.24.
//
// https://www.artemnovichkov.com/blog/github-contribution-graph-swift-charts
// https://github.com/artemnovichkov/awesome-swift-charts
import SwiftUI
import Charts
struct ActivityLog: View {
@State var activities: [Activity] = Activity.generate()
var body: some View {
Chart(activities) { contribution in
RectangleMark(
xStart: .value("Start week", contribution.date, unit: .weekOfYear),
xEnd: .value("End week", contribution.date, unit: .weekOfYear),
yStart: .value("Start weekday", weekday(for: contribution.date)),
yEnd: .value("End weekday", weekday(for: contribution.date) + 1)
)
.clipShape(RoundedRectangle(cornerRadius: 4).inset(by: 2))
.foregroundStyle(by: .value("Count", contribution.count))
}
.chartPlotStyle { content in
content
.aspectRatio(aspectRatio, contentMode: .fit)
}
.chartForegroundStyleScale(range: Gradient(colors: colors))
.chartXAxis {
AxisMarks(position: .top, values: .stride(by: .month)) {
AxisValueLabel(format: .dateTime.month())
.foregroundStyle(Color(.label))
}
}
.chartYAxis {
AxisMarks(position: .leading, values: [1, 3, 5]) { value in
if let value = value.as(Int.self) {
AxisValueLabel {
// Symbols from Calendar.current starting with Monday
// Text(shortWeekdaySymbols[value - 1])
}
.foregroundStyle(Color(.label))
}
}
}
.chartYScale(domain: .automatic(includesZero: false, reversed: true))
.chartLegend {
HStack(spacing: 4) {
Text("Less")
ForEach(legendColors, id: \.self) { color in
color
.frame(width: 10, height: 10)
.cornerRadius(2)
}
Text("More")
}
.padding(4)
.foregroundStyle(Color(.label))
.font(.caption2)
}
}
private func weekday(for date: Date) -> Int {
let weekday = Calendar.current.component(.weekday, from: date)
let adjustedWeekday = (weekday == 1) ? 7 : (weekday - 1)
return adjustedWeekday
}
private var aspectRatio: Double {
if activities.isEmpty {
return 1
}
let firstDate = activities.first!.date
let lastDate = activities.last!.date
let firstWeek = Calendar.current.component(.weekOfYear, from: firstDate)
let lastWeek = Calendar.current.component(.weekOfYear, from: lastDate)
return Double(lastWeek - firstWeek + 1) / 7
}
private var colors: [Color] {
(0...10).map { index in
if index == 0 {
return Color(.systemGray5)
}
return Color(.systemGreen).opacity(Double(index) / 10)
}
}
private var legendColors: [Color] {
Array(stride(from: 0, to: colors.count, by: 2).map { colors[$0] })
}
}
#Preview {
ActivityLog()
}
struct Activity: Identifiable {
let date: Date
let count: Int
var id: Date {
date
}
}
extension Activity {
static func generate() -> [Activity] {
var contributions: [Activity] = []
let toDate = Date.now
let fromDate = Calendar.current.date(byAdding: .day, value: -60, to: toDate)!
var currentDate = fromDate
while currentDate <= toDate {
let contribution = Activity(date: currentDate, count: .random(in: 0...10))
contributions.append(contribution)
currentDate = Calendar.current.date(byAdding: .day, value: 1, to: currentDate)!
}
return contributions
}
}

View File

@@ -0,0 +1,132 @@
//
// Miniplayer.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 07.09.24.
//
import SwiftUI
import SwiftData
// Maybe: https://github.com/LeoNatan/LNPopupController/tree/master
// TODO:The Miniplayer is the same view that we use for the Live Activity
// .safeAreaInset(edge: .bottom) { }
struct MiniPlayer: View {
@Default(\.isWorkingOut) var isWorkingOut
// @Query private var workouts: [Workout]
@State private var isFullScreenCoverPresented = false
@State private var workout = Workout.sampleData
@State private var workoutSession: WorkoutSession?
var body: some View {
Group {
if (isWorkingOut) {
Button(action: {
withAnimation() { isFullScreenCoverPresented.toggle() }}) {
HStack {
VStack(alignment: .leading) {
Text("8x Push-ups")
.font(.headline)
Text("Recommended Routine - 35 minutes")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
// TODO: Maybe replace it with a clock
Button(action: {
withAnimation() {
// TODO: This button has to do something with the workout (pause it, next exercise, etc)
}}) {
Image(systemName: "pause.circle.fill")
.font(.title)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .orange)
}
}
.padding()
}
.buttonStyle(.plain)
.fullScreenCover(isPresented: $isFullScreenCoverPresented) {
// ActiveWorkoutSession(workout: workout, workoutSession: workoutSession)
}
} else {
Button(action: {
withAnimation() { isFullScreenCoverPresented.toggle() }}) {
HStack {
VStack(alignment: .leading) {
Text("Start Workout")
.font(.headline)
// TODO: Replace this with the upcoming/planned workout
// Text(selectedWorkoutId)
// .font(.subheadline)
// .foregroundColor(.secondary)
}
Spacer()
Button(action: {
withAnimation() {
// TODO: This button "quick starts" the workout (skips over the ActiveWorkoutSession fullscreen cover)
// if let workoutId = selectedWorkoutId {
// If you selectedWorkoutId is set, get the corresponding workout. This idea is generally okay, but feels off (DRY?)
// workout = workouts.filter({ $0.id == UUID(uuidString: workoutId) }).first
// }
}}) {
Image(systemName: "play.circle.fill")
.font(.title)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .green)
}
}
.padding()
}
.buttonStyle(.plain)
.fullScreenCover(isPresented: $isFullScreenCoverPresented) {
// ActiveWorkoutSession(workout: workout, workoutSession: workoutSession)
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: 60)
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.shadow(radius: 10)
.padding(.horizontal)
// .padding(.bottom, 65)
}
}
#Preview {
ZStack(alignment: .bottom) {
TabView {
List {
Button(action: {
Defaults.shared.isWorkingOut.toggle()
}) {
Text("Toggle")
}
ForEach(0..<20) { _ in
Text("An item")
}
}
.tabItem {
Image(systemName: "calendar.badge.clock")
Text("Plans & Goals")
}
Text("Dummy")
.tabItem {
Image(systemName: "calendar.badge.clock")
Text("Plans & Goals")
}
}
}
.safeAreaInset(edge: .bottom) {
MiniPlayer()
.onAppear {
Defaults.shared.isWorkingOut = false
}
}
}

View File

@@ -0,0 +1,109 @@
//
// WorkoutLog.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 30.08.24.
//
import SwiftUI
import SwiftData
struct WorkoutLog: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \WorkoutSession.name) private var workoutSessions: [WorkoutSession]
@Default(\.isWorkingOut) var isWorkingOut
var body: some View {
List {
Section(header: Text("Dummies")) {
NavigationLink(destination: Text("WorkoutLogDetails")) {
HStack(alignment: .top) {
Image(systemName: "figure.run")
.padding(.trailing)
.padding(.top)
VStack(alignment: .leading) {
HStack {
Text("23.01.1988")
Text("14:37 Uhr")
Spacer()
}
Text("Marathon")
.fontWeight(.semibold)
HStack {
Text("34 km/42 km")
Text("")
Text("5:12 min/km")
}.foregroundStyle(.gray)
HStack {
Text("1 h")
Text("")
Text("1.337 kcal")
}
.foregroundStyle(.gray)
}
}
}
NavigationLink(destination: Text("WorkoutLogDetails")) {
HStack {
Image(systemName: "figure.run")
.padding(.trailing)
VStack(alignment: .leading) {
HStack {
Text("23.01.1988")
Text("14:37 Uhr")
Spacer()
}
Text("Recommended Routine")
.fontWeight(.semibold)
HStack {
Text("6/12 sets")
Text("")
Text("50 %")
}.foregroundStyle(.gray)
HStack {
Text("57 m")
Text("")
Text("357 kcal")
}
.foregroundStyle(.gray)
}
}
}
}
Section(header: Text("Workout Sessions")) {
ForEach(workoutSessions) { session in
VStack(alignment: .leading) {
Text(session.startDate.ISO8601Format())
Text(session.name)
.font(.subheadline)
}
}.onDelete(perform: deleteWorkoutSession)
}
}
.navigationTitle("Workout Logs")
}
private func deleteWorkoutSession(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(workoutSessions[index])
}
try? modelContext.save()
}
}
}
#Preview("Active WorkoutSession") {
NavigationStack {
WorkoutLog()
.onAppear {
Defaults.shared.isWorkingOut = true
}
}
}
#Preview("No Active WorkoutSession") {
WorkoutLog()
}

View File

@@ -0,0 +1,83 @@
//
// Onboarding.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 04.09.24.
//
import SwiftUI
// TODO: Inspiration holen von https://github.com/SvenTiigi/WhatsNewKit
struct Onboarding: View {
@Default(\.isFirstAppStart) var isFirstAppStart
@Default(\.isOnboarding) var isOnboarding
@Default(\.userId) var userId
@State private var currentPage = 0
let totalPages = 3
var body: some View {
VStack {
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
VStack {
Spacer()
Image(systemName: "scribble.variable")
.resizable()
.scaledToFit()
.frame(width: 150, height: 150)
.foregroundColor(.blue)
Text("userID: \(userId)")
.font(.largeTitle)
.fontWeight(.bold)
.multilineTextAlignment(.center)
.padding(.top, 40)
Text("Erfahren Sie, wie MyApp Ihnen helfen kann, produktiver zu sein.")
.font(.title3)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
.padding(.top, 20)
Spacer()
HStack {
if currentPage > 0 {
Button("Zurück") {
currentPage -= 1
}
.padding(.horizontal, 40)
}
Spacer()
Button(currentPage == totalPages - 1 ? "Los geht's!" : "Weiter") {
if currentPage < totalPages - 1 {
currentPage += 1
} else {
if (isFirstAppStart) {
userId = UUID().uuidString
}
isFirstAppStart = false
isOnboarding = false
}
}
.padding(.horizontal, 40)
}
.padding(.bottom, 40)
}
.tag(index)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
}
}
}
#Preview {
Onboarding()
}

View File

@@ -0,0 +1,70 @@
//
// Settings.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 30.08.24.
//
import SwiftUI
// TODO: Import/Export Workouts/Exercises
// https://www.avanderlee.com/swift/json-parsing-decoding/
struct Settings: View {
@Default(\.isDebug) var isDebug
@Default(\.isFirstAppStart) var isFirstAppStart
@Default(\.isOnboarding) var isOnboarding
@Default(\.isTrainerMode) var isTrainerMode
@Default(\.userId) var userId
@Default(\.reps) var reps
var body: some View {
List {
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")
}
}
Section(header: Text("Defaults")) {
StepperListItem(itemName: "Rep Count", itemValue: $reps)
}
Text(String(reps))
Section(header: Text("Danger Zone")) {
Toggle(isOn: $isDebug) {
Text("isDebug")
}
Toggle(isOn: $isOnboarding) {
Text("isOnboarding ")
}
Button("Reset App", role: .destructive, action: resetApp)
}
Section {
NavigationLink("Credits") {
Text(
"""
# Software Components
## Duration Picker
https://github.com/mac-gallagher/DurationPicker
"""
)
}
}
}
.navigationTitle("Settings")
}
private func resetApp() {
isFirstAppStart = true
isOnboarding = true
}
}
#Preview {
NavigationStack {
Settings()
}
}

View File

@@ -0,0 +1,34 @@
//
// Person.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 03.09.24.
//
import Foundation
import SwiftData
@Model
class Trainer: Nameable {
var id = UUID()
var name: String = ""
var trainees: [Trainee] = []
init(name: String) {
self.name = name
}
}
@Model
class Trainee: Nameable {
var id = UUID()
var name: String = ""
var trainer: Trainer?
init(name: String, trainer: Trainer? = nil) {
self.name = name
self.trainer = trainer
}
}

View File

@@ -0,0 +1,41 @@
//
// AddWorkout.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 17.08.24.
//
import SwiftUI
struct AddWorkout: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State var workout: Workout
var body: some View {
Form {
TextField("Workout Name", text: $workout.name)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
modelContext.delete(workout)
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
dismiss()
}
}
}
}
}
#Preview {
Color.clear
.sheet(isPresented: .constant(true)) {
AddWorkout(workout: Workout.sampleData.first!)
}
}

View File

@@ -0,0 +1,145 @@
//
// Workout.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
@Model
final class Workout: Nameable, Hashable {
static var systemImage = "figure.run.square.stack"
var id = UUID()
var creationDate = Date.now
// 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"
var workoutIconColorName = ColorName.black
// TODO: Expected Duration (learn from past workouts with ML and predict workout duration from that
@Relationship(deleteRule: .cascade) private var workoutItems: [WorkoutItem] = []
func getWorkoutItems() -> [WorkoutItem] {
return workoutItems.sorted { $0.position < $1.position }
}
init(name: String) {
self.name = name
}
func start() -> WorkoutSession {
return WorkoutSession(start: self)
}
func add(workoutItem: WorkoutItem) {
self.workoutItems.append(workoutItem)
updateWorkoutItemsPositions()
}
func add(workoutItems: [WorkoutItem]) {
for workoutItem in workoutItems {
self.workoutItems.append(workoutItem)
}
updateWorkoutItemsPositions()
}
func moveWorkoutItem(from source: IndexSet, to destination: Int) {
workoutItems.move(fromOffsets: source, toOffset: destination)
updateWorkoutItemsPositions()
}
private func updateWorkoutItemsPositions() {
for (index, exercise) in workoutItems.enumerated() {
exercise.position = index
}
}
func isSelected(workout: Workout) -> Bool { self.id == workout.id }
}
extension Workout {
// This enum maps the system colors to enable storing them with the SwiftData model data as String.
// See Workout.workoutIconColorName for a usage example.
// TODO: Use a Macro to reduce this horrible contraption to something maintainable.
enum ColorName: String, Codable {
case black
case blue
case cyan
case gray
case green
case indigo
case mint
case orange
case pink
case purple
case red
case white
case yellow
var color: Color {
switch self {
case .black: return .black
case .blue: return .blue
case .cyan: return .cyan
case .gray: return .gray
case .green: return .green
case .indigo: return .indigo
case .mint: return .mint
case .orange: return .orange
case .pink: return .pink
case .purple: return .purple
case .red: return .red
case .white: return .white
case .yellow: return .yellow
}
}
static func fromColor(_ color: Color) -> ColorName? {
switch color {
case .black: return .black
case .blue: return .blue
case .cyan: return .cyan
case .gray: return .gray
case .green: return .green
case .indigo: return .indigo
case .mint: return .mint
case .orange: return .orange
case .pink: return .pink
case .purple: return .purple
case .red: return .red
case .white: return .white
case .yellow: return .yellow
default: return nil
}
}
}
}
extension Workout {
private convenience init(name: String, workoutItems: [WorkoutItem]) {
self.init(name: name)
self.workoutItems = workoutItems
}
static let sampleData: [Workout] = {
var rr = Workout(name: "Recommended Routine")
for workoutItem in WorkoutItem.sampleDataRecommendedRoutine {
rr.add(workoutItem: workoutItem)
}
var rings = Workout(name: "Fully Body Rings")
for workoutItem in WorkoutItem.sampleDataRings {
rings.add(workoutItem: workoutItem)
}
return [rr, rings]
}()
}

View File

@@ -0,0 +1,162 @@
//
// WorkoutDetail.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
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(
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.")) {
ForEach(workout.getWorkoutItems()) { workoutItem in
WorkoutListItem(workout, workoutItem)
}
.onDelete(perform: deleteWorkoutItem)
.onMove(perform: move)
.environment(\.editMode, .constant(.active)) // Always active drag mode
AddItemButton(label: "Exercise", action: presentWorkoutItemLibrarySheet)
}
}
.navigationTitle("\(workout.name)")
.toolbar {
// TODO: Add proper Sharing for workouts.
// 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)
}
}
private func presentWorkoutItemLibrarySheet() {
withAnimation {
isPresentingWorkoutItemLibrarySheet = true
}
}
private func saveWorkout() {
if modelContext.hasChanges {
do {
try modelContext.save()
} catch {
print("Failed to save workout: \(error.localizedDescription)")
}
}
}
private func deleteWorkoutItem(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(workout.getWorkoutItems()[index])
}
try? modelContext.save()
}
}
private func move(from source: IndexSet, to destination: Int) {
workout.moveWorkoutItem(from: source, to: destination)
}
}
#Preview {
@Previewable @State var activeWorkoutSession: WorkoutSession?
NavigationStack {
WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: Workout.sampleData.first!)
.modelContainer(SampleData.shared.modelContainer)
}
}
#Preview("Debug") {
@Previewable @State var activeWorkoutSession: WorkoutSession?
TabView {
WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: Workout.sampleData.first!)
.tabItem {
Image(systemName: "figure.run.square.stack")
Text("Workouts")
}
DebugList()
.tabItem {
Image(systemName: "hammer")
Text("Debug")
}
}
.modelContainer(SampleData.shared.modelContainer)
}

View File

@@ -0,0 +1,70 @@
//
// WorkoutIconSelector.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 26.08.24.
//
import SwiftUI
struct WorkoutIconSelector: View {
@State var workout: Workout
@State private var searchText: String = ""
var filteredIcons: [String] {
if searchText.isEmpty {
return fitnessIcons
} else {
return fitnessIcons.filter { $0.contains(searchText.lowercased()) }
}
}
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))]) {
ForEach(systemColors, id: \.self) { color in
Button(action: {
workout.workoutIconColorName = Workout.ColorName.fromColor(color)!
}) {
Circle()
.fill(color)
.frame(width: 40, height: 40)
.overlay(
Circle()
// TODO: change this from stroke to checkmark symbol in the circle
.stroke(Color.black, lineWidth: workout.workoutIconColorName.color == color ? 4 : 0)
)
}
}
}
.padding()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50, maximum: 100))]) {
ForEach(filteredIcons, id: \.self) { iconName in
Button(action: {
workout.workoutIconSystemName = iconName
}) {
Image(systemName: iconName)
.foregroundStyle(workout.workoutIconColorName.color)
.padding()
.background()
.cornerRadius(8)
}
}
}
.padding()
.searchable(text: $searchText)
}
.overlay {
if filteredIcons.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}
}
}
#Preview {
NavigationStack() {
WorkoutIconSelector(workout: Workout.sampleData.first!)
}
}

View File

@@ -0,0 +1,108 @@
//
// WorkoutItem.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
// TODO: Think about what's happening when an exercise (template) is deleted or changed
// If it is delete -> we just keep the WorkoutItem with the name :)
// The only relevant delete is delete Workout -> Delete Workoutitems
// { didSet { self.name = exercise?.name ?? "self.name" } }
// TODO: Wondering if a SortDescriptor in the Model is useful?
// https://old.reddit.com/r/SwiftUI/comments/1fnvkud/extracting_the_creation_of_swiftdata_query_into/
// static var sorted: SortDescriptor<WorkoutItem> {
// SortDescriptor(\.position, order: .forward)
// }
// struct ExerciseValue: ValueType {
// var value: String = ""
// var unit: ExerciseUnit
// }
// TODO: Deload items -> Think about how to model deload/sick/rest/holiday week
//enum PerformanceMetric: String, CaseIterable, CustomStringConvertible {
// case speed = "m/s"
// case pace = "s/m"
//
// var description: String { rawValue }
//}
import Foundation
import SwiftData
@Model
final class WorkoutItem: Nameable, Positionable {
var id = UUID()
var name: String
var workout: Workout?
// TODO: Make WorkoutItem a protocol so the type can be distinguished by the class type
var workoutItemType: WorkoutItemType // Differentiates between exercise/rest/set
var position: Int = 0
var set: [WorkoutItem] = []
var exercise: 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 {
case exercise
case rest
case set
}
init(_ exercise: Exercise) {
self.exercise = exercise
self.workoutItemType = .exercise
// Push-up
self.name = exercise.name
// 8x
self.plannedReps = 1
// 0
self.plannedValue = 0
// kg
self.metric = exercise.metric
}
// init(set: [WorkoutItem] = []) {
// self.workoutItemType = .set
// self.name = "Set"
// self.plannedReps = 3
// self.plannedValue = 0
// set.forEach(addChild)
// }
// init(rest: Double) {
// self.workoutItemType = .rest
// self.name = "Rest"
// self.plannedReps = 1
// self.plannedValue = rest
// self.metric = .time
// }
func addChild(_ child: WorkoutItem) {
if self.workoutItemType == .set {
self.set.append(child)
}
}
}
extension WorkoutItem {
static let sampleDataRecommendedRoutine: [WorkoutItem] = {
var exercises = [WorkoutItem]()
for exercise in Exercise.sampleDataRecommendedRoutine {
exercises.append(WorkoutItem(exercise))
}
return exercises
}()
static let sampleDataRings: [WorkoutItem] = {
var exercises = [WorkoutItem]()
for exercise in Exercise.sampleDataRings {
exercises.append(WorkoutItem(exercise))
}
return exercises
}()
}

View File

@@ -0,0 +1,69 @@
//
// AddExerciseToWorkout.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 22.08.24.
//
import SwiftUI
import SwiftData
struct WorkoutItemLibrarySheet: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Exercise.name) private var exercises: [Exercise]
@State var workout: Workout
// TODO: Add (i) Button that allows editing an exercise (maybe requires NavigationStack?)
// TODO: Pass in some context? So that when we come from a Set and add a Set, it's treed. Or when we come from a Set and add an Exercise, we put it into it's child
var body: some View {
Group {
List {
// Section(header: Text("Utilities")) {
// AddItemButton(label: "Set") {
// addWorkoutItemtoWorkout(WorkoutItem(set: [
// WorkoutItem(Exercise("Set item 1")),
// WorkoutItem(Exercise("Set item 2"))
// ]))
// }
// AddItemButton(label: "Rest") {
// addWorkoutItemtoWorkout(WorkoutItem(rest: 45))
// }
// }
Section(header: Text("Excersises")) {
if !exercises.isEmpty {
ForEach(exercises) { exercise in
AddItemButton(label: exercise.name) {
let workoutItem = WorkoutItem(exercise)
addWorkoutItemtoWorkout(workoutItem)
}
}
} else {
ContentUnavailableView {
// TODO: Add Button that allows adding an exercise
Label("No Exercises", systemImage: Exercise.systemImage)
}
}
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
private func addWorkoutItemtoWorkout(_ workoutItem: WorkoutItem) {
workout.add(workoutItem: workoutItem)
// TODO: Handle saving in a way the user knows when it's saved
// modelContext.save()
}
}
#Preview("With Sample Data") {
WorkoutItemLibrarySheet(workout: Workout.sampleData.first!)
.modelContainer(SampleData.shared.modelContainer)
}
#Preview("Empty Database") {
WorkoutItemLibrarySheet(workout: Workout.sampleData.first!)
.modelContainer(for: Exercise.self, inMemory: true)
}

View File

@@ -0,0 +1,121 @@
//
// WorkoutLibrary.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
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: "")
@State private var newWorkoutName: String = ""
@State private var isAddingWorkout: Bool = false
@FocusState private var isInputFieldFocused: Bool
@State private var searchText: String = ""
var filteredItems: [Workout] {
if searchText.isEmpty { return workouts }
else { return workouts.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
var body: some View {
Group {
List {
ForEach(filteredItems) { workout in
NavigationLink {
WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: workout)
} label: {
Image(systemName: workout.workoutIconSystemName)
.foregroundStyle(workout.workoutIconColorName.color)
Text(workout.name)
}
.swipeActions(edge: .leading) {
Button {
activeWorkoutSession = workout.start()
} label: {
Label("Start", systemImage: "play")
.tint(.green)
}
}
}
.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() {
withAnimation {
newWorkout = Workout(name: "")
newWorkoutName = ""
isAddingWorkout = true
isInputFieldFocused = true
}
}
private func save(workout: Workout) {
withAnimation {
newWorkout.name = newWorkoutName
if !workout.name.isEmpty {
modelContext.insert(workout)
try? modelContext.save()
}
isAddingWorkout = false
}
}
private func deleteWorkout(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(workouts[index])
}
try? modelContext.save()
}
}
}
#Preview("With Sample Data") {
@Previewable @State var activeWorkoutSession: WorkoutSession?
NavigationStack {
WorkoutLibrary(activeWorkoutSession: $activeWorkoutSession)
}
.modelContainer(SampleData.shared.modelContainer)
}
#Preview("Empty Database") {
@Previewable @State var activeWorkoutSession: WorkoutSession?
NavigationStack {
WorkoutLibrary(activeWorkoutSession: $activeWorkoutSession)
}
.modelContainer(for: Workout.self, inMemory: true)
}

View File

@@ -0,0 +1,50 @@
//
// WorkoutListItem.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 02.09.24.
//
import SwiftUI
// Recursive structure
struct WorkoutListItem: View {
var workout: Workout
@State var workoutItem: WorkoutItem
init(_ workout: Workout, _ workoutItem: WorkoutItem ) {
self.workout = workout
self.workoutItem = workoutItem
}
var body: some View {
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)
}
}
}
#Preview {
List {
// WorkoutListItem(Workout(name: "RR"), WorkoutItem(set: [
// WorkoutItem(Exercise("Squat")),
// 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

@@ -0,0 +1,78 @@
import SwiftUI
import SwiftData
// This view can only be viewed, if there exists a WorkoutSession that can be considered active (a person working out).
struct ActiveWorkoutSession: View {
@Environment(\.modelContext) private var modelContext
@Default(\.isWorkingOut) var isWorkingOut
@State var isTimerRunning: Bool = true
@Query(sort: \WorkoutSession.name) var workoutSessions: [WorkoutSession]
@Binding var activeWorkoutSession: WorkoutSession?
var body: some View {
VStack {
List {
Section(header: Text("Workout")) {
Text(activeWorkoutSession!.name)
}
Section(header: Text("Exercises")) {
ForEach(activeWorkoutSession!.workoutSessionItems) { workoutItem in
ActiveWorkoutSessionListItem(workoutItem: workoutItem)
}
}
}
}
.navigationTitle("Session")
.toolbar {
Button(action: {
isWorkingOut = false
activeWorkoutSession?.stop()
}) {
HStack {
Image(systemName: "stop.fill")
Text("Stop")
}
}
.bold()
.fontDesign(.rounded)
.tint(.red)
}
}
private func getActiveWorkoutItems(activeWorkout: Workout?) -> [WorkoutItem] {
guard let activeWorkout else { return [] }
return activeWorkout.getWorkoutItems()
}
// TODO: Put this somewhere general
private func getItem<Item: Nameable>(from array: [Item], by id: String) -> Item? {
let filteredItems = array.filter { $0.id == UUID(uuidString: id) }
return filteredItems.count == 1 ? filteredItems.first : nil
}
}
#Preview {
@Previewable @State var activeWorkoutSession: WorkoutSession?
@Previewable @State var workout = Workout.sampleData.first!
NavigationStack {
ActiveWorkoutSession(activeWorkoutSession: $activeWorkoutSession)
}
.onAppear {
Defaults.shared.isWorkingOut = false
}
.modelContainer(SampleData.shared.modelContainer)
}
//#Preview("Empty modelContainer") {
// @Previewable @State var activeWorkoutSession: WorkoutSession?
//
// NavigationStack {
// ActiveWorkoutSession(activeWorkoutSession: $activeWorkoutSession)
// }
// .onAppear {
// Defaults.shared.isWorkingOut = false
// }
//}

View File

@@ -0,0 +1,73 @@
//
// ActiveWorkoutSessionControls.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 16.09.24.
//
import SwiftUI
struct ActiveWorkoutSessionControls: View {
@Binding var session: WorkoutSession
var body: some View {
VStack {
HStack {
Text(session.getCurrentTodo())
.font(.system(size: 24, weight: .bold, design: .rounded))
}
ProgressView("",
value: session.getCurrentExerciseIndex(),
total: session.getTotalExerciseCount() - 1
)
HStack {
Button(action: {
session.prevExercise()
}) {
HStack {
Image(systemName: "backward.end.fill")
Text("Prev")
}
}
.buttonStyle(.borderedProminent)
.bold()
.tint(.primary)
Button(action: {
// TODO: Implement proper Pausing
session.pause()
}) {
HStack {
Image(systemName: "pause.fill")
Text("Pause")
}
}
.buttonStyle(.borderedProminent)
.bold()
.tint(.gray)
.disabled(true)
Button(action: {
session.nextExercise()
}) {
HStack {
Text("Next")
Image(systemName: "forward.end.fill")
}
}
.buttonStyle(.borderedProminent)
.bold()
.tint(.primary)
}
}
}
}
#Preview("isWorkingOut = true") {
@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)
.onAppear() {
Defaults.shared.isWorkingOut = true
}
}

View File

@@ -0,0 +1,34 @@
//
// ActiveWorkoutSessionListItem.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 19.09.24.
//
import SwiftUI
struct ActiveWorkoutSessionListItem: View {
var workoutItem: WorkoutSessionItem
var body: some View {
HStack {
Text(workoutItem.name)
Spacer()
Button(action: {
// TODO: Implement a sheet view; don't use ExerciseDetail since its purpose is editing
}) {
Image(systemName: "info.circle")
.foregroundColor(.blue)
}
}
}
}
#Preview {
@Previewable @State var workoutSession = WorkoutSession(start: Workout.sampleData.first!)
List {
ForEach(WorkoutItem.sampleDataRecommendedRoutine) { item in
ActiveWorkoutSessionListItem(workoutItem: WorkoutSessionItem(workoutSession: workoutSession, planned: item))
}
}
}

View File

@@ -0,0 +1,103 @@
//
// WorkoutSession.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 07.09.24.
//
import Foundation
import SwiftData
@Model
final class WorkoutSession: Nameable {
var id = UUID()
var name: String
// TODO: Think about if a refrence to the workout makes sense; a Workout could change.
// var workout: Workout
var workoutSessionItems: [WorkoutSessionItem] = []
init(start workout: Workout) {
self.name = workout.name
for workoutItem in workout.getWorkoutItems() {
workoutSessionItems.append(WorkoutSessionItem(workoutSession: self, planned: workoutItem))
}
}
// State
// var isPaused: Bool
// var isCancelled: Bool
// var isDeleted: Bool
// var isSynced: Bool
// My workout session started at:
var startDate: Date = Date.now
// My workout session was completed at:
var stopDate: Date? = nil
// My workout session took me x seconds.
var duration: TimeInterval? = nil
// My workout session is completed and moved to the workout log.
var isCompleted: Bool = false
// Exercise Progress
var currentExercise = 0
func isActive() -> Bool {
return stopDate == nil
}
// MARK: -- Workout Controls
// func start(with workout: Workout) {
// self.workout = workout
// startDate = Date.now
// }
func pause() {
// TODO: Implement proper Pause
}
// Call stop() to terminate the workout.
func stop() {
isCompleted = true
stopDate = Date.now
duration = stopDate!.timeIntervalSince(startDate)
}
func prevExercise() {
if currentExercise > 0 {
currentExercise -= 1
}
}
func nextExercise() {
}
// MARK: -- Workout Information
func getFormattedDuration() -> String {
let elapsedTime = Date.now.timeIntervalSince(startDate)
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .positional
return formatter.string(from: elapsedTime) ?? "00:00:00"
}
func getTotalExerciseCount() -> Double {
return 100.0
}
func getCurrentExerciseIndex() -> Double {
return Double(currentExercise)
}
func getCurrentTodo() -> String {
return getCurrentExerciseMetric() + " " + getCurrentExerciseName()
}
func getCurrentExerciseName() -> String {
return "Hello"
}
func getCurrentExerciseMetric() -> String {
return "Hello"
}
}

View File

@@ -0,0 +1,34 @@
//
// WorkoutSessionItem.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 15.10.24.
//
import Foundation
import SwiftData
@Model
final class WorkoutSessionItem: Nameable, Positionable {
var id = UUID()
var position: Int
var name: String
var workoutSession: WorkoutSession
var plannedReps: Int // 8 times | 1 time
var plannedValue: Double // With 10 | 42,187
var metric: ExerciseMetric? // kg (weight) | km (distance)
var actualReps: Int?
var actualValue: Double?
init(workoutSession: WorkoutSession, planned: WorkoutItem) {
self.workoutSession = workoutSession
self.name = planned.exercise.name
self.position = planned.position
self.plannedReps = planned.plannedReps
self.plannedValue = planned.plannedValue
self.metric = planned.metric
}
}