fix repetitive save of WorkoutItem, add: Onboarding, Defaults, Settings, Trainer/Trainee skeletons, reorder files, remove all Bindable

This commit is contained in:
Felix Förtsch
2024-09-04 18:44:28 +02:00
parent 0905ea7d3f
commit d82d0cd9fa
25 changed files with 426 additions and 134 deletions

View File

@@ -12,9 +12,7 @@ struct AddItemButton: View {
var action: () -> Void var action: () -> Void
var body: some View { var body: some View {
Button(action: { Button(action: action) {
action()
}) {
HStack { HStack {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
.foregroundStyle(.green) .foregroundStyle(.green)

View File

@@ -20,23 +20,7 @@ struct ExerciseListItem: View {
Button(action: { Button(action: {
// workout.addExercise(from: exercise) // workout.addExercise(from: exercise)
}) { }) {
HStack { StepperListItem(itemName: exercise.name, itemValue: $exercise.reps)
Text(String(exercise.reps))
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(width: 20, height: 10)
.padding(8)
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(exercise.name)
.foregroundStyle(.black)
Spacer()
Stepper(
value: $exercise.reps,
in: 0...100,
step: 1
) {}
}
} }
} }
} }

View File

@@ -0,0 +1,43 @@
//
// StepperListItem.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 04.09.24.
//
import SwiftUI
struct StepperListItem: View {
@State var itemName: String
@Binding var itemValue: Int
var body: some View {
Stepper(
value: $itemValue,
in: 0...100,
step: 1
) {
HStack {
Text(String(itemValue))
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
.frame(width: 20, height: 10)
.padding(8)
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(itemName)
.foregroundStyle(.black)
}
}
}
}
#Preview {
@Previewable @State var value = 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)
}
}

View File

@@ -0,0 +1,45 @@
//
// Defaults.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 04.09.24.
//
import Foundation
import SwiftUI
public class Defaults: ObservableObject {
@AppStorage("isFirstAppStart") public var isFirstAppStart = true
@AppStorage("isOnboarding") public var isOnboarding = true
@AppStorage("userId") public var userId = UUID().uuidString
@AppStorage("sets") public var sets = 8
@AppStorage("reps") public var reps = 8
public static let shared = Defaults()
}
@propertyWrapper
public struct Default<T>: DynamicProperty {
@ObservedObject private var defaults: Defaults
private let keyPath: ReferenceWritableKeyPath<Defaults, T>
public init(_ keyPath: ReferenceWritableKeyPath<Defaults, T>, defaults: Defaults = .shared) {
self.keyPath = keyPath
self.defaults = defaults
}
public var wrappedValue: T {
get { defaults[keyPath: keyPath] }
nonmutating set { defaults[keyPath: keyPath] = newValue }
}
public var projectedValue: Binding<T> {
Binding(
get: { defaults[keyPath: keyPath] },
set: { value in
defaults[keyPath: keyPath] = value
}
)
}
}

View File

@@ -10,10 +10,11 @@ import SwiftData
struct ContentView: View { struct ContentView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Default(\.isOnboarding) var isOnboarding
var body: some View { var body: some View {
TabView { TabView {
WorkoutLog() // WorkoutLog()
WorkoutLibrary() WorkoutLibrary()
.tabItem { .tabItem {
Image(systemName: "figure.run.square.stack") Image(systemName: "figure.run.square.stack")
@@ -24,7 +25,7 @@ struct ContentView: View {
Image(systemName: "figure.run") Image(systemName: "figure.run")
Text("Exercises") Text("Exercises")
} }
DebugExerciseList() DebugList()
.tabItem { .tabItem {
Image(systemName: "hammer") Image(systemName: "hammer")
Text("Debug") Text("Debug")
@@ -35,6 +36,7 @@ struct ContentView: View {
Text("Settings") Text("Settings")
} }
} }
.sheet(isPresented: $isOnboarding) { Onboarding() }
} }
} }

View File

@@ -0,0 +1,64 @@
//
// DebugList.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 30.08.24.
//
import SwiftUI
import SwiftData
struct DebugList: View {
@Query(sort: \Exercise.name) private var exercises: [Exercise]
@Query(sort: \Workout.name) private var workouts: [Workout]
@Query(sort: \WorkoutItem.name) private var workoutItems: [WorkoutItem]
var body: some View {
List {
Section(header: Text("Exercises")) {
ForEach(exercises) { exercise in
DebugListItem(item: exercise)
}
}
Section(header: Text("Workouts")) {
ForEach(workouts) { workout in
DebugListItem(item: workout)
}
}
Section(header: Text("WorkoutItems")) {
ForEach(workoutItems) { workoutItem in
VStack(alignment: .leading) {
Text("\(workoutItem.name), pos: \(workoutItem.position), reps: \(workoutItem.reps)")
Text(workoutItem.id.uuidString)
.font(.system(size: 12, weight: .bold))
.fontDesign(.monospaced)
.foregroundStyle(.gray)
}
}
}
}
.tabItem {
Image(systemName: "wrench")
Text("Exercise Debug")
}
}
}
struct DebugListItem: View {
var item: any Nameable
var body: some View {
VStack(alignment: .leading) {
Text(item.name)
Text(item.id.uuidString)
.font(.system(size: 12, weight: .bold))
.fontDesign(.monospaced)
.foregroundStyle(.gray)
}
}
}
#Preview {
DebugList()
.modelContainer(SampleData.shared.modelContainer)
}

View File

@@ -1,29 +0,0 @@
//
// DebugExerciseList.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 30.08.24.
//
import SwiftUI
import SwiftData
struct DebugExerciseList: View {
@Query(sort: \WorkoutItem.name) private var exercises: [WorkoutItem]
var body: some View {
List {
ForEach(exercises) { exercise in
Text("\(exercise.name), pos: \(exercise.position)")
}
}
.tabItem {
Image(systemName: "figure.run.square.stack")
Text("Exercise Debug")
}
}
}
#Preview {
DebugExerciseList()
}

