add ExerciseEditor, Picker skeletons, AutocompleteTextfield
This commit is contained in:
@@ -20,32 +20,22 @@ struct ActiveWorkoutSession: View {
|
|||||||
List {
|
List {
|
||||||
Section(header: Text("Workout"), footer: Text(activeWorkoutSession?.creationDate.ISO8601Format() ?? "Unknown Date")) {
|
Section(header: Text("Workout"), footer: Text(activeWorkoutSession?.creationDate.ISO8601Format() ?? "Unknown Date")) {
|
||||||
NavigationLink(destination: {
|
NavigationLink(destination: {
|
||||||
ItemPicker<Workout>(items: workouts, selectedItem: $activeWorkout)
|
ItemPicker<Workout>(selectedItem: $activeWorkout, items: workouts)
|
||||||
}) {
|
}) {
|
||||||
Text(activeWorkout?.name ?? "Select Workout")
|
Text(activeWorkout?.name ?? "Select Workout")
|
||||||
}
|
}
|
||||||
.onChange(of: activeWorkout) { _, newWorkout in
|
.onChange(of: activeWorkout) { _, newWorkout in
|
||||||
if let workout = newWorkout {
|
if let newWorkout {
|
||||||
activeWorkoutId = workout.id.uuidString
|
activeWorkoutId = newWorkout.id.uuidString
|
||||||
activeWorkoutSession?.workout = workout
|
activeWorkoutSession?.workout = newWorkout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let activeWorkout = activeWorkout {
|
if let activeWorkout {
|
||||||
Section(header: Text("Exercises")) {
|
Section(header: Text("Exercises")) {
|
||||||
ForEach(getActiveWorkoutItems(activeWorkout: activeWorkout)) { workoutItem in
|
ForEach(getActiveWorkoutItems(activeWorkout: activeWorkout)) { workoutItem in
|
||||||
HStack {
|
ActiveWorkoutSessionListItem(workoutItem: workoutItem)
|
||||||
Text(String(workoutItem.reps))
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -54,14 +44,14 @@ struct ActiveWorkoutSession: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MARK: -- Workout Controls
|
// MARK: Workout Controls
|
||||||
if (isWorkingOut) {
|
if (isWorkingOut) {
|
||||||
if activeWorkoutSession != nil {
|
if activeWorkoutSession != nil {
|
||||||
ActiveWorkoutSessionControls(
|
ActiveWorkoutSessionControls(
|
||||||
session: Binding(
|
session: Binding(
|
||||||
get: { self.activeWorkoutSession! },
|
get: { self.activeWorkoutSession! },
|
||||||
set: { self.activeWorkoutSession = $0 }
|
set: { self.activeWorkoutSession = $0 }
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,11 +68,14 @@ struct ActiveWorkoutSession: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bold()
|
.bold()
|
||||||
|
.fontDesign(.rounded)
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
} else {
|
} else {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
isWorkingOut = true
|
isWorkingOut = true
|
||||||
activeWorkoutSession?.start()
|
if let activeWorkout {
|
||||||
|
activeWorkoutSession?.start(with: activeWorkout)
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "play.fill")
|
Image(systemName: "play.fill")
|
||||||
@@ -90,11 +83,11 @@ struct ActiveWorkoutSession: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bold()
|
.bold()
|
||||||
|
.fontDesign(.rounded)
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Load the active workout session and workout onAppear
|
|
||||||
if let activeWorkoutSession = getItem(from: workoutSessions, by: activeWorkoutSessionId) {
|
if let activeWorkoutSession = getItem(from: workoutSessions, by: activeWorkoutSessionId) {
|
||||||
self.activeWorkoutSession = activeWorkoutSession
|
self.activeWorkoutSession = activeWorkoutSession
|
||||||
if let workout = getItem(from: workouts, by: activeWorkoutId) {
|
if let workout = getItem(from: workouts, by: activeWorkoutId) {
|
||||||
@@ -132,6 +125,9 @@ struct ActiveWorkoutSession: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
ActiveWorkoutSession(activeWorkout: activeWorkout)
|
ActiveWorkoutSession(activeWorkout: activeWorkout)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
Defaults.shared.isWorkingOut = false
|
||||||
|
}
|
||||||
.modelContainer(SampleData.shared.modelContainer)
|
.modelContainer(SampleData.shared.modelContainer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ struct ActiveWorkoutSessionControls: View {
|
|||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Text(session.getCurrentTodo())
|
Text(session.getCurrentTodo())
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
}
|
}
|
||||||
ProgressView("",
|
ProgressView("",
|
||||||
value: session.getCurrentExerciseIndex() + 1,
|
value: session.getCurrentExerciseIndex(),
|
||||||
total: session.getTotalExerciseCount()
|
total: session.getTotalExerciseCount() - 1
|
||||||
)
|
)
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// ActiveWorkoutSessionListItem.swift
|
||||||
|
// WorkoutsPlus
|
||||||
|
//
|
||||||
|
// Created by Felix Förtsch on 19.09.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ActiveWorkoutSessionListItem: View {
|
||||||
|
var workoutItem: WorkoutItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
switch workoutItem.workoutItemType {
|
||||||
|
case .set:
|
||||||
|
Text(workoutItem.name)
|
||||||
|
case .workout:
|
||||||
|
Text(workoutItem.name)
|
||||||
|
case .exerciseWithReps:
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
case .exerciseWithDuration:
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
case .rest:
|
||||||
|
Text("Pause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
List {
|
||||||
|
ForEach(WorkoutItem.sampleDataRecommendedRoutine) { item in
|
||||||
|
ActiveWorkoutSessionListItem(workoutItem: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
WorkoutsPlus/Components/AutocompleteTextField.swift
Normal file
98
WorkoutsPlus/Components/AutocompleteTextField.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//
|
||||||
|
// AutocompleteTextField.swift
|
||||||
|
// WorkoutsPlus
|
||||||
|
//
|
||||||
|
// Created by Felix Förtsch on 19.09.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AutocompleteTextField<Item: Nameable>: View {
|
||||||
|
var placeholder: String
|
||||||
|
|
||||||
|
@Binding var item: Item?
|
||||||
|
var items: [Item]
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
var filteredItems: [Item] {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return []
|
||||||
|
} else {
|
||||||
|
return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var isAutoCompleteShown: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
HStack {
|
||||||
|
TextField(placeholder, text: $searchText)
|
||||||
|
// TODO: Fix "List line" not extending to the full width
|
||||||
|
if item == nil && !searchText.isEmpty {
|
||||||
|
Text("NEW")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
if item != nil {
|
||||||
|
Text("EDIT")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear() {
|
||||||
|
if item != nil {
|
||||||
|
self.searchText = item!.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: searchText) {
|
||||||
|
if filteredItems.count == 1 && filteredItems.first?.name == searchText {
|
||||||
|
self.item = filteredItems.first
|
||||||
|
isAutoCompleteShown = false
|
||||||
|
} else {
|
||||||
|
self.item = nil
|
||||||
|
isAutoCompleteShown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isAutoCompleteShown {
|
||||||
|
ForEach(filteredItems, id: \.self) { item in
|
||||||
|
HStack {
|
||||||
|
Text(item.name)
|
||||||
|
.foregroundStyle(.gray)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.left")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
// This .contentShape makes the whole row tappable
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
self.item = item
|
||||||
|
searchText = item.name
|
||||||
|
isAutoCompleteShown = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Item: Nameable {
|
||||||
|
var id = UUID()
|
||||||
|
var name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var item: Item? = nil
|
||||||
|
VStack {
|
||||||
|
Text("Preview only: ")
|
||||||
|
Text(item?.name ?? "No item selected")
|
||||||
|
}
|
||||||
|
.background(.red)
|
||||||
|
List {
|
||||||
|
AutocompleteTextField<Item>(placeholder: "New Item", item: $item, items: [
|
||||||
|
Item(name: "Item 1"),
|
||||||
|
Item(name: "Item 2"),
|
||||||
|
Item(name: "Item 3")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
22
WorkoutsPlus/Components/Binding.swift
Normal file
22
WorkoutsPlus/Components/Binding.swift
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Binding.swift
|
||||||
|
// WorkoutsPlus
|
||||||
|
//
|
||||||
|
// Created by Felix Förtsch on 21.09.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Binding {
|
||||||
|
init?(_ source: Binding<Value?>) {
|
||||||
|
guard let value = source.wrappedValue else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
get: { value },
|
||||||
|
set: { newValue in
|
||||||
|
source.wrappedValue = newValue
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
WorkoutsPlus/Components/DistancePicker.swift
Normal file
31
WorkoutsPlus/Components/DistancePicker.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
//
|
||||||
|
// DistancePicker.swift
|
||||||
|
// WorkoutsPlus
|
||||||
|
//
|
||||||
|
// Created by Felix Förtsch on 21.09.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DistancePicker: View {
|
||||||
|
@Binding var distance: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
TextField("Distance (m)", text: $distance)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
|
||||||
|
if let distanceInMeters = Double(distance) {
|
||||||
|
let distanceInKilometers = distanceInMeters / 1000
|
||||||
|
Text("Distance: \(distanceInKilometers, specifier: "%.2f") km")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var distance = ""
|
||||||
|
DistancePicker(distance: $distance)
|
||||||
|
}
|
||||||
25
WorkoutsPlus/Components/DurationPicker.swift
Normal file
25
WorkoutsPlus/Components/DurationPicker.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -27,6 +27,6 @@ struct ExerciseListItem: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
List {
|
List {
|
||||||
ExerciseListItem(Workout(name: "RR"), WorkoutItem(exercise: Exercise("Push-ups")))
|
ExerciseListItem(Workout(name: "RR"), WorkoutItem(exercise: Exercise("Push-ups", .reps)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
//
|
//
|
||||||
// ItemPicker.swift
|
// ItemPicker.swift
|
||||||
// WorkoutsPlus
|
// WorkoutsPlus
|
||||||
|
// Advanced Version of Picker.pickerStyle(NavigationLinkPickerStyle()) that's searchable and has a ContentUnavailableView
|
||||||
//
|
//
|
||||||
// Created by Felix Förtsch on 10.09.24.
|
// Created by Felix Förtsch on 10.09.24.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct ItemPicker<Item: Nameable>: View {
|
struct ItemPicker<Item: Nameable>: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@Binding var selectedItem: Item?
|
||||||
|
var items: [Item]
|
||||||
|
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
var filteredItems: [Item] {
|
var filteredItems: [Item] {
|
||||||
if searchText.isEmpty {
|
if searchText.isEmpty {
|
||||||
@@ -20,9 +23,6 @@ struct ItemPicker<Item: Nameable>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var items: [Item]
|
|
||||||
@Binding var selectedItem: Item?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
ForEach(filteredItems) { item in
|
ForEach(filteredItems) { item in
|
||||||
@@ -51,10 +51,18 @@ struct ItemPicker<Item: Nameable>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
private struct Item: Nameable {
|
||||||
@Previewable @State var selectedWorkout: Workout? = nil
|
var id = UUID()
|
||||||
NavigationStack {
|
var name: String
|
||||||
ItemPicker<Workout>(items: Workout.sampleData, selectedItem: $selectedWorkout)
|
}
|
||||||
}
|
|
||||||
.modelContainer(SampleData.shared.modelContainer)
|
#Preview {
|
||||||
|
@Previewable @State var selectedItem: Item? = nil
|
||||||
|
NavigationStack {
|
||||||
|
ItemPicker<Item>(selectedItem: $selectedItem, items: [
|
||||||
|
Item(name: "Item 1"),
|
||||||
|
Item(name: "Item 2"),
|
||||||
|
Item(name: "Item 3")
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
WorkoutsPlus/Components/RepsPicker.swift
Normal file
23
WorkoutsPlus/Components/RepsPicker.swift
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// RepsPicker.swift
|
||||||
|
// WorkoutsPlus
|
||||||
|
//
|
||||||
|
// Created by Felix Förtsch on 21.09.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: Think about implementing a custom keyboard like in FoodNoms
|
||||||
|
struct RepsPicker: View {
|
||||||
|
@Binding var reps: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TextField("Enter reps", text: $reps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var reps = ""
|
||||||
|
RepsPicker(reps: $reps)
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
//
|
|
||||||
// AddExercise.swift
|
|
||||||
// WorkoutsPlus
|
|
||||||
//
|
|
||||||
// Created by Felix Förtsch on 18.08.24.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AddExercise: View {
|
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
@State var exercise: Exercise
|
|
||||||
|
|
||||||
var body : some View {
|
|
||||||
Form {
|
|
||||||
TextField("Workout Name", text: $exercise.name)
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") {
|
|
||||||
modelContext.delete(exercise)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Save") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
Color.clear
|
|
||||||
.sheet(isPresented: .constant(true)) {
|
|
||||||
AddExercise(exercise: Exercise(""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
118
WorkoutsPlus/Exercise/ExerciseEditor.swift
Normal file
118
WorkoutsPlus/Exercise/ExerciseEditor.swift
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// @Query(sort: \Exercise.name) var exercises: [Exercise]
|
||||||
|
@State var exercise: Exercise?
|
||||||
|
|
||||||
|
@State private var name: String = ""
|
||||||
|
@State private var description: String = ""
|
||||||
|
|
||||||
|
@State private var metric: ExerciseMetric = .reps
|
||||||
|
@State private var reps: String = ""
|
||||||
|
@State private var duration: String = ""
|
||||||
|
@State private var distance: String = ""
|
||||||
|
|
||||||
|
@State private var isPartOfProgression: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section(footer: Text("The exercise description is optional.")) {
|
||||||
|
TextField("Exercise Name", text: $name)
|
||||||
|
// TODO: Add Autocomplete
|
||||||
|
TextField("Description", text: $description)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(footer: Text(Exercise.getAdvice(for: name, with: metric))) {
|
||||||
|
Picker("Metric", selection: $metric) {
|
||||||
|
Text("Reps").tag(ExerciseMetric.reps)
|
||||||
|
Text("Duration").tag(ExerciseMetric.duration)
|
||||||
|
Text("Distance").tag(ExerciseMetric.distance)
|
||||||
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
|
switch metric {
|
||||||
|
case .reps:
|
||||||
|
RepsPicker(reps: $reps)
|
||||||
|
case .duration:
|
||||||
|
DurationPicker(duration: $duration)
|
||||||
|
case .distance:
|
||||||
|
DistancePicker(distance: $distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.reps = exercise.suggestedReps
|
||||||
|
self.duration = exercise.suggestedDuration
|
||||||
|
self.distance = exercise.suggestedDistance
|
||||||
|
|
||||||
|
self.isPartOfProgression = exercise.isPartOfProgression
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
if let exercise {
|
||||||
|
exercise.name = name
|
||||||
|
exercise.exerciseDescription = description
|
||||||
|
|
||||||
|
exercise.metric = metric
|
||||||
|
exercise.suggestedReps = reps
|
||||||
|
exercise.suggestedDuration = duration
|
||||||
|
exercise.suggestedDistance = distance
|
||||||
|
|
||||||
|
exercise.isPartOfProgression = isPartOfProgression
|
||||||
|
} else {
|
||||||
|
let newExercise = Exercise(name, metric)
|
||||||
|
modelContext.insert(newExercise)
|
||||||
|
// try? modelContext.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ExerciseEditor()
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ struct ExerciseLibrary: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query(sort: \Exercise.name) private var exercises: [Exercise]
|
@Query(sort: \Exercise.name) private var exercises: [Exercise]
|
||||||
|
|
||||||
@State private var newExercise: Exercise = Exercise("")
|
@State private var newExercise: Exercise = Exercise("", .reps)
|
||||||
@State private var newExerciseName: String = ""
|
@State private var newExerciseName: String = ""
|
||||||
@State private var isAddingExercise: Bool = false
|
@State private var isAddingExercise: Bool = false
|
||||||
@FocusState private var isInputFieldFocused: Bool
|
@FocusState private var isInputFieldFocused: Bool
|
||||||
@@ -26,37 +26,33 @@ struct ExerciseLibrary: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add search bar to the top
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
List {
|
List {
|
||||||
ForEach(filteredItems) { exercise in
|
Section {
|
||||||
NavigationLink {
|
ForEach(filteredItems) { exercise in
|
||||||
ExerciseDetail(exercise: exercise)
|
NavigationLink {
|
||||||
} label: {
|
ExerciseEditor(exercise: exercise)
|
||||||
Text(exercise.name)
|
} label: {
|
||||||
// TODO: show exercise.metric in gray (eg Dips = reps, Intervall = time or = distance?)
|
HStack {
|
||||||
|
Text(exercise.name)
|
||||||
|
Spacer()
|
||||||
|
Text(exercise.metric.rawValue)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteExercise)
|
||||||
|
if filteredItems.isEmpty {
|
||||||
|
ContentUnavailableView.search(text: searchText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteExercise)
|
Section {
|
||||||
if isAddingExercise {
|
AddItemButton(label: "Exercise", action: addExercise)
|
||||||
TextField("New Exercise", text: $newExerciseName, onCommit: {
|
|
||||||
newExercise.name = newExerciseName
|
|
||||||
save(exercise: newExercise)
|
|
||||||
isAddingExercise = false
|
|
||||||
})
|
|
||||||
.textInputAutocapitalization(.words)
|
|
||||||
.focused($isInputFieldFocused)
|
|
||||||
}
|
}
|
||||||
if filteredItems.isEmpty {
|
|
||||||
ContentUnavailableView.search(text: searchText)
|
|
||||||
}
|
|
||||||
AddItemButton(label: "Exercise", action: addExercise)
|
|
||||||
}
|
}
|
||||||
.searchable(text: $searchText)
|
.searchable(text: $searchText)
|
||||||
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Exercises")
|
.navigationTitle("Exercises")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -64,27 +60,20 @@ struct ExerciseLibrary: View {
|
|||||||
EditButton()
|
EditButton()
|
||||||
}
|
}
|
||||||
ToolbarItem {
|
ToolbarItem {
|
||||||
Button(action: {}) {
|
Button(action: addExercise) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.sheet(isPresented: $isAddingExercise) {
|
||||||
|
ExerciseEditor(isPresentedAsSheet: true)
|
||||||
|
|
||||||
private func addExercise() {
|
|
||||||
withAnimation {
|
|
||||||
newExercise = Exercise("")
|
|
||||||
newExerciseName = ""
|
|
||||||
isAddingExercise = true
|
|
||||||
isInputFieldFocused = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func save(exercise: Exercise) {
|
private func addExercise() {
|
||||||
if !exercise.name.isEmpty {
|
withAnimation {
|
||||||
modelContext.insert(exercise)
|
isAddingExercise = true
|
||||||
try? modelContext.save()
|
isInputFieldFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,42 +13,76 @@ final class Exercise: Nameable {
|
|||||||
static var systemImage = "figure.run"
|
static var systemImage = "figure.run"
|
||||||
|
|
||||||
var id = UUID()
|
var id = UUID()
|
||||||
|
var creationDate: Date = Date.now
|
||||||
|
|
||||||
|
// The example for a exercise is the Push-up (but could also be: a sprint interval, a jump, a marathon, etc).
|
||||||
@Attribute(.unique) var name: String
|
@Attribute(.unique) var name: String
|
||||||
// var metric: String = "reps"
|
// Performing a push-up correctly has the following form cues
|
||||||
// var exerciseDescription: ExerciseDescription?
|
var exerciseDescription: String = ""
|
||||||
|
|
||||||
|
// A push-up is measured in reps.
|
||||||
|
var metric: ExerciseMetric
|
||||||
|
// In a typical push-up exercise you perform 8 reps.
|
||||||
|
var suggestedReps = "8" // TODO: Make a Rep
|
||||||
|
var suggestedDuration = "" // TODO: Make a Duration
|
||||||
|
var suggestedDistance = "" // TODO: Make a Distance
|
||||||
|
|
||||||
|
// A push-up is part of the Push-up Progression.
|
||||||
|
var isPartOfProgression: Bool = false
|
||||||
|
// The focus of the push-up is strength
|
||||||
// var focus: ExerciseFocus? // Strength, Flexibility, Speed, etc
|
// var focus: ExerciseFocus? // Strength, Flexibility, Speed, etc
|
||||||
|
|
||||||
var timestamp: Date = Date.now
|
init(_ name: String, _ metric: ExerciseMetric) {
|
||||||
|
|
||||||
init(_ name: String = "") {
|
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.metric = metric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExerciseMetric: String, Codable {
|
||||||
|
case reps = "Reps" // Repeat the exrcise for a given amount of repetitions
|
||||||
|
case duration = "Duration" // Do the exercise for given amount of time
|
||||||
|
case distance = "Distance" // Do the exercise for a given amount of distance
|
||||||
|
// Other possible metrics:
|
||||||
|
// - Open exercise: this exercise does not bring a metric (eg running)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Exercise {
|
||||||
|
static func getAdvice(for name: String, with metric: ExerciseMetric) -> String {
|
||||||
|
switch metric {
|
||||||
|
case .reps:
|
||||||
|
return "Repeat \(name == "" ? "New Exercise" : name) 8 times."
|
||||||
|
case .duration:
|
||||||
|
return "Do \(name == "" ? "New Exercise" : name) for 30 seconds."
|
||||||
|
case .distance:
|
||||||
|
return "Do \(name == "" ? "New Exercise" : name) for 500 meters."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Exercise {
|
extension Exercise {
|
||||||
static let sampleDataRecommendedRoutine: [Exercise] = [
|
static let sampleDataRecommendedRoutine: [Exercise] = [
|
||||||
Exercise("Shoulder Band Warm-up"),
|
Exercise("Shoulder Band Warm-up", .duration),
|
||||||
Exercise("Squat Sky Reaches"),
|
Exercise("Squat Sky Reaches", .reps),
|
||||||
Exercise("GMB Wrist Prep"),
|
Exercise("GMB Wrist Prep", .duration),
|
||||||
Exercise("Dead Bugs"),
|
Exercise("Dead Bugs", .reps),
|
||||||
Exercise("Pull-up Progression"),
|
Exercise("Pull-up Progression", .reps),
|
||||||
Exercise("Dip Progression"),
|
Exercise("Dip Progression", .reps),
|
||||||
Exercise("Squat Progression"),
|
Exercise("Squat Progression", .reps),
|
||||||
Exercise("Hinge Progression"),
|
Exercise("Hinge Progression", .reps),
|
||||||
Exercise("Row Progression"),
|
Exercise("Row Progression", .reps),
|
||||||
Exercise("Push-up Progression"),
|
Exercise("Push-up Progression", .reps),
|
||||||
Exercise("Handstand Practice"),
|
Exercise("Handstand Practice", .duration),
|
||||||
Exercise("Support Practice")
|
Exercise("Support Practice", .duration)
|
||||||
]
|
]
|
||||||
|
|
||||||
static let sampleDataRings: [Exercise] = [
|
static let sampleDataRings: [Exercise] = [
|
||||||
Exercise("Dips"),
|
Exercise("Dips", .reps),
|
||||||
Exercise("Chin-ups"),
|
Exercise("Chin-ups", .reps),
|
||||||
Exercise("Push-ups"),
|
Exercise("Push-ups", .reps),
|
||||||
Exercise("Inverted Rows"),
|
Exercise("Inverted Rows", .reps),
|
||||||
Exercise("Hanging Knee Raises"),
|
Exercise("Hanging Knee Raises", .reps),
|
||||||
Exercise("Pistol Squats"),
|
Exercise("Pistol Squats", .reps),
|
||||||
Exercise("Hanging Leg Curls"),
|
Exercise("Hanging Leg Curls", .reps),
|
||||||
Exercise("Sissy Squats"),
|
Exercise("Sissy Squats", .reps),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import Foundation
|
|||||||
|
|
||||||
protocol Nameable: Identifiable, Hashable {
|
protocol Nameable: Identifiable, Hashable {
|
||||||
var id: UUID { get }
|
var id: UUID { get }
|
||||||
var name: String { get }
|
var name: String { get set }
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol Positionable: Identifiable {
|
protocol Positionable: Identifiable {
|
||||||
var id: UUID { get }
|
var id: UUID { get }
|
||||||
var position: Int { get }
|
var position: Int { get set }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ final class Workout: Nameable, Hashable {
|
|||||||
static var systemImage = "figure.run.square.stack"
|
static var systemImage = "figure.run.square.stack"
|
||||||
|
|
||||||
var id = UUID()
|
var id = UUID()
|
||||||
|
var creationDate = Date.now
|
||||||
|
|
||||||
|
// The name of my workout is: Recommended Routine, My Marathon Workout
|
||||||
@Attribute(.unique) var name: String
|
@Attribute(.unique) var name: String
|
||||||
|
|
||||||
// Icon
|
// Icon
|
||||||
var workoutIconSystemName = "figure.run"
|
var workoutIconSystemName = "figure.run"
|
||||||
var workoutIconColorName = ColorName.black
|
var workoutIconColorName = ColorName.black
|
||||||
|
|
||||||
// Other properties and methods
|
// TODO: Expected Duration (learn from past workouts with ML and predict workout duration from that
|
||||||
var timestamp = Date.now
|
|
||||||
|
|
||||||
@Relationship(deleteRule: .cascade) private var workoutItems: [WorkoutItem] = []
|
@Relationship(deleteRule: .cascade) private var workoutItems: [WorkoutItem] = []
|
||||||
func getWorkoutItems() -> [WorkoutItem] {
|
func getWorkoutItems() -> [WorkoutItem] {
|
||||||
@@ -134,5 +137,4 @@ extension Workout {
|
|||||||
|
|
||||||
return [rr, rings]
|
return [rr, rings]
|
||||||
}()
|
}()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ final class WorkoutItem: Nameable, Positionable {
|
|||||||
|
|
||||||
var workout: Workout?
|
var workout: Workout?
|
||||||
var workoutItemType: WorkoutItemType
|
var workoutItemType: WorkoutItemType
|
||||||
|
enum WorkoutItemType: Codable {
|
||||||
|
case set
|
||||||
|
case workout
|
||||||
|
case exerciseWithReps
|
||||||
|
case exerciseWithDuration
|
||||||
|
case rest
|
||||||
|
}
|
||||||
|
|
||||||
var position: Int = 0
|
var position: Int = 0
|
||||||
|
|
||||||
var reps: Int = 0
|
var reps: Int = 0
|
||||||
@@ -31,21 +39,21 @@ final class WorkoutItem: Nameable, Positionable {
|
|||||||
|
|
||||||
// Exercise
|
// Exercise
|
||||||
init(reps: Int, _ exercise: String) {
|
init(reps: Int, _ exercise: String) {
|
||||||
self.workoutItemType = .exercise
|
self.workoutItemType = .exerciseWithReps
|
||||||
self.name = exercise
|
self.name = exercise
|
||||||
self.reps = reps
|
self.reps = reps
|
||||||
self.exercise = Exercise(exercise)
|
self.exercise = Exercise(exercise, .reps)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(duration: Int, _ exercise: String) {
|
init(duration: Int, _ exercise: String) {
|
||||||
self.workoutItemType = .exercise
|
self.workoutItemType = .exerciseWithDuration
|
||||||
self.name = exercise
|
self.name = exercise
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
self.exercise = Exercise(exercise)
|
self.exercise = Exercise(exercise, .duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(exercise: Exercise) {
|
init(exercise: Exercise) {
|
||||||
self.workoutItemType = .exercise
|
self.workoutItemType = .exerciseWithReps
|
||||||
self.name = exercise.name
|
self.name = exercise.name
|
||||||
self.exercise = exercise
|
self.exercise = exercise
|
||||||
}
|
}
|
||||||
@@ -74,15 +82,6 @@ final class WorkoutItem: Nameable, Positionable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WorkoutItem {
|
|
||||||
enum WorkoutItemType: Codable {
|
|
||||||
case set
|
|
||||||
case workout
|
|
||||||
case exercise
|
|
||||||
case rest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension WorkoutItem {
|
extension WorkoutItem {
|
||||||
static let sampleDataRecommendedRoutine: [WorkoutItem] = {
|
static let sampleDataRecommendedRoutine: [WorkoutItem] = {
|
||||||
var exercises = [WorkoutItem]()
|
var exercises = [WorkoutItem]()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ final class WorkoutSession: Nameable {
|
|||||||
var name = ""
|
var name = ""
|
||||||
var workout: Workout? {
|
var workout: Workout? {
|
||||||
didSet {
|
didSet {
|
||||||
self.name = workout?.name ?? ""
|
self.name = workout?.name ?? "Unknown Workout"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,22 +26,27 @@ final class WorkoutSession: Nameable {
|
|||||||
|
|
||||||
// Time
|
// Time
|
||||||
var creationDate = Date.now
|
var creationDate = Date.now
|
||||||
|
// My workout session started at:
|
||||||
var startDate: Date? = nil
|
var startDate: Date? = nil
|
||||||
|
// My workout session was completed at:
|
||||||
var stopDate: Date? = nil
|
var stopDate: Date? = nil
|
||||||
|
// My workout session took me x seconds.
|
||||||
var duration: TimeInterval? = nil
|
var duration: TimeInterval? = nil
|
||||||
|
// My workout session is completed and moved to the workout log.
|
||||||
var isCompleted: Bool = false
|
var isCompleted: Bool = false
|
||||||
|
|
||||||
// Exercise Progress
|
// Exercise Progress
|
||||||
var currentExercise = 0
|
var currentExercise = 0
|
||||||
|
|
||||||
init () { }
|
init () { }
|
||||||
|
|
||||||
func isActive() -> Bool {
|
func isActive() -> Bool {
|
||||||
return startDate != nil && stopDate == nil
|
return startDate != nil && stopDate == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -- Workout Controls
|
// MARK: -- Workout Controls
|
||||||
func start() {
|
func start(with workout: Workout) {
|
||||||
|
self.workout = workout
|
||||||
startDate = Date.now
|
startDate = Date.now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ struct WorkoutDetail: View {
|
|||||||
footer: Text("Drag and drop to re-arrange or swipe to delete exercises.")) {
|
footer: Text("Drag and drop to re-arrange or swipe to delete exercises.")) {
|
||||||
ForEach(workout.getWorkoutItems()) { workoutItem in
|
ForEach(workout.getWorkoutItems()) { workoutItem in
|
||||||
switch workoutItem.workoutItemType {
|
switch workoutItem.workoutItemType {
|
||||||
case .exercise:
|
case .exerciseWithReps:
|
||||||
|
ExerciseListItem(workout, workoutItem)
|
||||||
|
case .exerciseWithDuration:
|
||||||
ExerciseListItem(workout, workoutItem)
|
ExerciseListItem(workout, workoutItem)
|
||||||
case .set:
|
case .set:
|
||||||
SetListItem(workout, workoutItem)
|
SetListItem(workout, workoutItem)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ struct WorkoutItemLibrarySheet: View {
|
|||||||
Section(header: Text("Utilities")) {
|
Section(header: Text("Utilities")) {
|
||||||
AddItemButton(label: "Set") {
|
AddItemButton(label: "Set") {
|
||||||
addWorkoutItemtoWorkout(WorkoutItem(set: [
|
addWorkoutItemtoWorkout(WorkoutItem(set: [
|
||||||
WorkoutItem(exercise: Exercise("Set item 1")),
|
WorkoutItem(exercise: Exercise("Set item 1", .reps)),
|
||||||
WorkoutItem(exercise: Exercise("Set item 2"))
|
WorkoutItem(exercise: Exercise("Set item 2", .reps))
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
AddItemButton(label: "Rest") {
|
AddItemButton(label: "Rest") {
|
||||||
|
|||||||
Reference in New Issue
Block a user