add ExerciseEditor, Picker skeletons, AutocompleteTextfield

This commit is contained in:
Felix Förtsch
2024-09-23 11:41:34 +02:00
parent 41b97964c4
commit 4a42fc6c33
20 changed files with 527 additions and 164 deletions

View File

@@ -20,32 +20,22 @@ struct ActiveWorkoutSession: View {
List {
Section(header: Text("Workout"), footer: Text(activeWorkoutSession?.creationDate.ISO8601Format() ?? "Unknown Date")) {
NavigationLink(destination: {
ItemPicker<Workout>(items: workouts, selectedItem: $activeWorkout)
ItemPicker<Workout>(selectedItem: $activeWorkout, items: workouts)
}) {
Text(activeWorkout?.name ?? "Select Workout")
}
.onChange(of: activeWorkout) { _, newWorkout in
if let workout = newWorkout {
activeWorkoutId = workout.id.uuidString
activeWorkoutSession?.workout = workout
if let newWorkout {
activeWorkoutId = newWorkout.id.uuidString
activeWorkoutSession?.workout = newWorkout
}
}
}
if let activeWorkout = activeWorkout {
if let activeWorkout {
Section(header: Text("Exercises")) {
ForEach(getActiveWorkoutItems(activeWorkout: activeWorkout)) { workoutItem in
HStack {
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)
}
}
ActiveWorkoutSessionListItem(workoutItem: workoutItem)
}
}
} else {
@@ -54,14 +44,14 @@ struct ActiveWorkoutSession: View {
}
}
}
// MARK: -- Workout Controls
// MARK: Workout Controls
if (isWorkingOut) {
if activeWorkoutSession != nil {
ActiveWorkoutSessionControls(
session: Binding(
get: { self.activeWorkoutSession! },
set: { self.activeWorkoutSession = $0 }
))
get: { self.activeWorkoutSession! },
set: { self.activeWorkoutSession = $0 }
))
}
}
}
@@ -78,11 +68,14 @@ struct ActiveWorkoutSession: View {
}
}
.bold()
.fontDesign(.rounded)
.tint(.red)
} else {
Button(action: {
isWorkingOut = true
activeWorkoutSession?.start()
if let activeWorkout {
activeWorkoutSession?.start(with: activeWorkout)
}
}) {
HStack {
Image(systemName: "play.fill")
@@ -90,11 +83,11 @@ struct ActiveWorkoutSession: View {
}
}
.bold()
.fontDesign(.rounded)
.tint(.green)
}
}
.onAppear {
// Load the active workout session and workout onAppear
if let activeWorkoutSession = getItem(from: workoutSessions, by: activeWorkoutSessionId) {
self.activeWorkoutSession = activeWorkoutSession
if let workout = getItem(from: workouts, by: activeWorkoutId) {
@@ -132,6 +125,9 @@ struct ActiveWorkoutSession: View {
NavigationStack {
ActiveWorkoutSession(activeWorkout: activeWorkout)
}
.onAppear {
Defaults.shared.isWorkingOut = false
}
.modelContainer(SampleData.shared.modelContainer)
}

View File

@@ -15,10 +15,11 @@ struct ActiveWorkoutSessionControls: View {
VStack {
HStack {
Text(session.getCurrentTodo())
.font(.system(size: 24, weight: .bold, design: .rounded))
}
ProgressView("",
value: session.getCurrentExerciseIndex() + 1,
total: session.getTotalExerciseCount()
value: session.getCurrentExerciseIndex(),
total: session.getTotalExerciseCount() - 1
)
HStack {
Button(action: {

View File

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

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

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

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

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

View File

@@ -27,6 +27,6 @@ struct ExerciseListItem: View {
#Preview {
List {
ExerciseListItem(Workout(name: "RR"), WorkoutItem(exercise: Exercise("Push-ups")))
ExerciseListItem(Workout(name: "RR"), WorkoutItem(exercise: Exercise("Push-ups", .reps)))
}
}

View File

@@ -1,16 +1,19 @@
//
// ItemPicker.swift
// WorkoutsPlus
// Advanced Version of Picker.pickerStyle(NavigationLinkPickerStyle()) that's searchable and has a ContentUnavailableView
//
// Created by Felix Förtsch on 10.09.24.
//
import SwiftUI
import SwiftData
struct ItemPicker<Item: Nameable>: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedItem: Item?
var items: [Item]
@State private var searchText = ""
var filteredItems: [Item] {
if searchText.isEmpty {
@@ -20,9 +23,6 @@ struct ItemPicker<Item: Nameable>: View {
}
}
var items: [Item]
@Binding var selectedItem: Item?
var body: some View {
List {
ForEach(filteredItems) { item in
@@ -51,10 +51,18 @@ struct ItemPicker<Item: Nameable>: View {
}
}
#Preview {
@Previewable @State var selectedWorkout: Workout? = nil
NavigationStack {
ItemPicker<Workout>(items: Workout.sampleData, selectedItem: $selectedWorkout)
}
.modelContainer(SampleData.shared.modelContainer)
private struct Item: Nameable {
var id = UUID()
var name: String
}
#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")
])
}
}

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

View File

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

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

View File

@@ -12,7 +12,7 @@ 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 newExercise: Exercise = Exercise("", .reps)
@State private var newExerciseName: String = ""
@State private var isAddingExercise: Bool = false
@FocusState private var isInputFieldFocused: Bool
@@ -26,37 +26,33 @@ struct ExerciseLibrary: View {
}
}
// TODO: Add search bar to the top
var body: some View {
Group {
List {
ForEach(filteredItems) { exercise in
NavigationLink {
ExerciseDetail(exercise: exercise)
} label: {
Text(exercise.name)
// TODO: show exercise.metric in gray (eg Dips = reps, Intervall = time or = distance?)
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)
}
}
.onDelete(perform: deleteExercise)
if isAddingExercise {
TextField("New Exercise", text: $newExerciseName, onCommit: {
newExercise.name = newExerciseName
save(exercise: newExercise)
isAddingExercise = false
})
.textInputAutocapitalization(.words)
.focused($isInputFieldFocused)
Section {
AddItemButton(label: "Exercise", action: addExercise)
}
if filteredItems.isEmpty {
ContentUnavailableView.search(text: searchText)
}
AddItemButton(label: "Exercise", action: addExercise)
}
.searchable(text: $searchText)
}
.navigationTitle("Exercises")
.toolbar {
@@ -64,27 +60,20 @@ struct ExerciseLibrary: View {
EditButton()
}
ToolbarItem {
Button(action: {}) {
Button(action: addExercise) {
Image(systemName: "plus")
}
}
}
}
private func addExercise() {
withAnimation {
newExercise = Exercise("")
newExerciseName = ""
isAddingExercise = true
isInputFieldFocused = true
.sheet(isPresented: $isAddingExercise) {
ExerciseEditor(isPresentedAsSheet: true)
}
}
private func save(exercise: Exercise) {
if !exercise.name.isEmpty {
modelContext.insert(exercise)
try? modelContext.save()
private func addExercise() {
withAnimation {
isAddingExercise = true
isInputFieldFocused = true
}
}

View File

@@ -13,42 +13,76 @@ final class Exercise: Nameable {
static var systemImage = "figure.run"
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
// var metric: String = "reps"
// var exerciseDescription: ExerciseDescription?
// Performing a push-up correctly has the following form cues
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 timestamp: Date = Date.now
init(_ name: String = "") {
init(_ name: String, _ metric: ExerciseMetric) {
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 {
static let sampleDataRecommendedRoutine: [Exercise] = [
Exercise("Shoulder Band Warm-up"),
Exercise("Squat Sky Reaches"),
Exercise("GMB Wrist Prep"),
Exercise("Dead Bugs"),
Exercise("Pull-up Progression"),
Exercise("Dip Progression"),
Exercise("Squat Progression"),
Exercise("Hinge Progression"),
Exercise("Row Progression"),
Exercise("Push-up Progression"),
Exercise("Handstand Practice"),
Exercise("Support Practice")
Exercise("Shoulder Band Warm-up", .duration),
Exercise("Squat Sky Reaches", .reps),
Exercise("GMB Wrist Prep", .duration),
Exercise("Dead Bugs", .reps),
Exercise("Pull-up Progression", .reps),
Exercise("Dip Progression", .reps),
Exercise("Squat Progression", .reps),
Exercise("Hinge Progression", .reps),
Exercise("Row Progression", .reps),
Exercise("Push-up Progression", .reps),
Exercise("Handstand Practice", .duration),
Exercise("Support Practice", .duration)
]
static let sampleDataRings: [Exercise] = [
Exercise("Dips"),
Exercise("Chin-ups"),
Exercise("Push-ups"),
Exercise("Inverted Rows"),
Exercise("Hanging Knee Raises"),
Exercise("Pistol Squats"),
Exercise("Hanging Leg Curls"),
Exercise("Sissy Squats"),
Exercise("Dips", .reps),
Exercise("Chin-ups", .reps),
Exercise("Push-ups", .reps),
Exercise("Inverted Rows", .reps),
Exercise("Hanging Knee Raises", .reps),
Exercise("Pistol Squats", .reps),
Exercise("Hanging Leg Curls", .reps),
Exercise("Sissy Squats", .reps),
]
}

View File

@@ -9,10 +9,10 @@ import Foundation
protocol Nameable: Identifiable, Hashable {
var id: UUID { get }
var name: String { get }
var name: String { get set }
}
protocol Positionable: Identifiable {
var id: UUID { get }
var position: Int { get }
var position: Int { get set }
}

View File

@@ -13,14 +13,17 @@ 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
// Icon
var workoutIconSystemName = "figure.run"
var workoutIconColorName = ColorName.black
// Other properties and methods
var timestamp = Date.now
// 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] {
@@ -134,5 +137,4 @@ extension Workout {
return [rr, rings]
}()
}

View File

@@ -15,6 +15,14 @@ final class WorkoutItem: Nameable, Positionable {
var workout: Workout?
var workoutItemType: WorkoutItemType
enum WorkoutItemType: Codable {
case set
case workout
case exerciseWithReps
case exerciseWithDuration
case rest
}
var position: Int = 0
var reps: Int = 0
@@ -31,21 +39,21 @@ final class WorkoutItem: Nameable, Positionable {
// Exercise
init(reps: Int, _ exercise: String) {
self.workoutItemType = .exercise
self.workoutItemType = .exerciseWithReps
self.name = exercise
self.reps = reps
self.exercise = Exercise(exercise)
self.exercise = Exercise(exercise, .reps)
}
init(duration: Int, _ exercise: String) {
self.workoutItemType = .exercise
self.workoutItemType = .exerciseWithDuration
self.name = exercise
self.duration = duration
self.exercise = Exercise(exercise)
self.exercise = Exercise(exercise, .duration)
}
init(exercise: Exercise) {
self.workoutItemType = .exercise
self.workoutItemType = .exerciseWithReps
self.name = exercise.name
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 {
static let sampleDataRecommendedRoutine: [WorkoutItem] = {
var exercises = [WorkoutItem]()

View File

@@ -14,7 +14,7 @@ final class WorkoutSession: Nameable {
var name = ""
var workout: Workout? {
didSet {
self.name = workout?.name ?? ""
self.name = workout?.name ?? "Unknown Workout"
}
}
@@ -26,22 +26,27 @@ final class WorkoutSession: Nameable {
// Time
var creationDate = Date.now
// My workout session started at:
var startDate: Date? = nil
// 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
init () { }
func isActive() -> Bool {
return startDate != nil && stopDate == nil
}
// MARK: -- Workout Controls
func start() {
func start(with workout: Workout) {
self.workout = workout
startDate = Date.now
}

View File

@@ -30,7 +30,9 @@ struct WorkoutDetail: View {
footer: Text("Drag and drop to re-arrange or swipe to delete exercises.")) {
ForEach(workout.getWorkoutItems()) { workoutItem in
switch workoutItem.workoutItemType {
case .exercise:
case .exerciseWithReps:
ExerciseListItem(workout, workoutItem)
case .exerciseWithDuration:
ExerciseListItem(workout, workoutItem)
case .set:
SetListItem(workout, workoutItem)

View File

@@ -22,8 +22,8 @@ struct WorkoutItemLibrarySheet: View {
Section(header: Text("Utilities")) {
AddItemButton(label: "Set") {
addWorkoutItemtoWorkout(WorkoutItem(set: [
WorkoutItem(exercise: Exercise("Set item 1")),
WorkoutItem(exercise: Exercise("Set item 2"))
WorkoutItem(exercise: Exercise("Set item 1", .reps)),
WorkoutItem(exercise: Exercise("Set item 2", .reps))
]))
}
AddItemButton(label: "Rest") {