add always sorted [WorkoutItem], ContentUnavailableView to searches, SampleData import, refactor WorkoutItem init

This commit is contained in:
Felix Förtsch
2024-09-18 15:05:05 +02:00
parent 0a400ff349
commit 41b97964c4
17 changed files with 295 additions and 122 deletions

View File

@@ -5,23 +5,24 @@ struct ActiveWorkoutSession: View {
@Environment(\.modelContext) private var modelContext
@Default(\.isWorkingOut) var isWorkingOut
@State var isTimerRunning: Bool = true
@Query(sort: \WorkoutSession.name) private var workoutSessions: [WorkoutSession]
@State private var activeWorkoutSession: WorkoutSession?
@Query(sort: \WorkoutSession.name) var workoutSessions: [WorkoutSession]
@State var activeWorkoutSession: WorkoutSession?
@Default(\.activeWorkoutSessionId) var activeWorkoutSessionId
@Query(sort: \Workout.name) private var workouts: [Workout]
@State private var activeWorkout: Workout?
@Query(sort: \Workout.name) var workouts: [Workout]
@State var activeWorkout: Workout?
@Default(\.activeWorkoutId) var activeWorkoutId
var body: some View {
VStack {
List {
Section(footer: Text(activeWorkoutSession?.creationDate.ISO8601Format() ?? "Unknown Date")) {
Section(header: Text("Workout"), footer: Text(activeWorkoutSession?.creationDate.ISO8601Format() ?? "Unknown Date")) {
NavigationLink(destination: {
ItemPicker<Workout>(items: workouts, selectedItem: $activeWorkout)
}) {
Text(activeWorkout?.name ?? "Select your next Workout")
Text(activeWorkout?.name ?? "Select Workout")
}
.onChange(of: activeWorkout) { _, newWorkout in
if let workout = newWorkout {
@@ -33,7 +34,7 @@ struct ActiveWorkoutSession: View {
if let activeWorkout = activeWorkout {
Section(header: Text("Exercises")) {
ForEach(activeWorkout.workoutItems.sorted(by: { $0.position < $1.position })) { workoutItem in
ForEach(getActiveWorkoutItems(activeWorkout: activeWorkout)) { workoutItem in
HStack {
Text(String(workoutItem.reps))
Text(workoutItem.name)
@@ -47,65 +48,51 @@ struct ActiveWorkoutSession: View {
}
}
}
}
}
if true { // This condition should be more meaningful.
VStack {
HStack {
Text(String(workoutSessions.count))
Button(action: {
// save(workoutSession: activeWorkoutSession)
}) {
Text("Save Session")
}
.buttonStyle(.borderedProminent)
} else {
ContentUnavailableView {
Label("Select a Workout", systemImage: "arrow.up")
}
}
}
// MARK: -- Workout Controls
Group {
if activeWorkoutSession?.workout != nil {
if isWorkingOut {
// MARK: -- Stop Workout
VStack {
ProgressView("", value: 10, total: 100)
TimerView(isActive: $isWorkingOut)
.font(.title)
.bold()
Button(action: {
isWorkingOut = false
activeWorkoutSession?.startWorkoutSession()
}) {
HStack {
Image(systemName: "stop.fill")
Text("Stop Workout")
}
}
.buttonStyle(.borderedProminent)
.bold()
.tint(.red)
}
} else {
// MARK: -- Start Workout
Button(action: {
isWorkingOut = true
activeWorkoutSession?.stopWorkoutSession()
}) {
HStack {
Image(systemName: "play.fill")
Text("Start Workout")
}
}
.buttonStyle(.borderedProminent)
.bold()
.tint(.green)
}
if (isWorkingOut) {
if activeWorkoutSession != nil {
ActiveWorkoutSessionControls(
session: Binding(
get: { self.activeWorkoutSession! },
set: { self.activeWorkoutSession = $0 }
))
}
}
}
.navigationTitle("Workout Session")
.navigationTitle("Session")
.toolbar {
if (isWorkingOut) {
Button(action: {
isWorkingOut = false
activeWorkoutSession?.stop()
}) {
HStack {
Image(systemName: "stop.fill")
Text("Stop")
}
}
.bold()
.tint(.red)
} else {
Button(action: {
isWorkingOut = true
activeWorkoutSession?.start()
}) {
HStack {
Image(systemName: "play.fill")
Text("Start")
}
}
.bold()
.tint(.green)
}
}
.onAppear {
// Load the active workout session and workout onAppear
if let activeWorkoutSession = getItem(from: workoutSessions, by: activeWorkoutSessionId) {
@@ -128,15 +115,22 @@ struct ActiveWorkoutSession: View {
self.activeWorkoutSession = newWorkoutSession
}
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 {
#Preview("RR Selected") {
@Previewable @State var activeWorkout = Workout.sampleData.first!
NavigationStack {
ActiveWorkoutSession()
ActiveWorkoutSession(activeWorkout: activeWorkout)
}
.modelContainer(SampleData.shared.modelContainer)
}

View File

@@ -0,0 +1,82 @@
//
// 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())
}
ProgressView("",
value: session.getCurrentExerciseIndex() + 1,
total: session.getTotalExerciseCount()
)
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()
activeWorkoutSession.workout = 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
}
}
#Preview("isWorkingOut = false") {
@Previewable @State var activeWorkoutSession = WorkoutSession()
return ActiveWorkoutSessionControls(session: $activeWorkoutSession)
.onAppear() {
Defaults.shared.isWorkingOut = false
}
}

View File

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

View File

@@ -43,7 +43,7 @@ struct SetListItem: View {
.foregroundStyle(.green)
}
}
ForEach(set.workoutItems) { workoutItem in
ForEach(set.set) { workoutItem in
ExerciseListItem(workout, workoutItem)
.padding(.leading)
}
@@ -55,17 +55,17 @@ struct SetListItem: View {
}
#Preview {
let set = WorkoutItem(workoutItems: [
WorkoutItem(10, "Squat"),
WorkoutItem(10, "Squat"),
WorkoutItem(10, "Squat")])
let set = WorkoutItem(set: [
WorkoutItem(reps: 10, "Squat"),
WorkoutItem(reps: 10, "Squat"),
WorkoutItem(reps: 10, "Squat")])
List {
SetListItem(Workout(name: "RR"), set)
}
}
#Preview("Empty Database") {
let set = WorkoutItem(workoutItems: [])
let set = WorkoutItem(set: [])
List {
SetListItem(Workout(name: "RR"), set)
}

View File

@@ -33,11 +33,11 @@ struct StepperListItem: View {
}
#Preview {
@Previewable @State var value = 8
@Previewable @State var itemValue = 8
List {
StepperListItem(itemName: "Short Name", itemValue: $value)
StepperListItem(itemName: "Very very very very long name with whitespace", itemValue: $value)
StepperListItem(itemName: "Veryveryverylonglonglonglongnamewithoutwithwhitespace", itemValue: $value)
StepperListItem(itemName: "Short Name", itemValue: $itemValue)
StepperListItem(itemName: "Very very very very long name with whitespace", itemValue: $itemValue)
StepperListItem(itemName: "Veryveryverylonglonglonglongnamewithoutwithwhitespace", itemValue: $itemValue)
}
}

View File

@@ -10,14 +10,18 @@ import SwiftUI
struct TimerView: View {
@Binding var isActive: Bool
@State private var time = 0
var startDate: Date?
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text("\(time)")
.onReceive(timer) { _ in
if isActive {
self.time += 1
if let startDate = startDate {
self.time = Int(Date.now.timeIntervalSince(startDate))
} else {
self.time += 1
}
}
}
.onDisappear {
@@ -26,6 +30,9 @@ struct TimerView: View {
}
}
#Preview {
TimerView(isActive: .constant(true), startDate: Date().addingTimeInterval(-3600)) // Example startDate 1 hour ago
}
#Preview {
TimerView(isActive: .constant(true))

View File

@@ -59,8 +59,6 @@ struct ContentView: View {
NavigationLink(destination: WorkoutLog()) {
Label("Workout Log", systemImage: "calendar.badge.clock")
}
}
Section {
NavigationLink(destination: WorkoutLibrary()) {
Label("Workouts", systemImage: "figure.run.square.stack")
}
@@ -69,12 +67,14 @@ struct ContentView: View {
}
}
Section {
Label("Add Food", systemImage: "plus")
Label("Food Log", systemImage: "list.bullet.clipboard")
Label("Food Library", systemImage: "fork.knife")
}
Section {
NavigationLink(destination: Settings()) {
Label("Settings", systemImage: "gear")
}
}
Section(header: Text("Debug")) {
NavigationLink(destination: DebugList()) {
Label("Debug", systemImage: "hammer")
}

View File

@@ -9,6 +9,8 @@ import SwiftUI
import SwiftData
struct DebugList: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Exercise.name) private var exercises: [Exercise]
@Query(sort: \Workout.name) private var workouts: [Workout]
@Query(sort: \WorkoutItem.name) private var workoutItems: [WorkoutItem]
@@ -16,6 +18,9 @@ struct DebugList: View {
var body: some View {
List {
Button(action: {SampleData.insertSampleData(into: modelContext)} ) {
Text("Insert Sample Data")
}
Section(header: Text("Exercises")) {
ForEach(exercises) { exercise in
DebugListItem(item: exercise)

View File

@@ -24,13 +24,15 @@ class SampleData {
do {
modelContainer = try ModelContainer(for: schema, configurations: [modelConfiguration])
insertSampleData()
SampleData.insertSampleData(into: context)
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
func insertSampleData() {
}
extension SampleData {
static func insertSampleData(into context: ModelContext) {
for exercise in Exercise.sampleDataRecommendedRoutine {
context.insert(exercise)
}

View File

@@ -37,6 +37,7 @@ struct ExerciseLibrary: View {
ExerciseDetail(exercise: exercise)
} label: {
Text(exercise.name)
// TODO: show exercise.metric in gray (eg Dips = reps, Intervall = time or = distance?)
}
}
.onDelete(perform: deleteExercise)
@@ -49,15 +50,24 @@ struct ExerciseLibrary: View {
.textInputAutocapitalization(.words)
.focused($isInputFieldFocused)
}
if filteredItems.isEmpty {
ContentUnavailableView.search(text: searchText)
}
AddItemButton(label: "Exercise", action: addExercise)
}
.searchable(text: $searchText)
}
.navigationTitle("Exercises")
.toolbar {
ToolbarItem {
EditButton()
}
ToolbarItem {
Button(action: {}) {
Image(systemName: "plus")
}
}
}
}

View File

@@ -22,7 +22,10 @@ final class Workout: Nameable, Hashable {
// Other properties and methods
var timestamp = Date.now
@Relationship(deleteRule: .cascade) var workoutItems: [WorkoutItem] = []
@Relationship(deleteRule: .cascade) private var workoutItems: [WorkoutItem] = []
func getWorkoutItems() -> [WorkoutItem] {
return workoutItems.sorted { $0.position < $1.position }
}
init(name: String) {
self.name = name
@@ -113,9 +116,9 @@ extension Workout {
}
extension Workout {
private convenience init(name: String, exercises: [WorkoutItem]) {
private convenience init(name: String, workoutItems: [WorkoutItem]) {
self.init(name: name)
self.workoutItems = exercises
self.workoutItems = workoutItems
}
static let sampleData: [Workout] = {

View File

@@ -17,7 +17,8 @@ final class WorkoutItem: Nameable, Positionable {
var workoutItemType: WorkoutItemType
var position: Int = 0
var reps: Int = 8
var reps: Int = 0
var duration: Int = 0
// EXERCISE
var exercise: Exercise?
@@ -28,36 +29,49 @@ final class WorkoutItem: Nameable, Positionable {
// TODO: Deload items -> Think about how to model deload/sick/rest/holiday week
init(_ reps: Int, _ exercise: String) {
// Exercise
init(reps: Int, _ exercise: String) {
self.workoutItemType = .exercise
self.name = exercise
self.reps = reps
self.exercise = Exercise(exercise)
}
init(from exercise: Exercise) {
init(duration: Int, _ exercise: String) {
self.workoutItemType = .exercise
self.name = exercise
self.duration = duration
self.exercise = Exercise(exercise)
}
init(exercise: Exercise) {
self.workoutItemType = .exercise
self.name = exercise.name
self.exercise = exercise
}
// SET
var workoutItems: [WorkoutItem] = []
var set: [WorkoutItem] = []
init(workoutItems: [WorkoutItem] = []) {
self.name = "Set"
init(set: [WorkoutItem] = []) {
self.workoutItemType = .set
self.name = "Set"
self.reps = 3
workoutItems.forEach(addChild)
set.forEach(addChild)
}
func addChild(_ child: WorkoutItem) {
if self.workoutItemType == .set {
self.workoutItems.append(child)
self.set.append(child)
}
}
// PAUSE
init (rest: Int) {
self.workoutItemType = .rest
self.name = "Rest"
self.duration = rest
}
}
extension WorkoutItem {
@@ -65,6 +79,7 @@ extension WorkoutItem {
case set
case workout
case exercise
case rest
}
}
@@ -73,7 +88,7 @@ extension WorkoutItem {
var exercises = [WorkoutItem]()
for exercise in Exercise.sampleDataRecommendedRoutine {
exercises.append(WorkoutItem(from: exercise))
exercises.append(WorkoutItem(exercise: exercise))
}
// var set = WorkoutItem(workoutItems: [
@@ -89,7 +104,7 @@ extension WorkoutItem {
var exercises = [WorkoutItem]()
for exercise in Exercise.sampleDataRings {
exercises.append(WorkoutItem(from: exercise))
exercises.append(WorkoutItem(exercise: exercise))
}
return exercises

View File

@@ -18,30 +18,62 @@ final class WorkoutSession: Nameable {
}
}
// State
// var isPaused: Bool
// var isCancelled: Bool
// var isDeleted: Bool
// var isSynced: Bool
// Time
var creationDate = Date.now
private var startDate: Date? = nil
private var stopDate: Date? = nil
private var duration: TimeInterval? = nil
var startDate: Date? = nil
var stopDate: Date? = nil
var duration: TimeInterval? = nil
var isCompleted: Bool = false
// Exercise Progress
var currentExercise = 0
init () { }
func isActive() -> Bool {
return startDate != nil && stopDate == nil
}
func startWorkoutSession() {
self.startDate = Date.now
// MARK: -- Workout Controls
func start() {
startDate = Date.now
}
func stopWorkoutSession() {
guard let startDate else { return }
self.stopDate = Date.now
self.duration = self.stopDate!.timeIntervalSince(startDate)
func pause() {
// TODO: Implement proper Pause
}
func elapsedTime(since startDate: Date) -> String {
// Call stop() to terminate the workout.
func stop() {
guard let startDate = startDate else { return }
isCompleted = true
stopDate = Date.now
duration = stopDate!.timeIntervalSince(startDate)
}
func prevExercise() {
guard workout != nil else { return }
if currentExercise > 0 {
currentExercise -= 1
}
}
func nextExercise() {
guard let workout = workout else { return }
if currentExercise < workout.getWorkoutItems().count - 1 {
currentExercise += 1
}
}
// MARK: -- Workout Information
func getFormattedDuration() -> String {
guard let startDate = startDate else { return "00:00:00" }
let elapsedTime = Date.now.timeIntervalSince(startDate)
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
@@ -49,10 +81,26 @@ final class WorkoutSession: Nameable {
return formatter.string(from: elapsedTime) ?? "00:00:00"
}
// var isCompleted: Bool
// var isPaused: Bool
// var isCancelled: Bool
// var isDeleted: Bool
// var isSynced: Bool
init () { }
func getTotalExerciseCount() -> Double {
guard let workout = workout else { return 0 }
return Double(workout.getWorkoutItems().count)
}
func getCurrentExerciseIndex() -> Double {
return Double(currentExercise)
}
func getCurrentTodo() -> String {
return getCurrentExerciseMetric() + " " + getCurrentExerciseName()
}
func getCurrentExerciseName() -> String {
guard let workout = workout else { return "Unknown Workout" }
return workout.getWorkoutItems()[Int(currentExercise)].name
}
func getCurrentExerciseMetric() -> String {
guard let workout = workout else { return "Unknown Workout" }
return String(workout.getWorkoutItems()[Int(currentExercise)].reps)
}
}

View File

@@ -8,8 +8,8 @@
- Progression-Fotos: als eigene App? -> Generalisierung zu "Foto-Track"
## Workouts
- time-based: 60 s
- rep-based: 3x -> TODO: Russian System (333 3333 33333, 444 4444 444444, usw -> https://www.youtube.com/watch?v=GZmtjlPTU1g) abbilden (ob System flexibel genug ist)
- TODO: Russian System (333 3333 33333, 444 4444 444444, usw -> https://www.youtube.com/watch?v=GZmtjlPTU1g) abbilden (ob System flexibel genug ist)
- Loops
## Trainingspläne

View File

@@ -28,8 +28,7 @@ struct WorkoutDetail: View {
Section(
header: Text("Exercises"),
footer: Text("Drag and drop to re-arrange or swipe to delete exercises.")) {
ForEach(workout.workoutItems
.sorted(by: { $0.position < $1.position})) { workoutItem in
ForEach(workout.getWorkoutItems()) { workoutItem in
switch workoutItem.workoutItemType {
case .exercise:
ExerciseListItem(workout, workoutItem)
@@ -37,6 +36,8 @@ struct WorkoutDetail: View {
SetListItem(workout, workoutItem)
case .workout:
Text(workoutItem.name)
case .rest:
Text(workoutItem.name)
}
}
.onDelete(perform: deleteWorkoutItem)
@@ -75,7 +76,7 @@ struct WorkoutDetail: View {
private func deleteWorkoutItem(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(workout.workoutItems[index])
modelContext.delete(workout.getWorkoutItems()[index])
}
try? modelContext.save()
}

View File

@@ -21,17 +21,20 @@ struct WorkoutItemLibrarySheet: View {
List {
Section(header: Text("Utilities")) {
AddItemButton(label: "Set") {
addWorkoutItemtoWorkout(WorkoutItem(workoutItems: [
WorkoutItem(from: Exercise("Set item 1")),
WorkoutItem(from: Exercise("Set item 2"))
addWorkoutItemtoWorkout(WorkoutItem(set: [
WorkoutItem(exercise: Exercise("Set item 1")),
WorkoutItem(exercise: 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(from: exercise)
let workoutItem = WorkoutItem(exercise: exercise)
addWorkoutItemtoWorkout(workoutItem)
}
}

View File

@@ -38,6 +38,9 @@ struct WorkoutLibrary: View {
}
}
.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: {