View File

@@ -11,7 +11,7 @@ struct AddExercise: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Bindable var exercise: Exercise @State var exercise: Exercise
var body : some View { var body : some View {
Form { Form {

View File

@@ -1,5 +1,5 @@
// //
// ExerciseDetailsView.swift // ExerciseDetail.swift
// WorkoutsPlus // WorkoutsPlus
// //
// Created by Felix Förtsch on 10.08.24. // Created by Felix Förtsch on 10.08.24.
@@ -11,7 +11,7 @@ struct ExerciseDetail: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Bindable var exercise: Exercise @State var exercise: Exercise
var body: some View { var body: some View {

View File

@@ -9,9 +9,10 @@ import Foundation
import SwiftData import SwiftData
@Model @Model
final class Exercise: Identifiable { final class Exercise: Nameable {
var id = UUID()
static var systemImage = "figure.run" static var systemImage = "figure.run"
var id = UUID()
@Attribute(.unique) var name: String @Attribute(.unique) var name: String
// var metric: String = "reps" // var metric: String = "reps"
// var exerciseDescription: ExerciseDescription? // var exerciseDescription: ExerciseDescription?

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,18 @@
//
// Protocols.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 04.09.24.
//
import Foundation
protocol Nameable: Identifiable {
var id: UUID { get }
var name: String { get }
}
protocol Positionable: Identifiable {
var id: UUID { get }
var position: Int { get }
}

View File

@@ -9,11 +9,12 @@ import Foundation
import SwiftData import SwiftData
@Model @Model
final class Workout: Identifiable { final class Workout: Nameable {
var id = UUID()
var name: String
static var systemImage = "figure.run.square.stack" static var systemImage = "figure.run.square.stack"
var id = UUID()
@Attribute(.unique) var name: String
// Other properties and methods // Other properties and methods
var timestamp: Date = Date.now var timestamp: Date = Date.now
@@ -25,22 +26,22 @@ final class Workout: Identifiable {
func add(workoutItem: WorkoutItem) { func add(workoutItem: WorkoutItem) {
self.workoutItems.append(workoutItem) self.workoutItems.append(workoutItem)
updateExercisePositions() updateWorkoutItemsPositions()
} }
func add(workoutItems: [WorkoutItem]) { func add(workoutItems: [WorkoutItem]) {
for workoutItem in workoutItems { for workoutItem in workoutItems {
self.workoutItems.append(workoutItem) self.workoutItems.append(workoutItem)
} }
updateExercisePositions() updateWorkoutItemsPositions()
} }
func moveWorkoutItem(from source: IndexSet, to destination: Int) { func moveWorkoutItem(from source: IndexSet, to destination: Int) {
workoutItems.move(fromOffsets: source, toOffset: destination) workoutItems.move(fromOffsets: source, toOffset: destination)
updateExercisePositions() updateWorkoutItemsPositions()
} }
private func updateExercisePositions() { private func updateWorkoutItemsPositions() {
for (index, exercise) in workoutItems.enumerated() { for (index, exercise) in workoutItems.enumerated() {
exercise.position = index exercise.position = index
} }

View File

@@ -1,5 +1,5 @@
// //
// Exercise.swift // WorkoutItem.swift
// WorkoutsPlus // WorkoutsPlus
// //
// Created by Felix Förtsch on 10.08.24. // Created by Felix Förtsch on 10.08.24.
@@ -9,10 +9,10 @@ import Foundation
import SwiftData import SwiftData
@Model @Model
final class WorkoutItem: Identifiable { final class WorkoutItem: Nameable, Positionable {
var id = UUID() var id = UUID()
var name: String var name: String
var workout: Workout? var workout: Workout?
var workoutItemType: WorkoutItemType var workoutItemType: WorkoutItemType
var position: Int = 0 var position: Int = 0
@@ -20,11 +20,9 @@ final class WorkoutItem: Identifiable {
var reps: Int = 8 var reps: Int = 8
// EXERCISE // EXERCISE
var exercise: Exercise? { var exercise: Exercise?
didSet { // TODO: Think about what's happening when an exercise (template) is deleted or changed
self.name = exercise?.name ?? "self.name" // { didSet { self.name = exercise?.name ?? "self.name" } }
}
}
init(_ reps: Int, _ exercise: String) { init(_ reps: Int, _ exercise: String) {
self.workoutItemType = .exercise self.workoutItemType = .exercise

View File

@@ -0,0 +1,82 @@
//
// Onboarding.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 04.09.24.
//
import SwiftUI
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()
}

25
WorkoutsPlus/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Workouts+
- JSON Import/Export
- Use Multipeer connect for sharing/tracking?
- Progression-Fotos: als eigene App? -> Generalisierung zu "Foto-Track"
- Karte mit öffentlichen Orten einbinden (AOK-Fitnesspark?)
## Workouts
- time-based: 60 s
- rep-based: 3x
- Loops
## Trainingspläne
Idee: Platformdenken
App ist für Sportler mit einem Trainer. Trainer können die App nutzen, um ihre Trainees mit Trainingsplänen zu versorgen.
Dazu muss der Trainer eine Möglichkeit bekommen bezahlt zu werden?
## Ernährungspläne
Visualisierung des Budgets (Balkendiagram, das man via DragNdrop auffüllen kann, um zu sehen, wie man hinkommt)
mglw mehrdimensional? eine Achse kcal eine Achse Protein?
Energiebudget -> im ersten Schritt einfach immer denselben Ernährungsplan

View File

@@ -1,18 +0,0 @@
//
// Settings.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 30.08.24.
//
import SwiftUI
struct Settings: View {
var body: some View {
Text("Settings")
}
}
#Preview {
Settings()
}

View File

@@ -0,0 +1,44 @@
//
// Settings.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 30.08.24.
//
import SwiftUI
struct Settings: View {
@Default(\.isFirstAppStart) var isFirstAppStart
@Default(\.isOnboarding) var isOnboarding
@Default(\.userId) var userId
@Default(\.reps) var reps
var body: some View {
List {
Section(header: Text("User")) {
Text(userId)
TextField("name", text: $userId)
}
Section(header: Text("Defaults")) {
StepperListItem(itemName: "Rep Count", itemValue: $reps)
}
Text(String(reps))
Section(header: Text("Danger Zone")) {
Toggle(isOn: $isOnboarding) {
Text("isOnboarding")
}
Button("Reset App", role: .destructive, action: resetApp)
}
}
}
private func resetApp() {
isFirstAppStart = true
isOnboarding = true
}
}
#Preview {
Settings()
}

View File

@@ -1,5 +1,5 @@
// //
// AddWorkoutView.swift // AddWorkout.swift
// WorkoutsPlus // WorkoutsPlus
// //
// Created by Felix Förtsch on 17.08.24. // Created by Felix Förtsch on 17.08.24.
@@ -11,7 +11,7 @@ struct AddWorkout: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Bindable var workout: Workout @State var workout: Workout
var body: some View { var body: some View {
Form { Form {

View File

@@ -12,54 +12,38 @@ struct AddWorkoutItemToWorkout: 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]
@Bindable var workout: Workout @State var workout: Workout
// TODO: Add (i) Button that allows editing an exercise (maybe requires NavigationStack?)
var body: some View { var body: some View {
Group { Group {
List { List {
Section(header: Text("Utilities")) { Section(header: Text("Utilities")) {
AddExerciseToWorkoutListItem(WorkoutItem(workoutItems: []), workout) // AddExerciseToWorkoutListItem(WorkoutItem(workoutItems: []), workout)
} }
Section(header: Text("Excersises")) { Section(header: Text("Excersises")) {
if !exercises.isEmpty { if !exercises.isEmpty {
ForEach(exercises) { exercise in ForEach(exercises) { exercise in
AddItemButton(label: exercise.name) {
AddExerciseToWorkoutListItem(WorkoutItem(from: exercise), workout) let workoutItem = WorkoutItem(from: exercise)
}} else { addWorkoutItemtoWorkout(workoutItem)
ContentUnavailableView {
// TODO: Add Button that allows adding an exercise
Label("No Exercises", systemImage: Exercise.systemImage)
} }
} }
} else {
ContentUnavailableView {
// TODO: Add Button that allows adding an exercise
Label("No Exercises", systemImage: Exercise.systemImage)
}
}
} }
} }
} }
} }
}
struct AddExerciseToWorkoutListItem: View {
@Environment(\.modelContext) private var modelContext
var workoutItem: WorkoutItem private func addWorkoutItemtoWorkout(_ workoutItem: WorkoutItem) {
var workout: Workout workout.add(workoutItem: workoutItem)
// TODO: Handle saving in a way the user knows when it's saved
init(_ workoutItem: WorkoutItem, _ workout: Workout) { // modelContext.save()
self.workoutItem = workoutItem
self.workout = workout
}
var body: some View {
Button(action: {
workout.add(workoutItem: workoutItem)
}) {
HStack {
Text(workoutItem.name)
.foregroundStyle(.black)
Spacer()
Image(systemName: "plus.circle.fill")
.foregroundStyle(.green)
}
}
} }
} }

View File

@@ -1,5 +1,5 @@
// //
// WorkoutDetailsView.swift // WorkoutDetail.swift
// WorkoutsPlus // WorkoutsPlus
// //
// Created by Felix Förtsch on 10.08.24. // Created by Felix Förtsch on 10.08.24.
@@ -12,8 +12,8 @@ struct WorkoutDetail: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Bindable var workout: Workout @State var workout: Workout
@State private var isPresenting = false @State private var isPresentingExerciseLibrary = false
var body: some View { var body: some View {
@@ -47,18 +47,17 @@ struct WorkoutDetail: View {
EditButton() EditButton()
} }
} }
.sheet(isPresented: $isPresenting) { .sheet(isPresented: $isPresentingExerciseLibrary) {
NavigationStack { AddWorkoutItemToWorkout(workout: workout)
AddWorkoutItemToWorkout(workout: workout) .presentationDetents([.medium, .large])
} .presentationDragIndicator(.visible)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} }
} }
private func addWorkoutItemToWorkout() { private func addWorkoutItemToWorkout() {
withAnimation { withAnimation {
isPresenting = true isPresentingExerciseLibrary = true
} }
} }
@@ -92,3 +91,20 @@ struct WorkoutDetail: View {
.modelContainer(SampleData.shared.modelContainer) .modelContainer(SampleData.shared.modelContainer)
} }
} }
#Preview("Debug") {
TabView {
WorkoutDetail(workout: Workout.sampleData)
.tabItem {
Image(systemName: "figure.run.square.stack")
Text("Workouts")
}
DebugList()
.tabItem {
Image(systemName: "hammer")
Text("Debug")
}
}
.modelContainer(SampleData.shared.modelContainer)
}

View File

@@ -1,5 +1,5 @@
// //
// WorkoutLibraryView.swift // WorkoutLibrary.swift
// WorkoutsPlus // WorkoutsPlus
// //
// Created by Felix Förtsch on 10.08.24. // Created by Felix Förtsch on 10.08.24.