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 body: some View {
Button(action: {
action()
}) {
Button(action: action) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundStyle(.green)

View File

@@ -20,23 +20,7 @@ struct ExerciseListItem: View {
Button(action: {
// workout.addExercise(from: exercise)
}) {
HStack {
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
) {}
}
StepperListItem(itemName: exercise.name, itemValue: $exercise.reps)
}
}
}

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 {
@Environment(\.modelContext) private var modelContext
@Default(\.isOnboarding) var isOnboarding
var body: some View {
TabView {
WorkoutLog()
// WorkoutLog()
WorkoutLibrary()
.tabItem {
Image(systemName: "figure.run.square.stack")
@@ -24,7 +25,7 @@ struct ContentView: View {
Image(systemName: "figure.run")
Text("Exercises")
}
DebugExerciseList()
DebugList()
.tabItem {
Image(systemName: "hammer")
Text("Debug")
@@ -35,6 +36,7 @@ struct ContentView: View {
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(\.dismiss) private var dismiss
@Bindable var exercise: Exercise
@State var exercise: Exercise
var body : some View {
Form {

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
//
// Exercise.swift
// WorkoutItem.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
@@ -9,10 +9,10 @@ import Foundation
import SwiftData
@Model
final class WorkoutItem: Identifiable {
final class WorkoutItem: Nameable, Positionable {
var id = UUID()
var name: String
var workout: Workout?
var workoutItemType: WorkoutItemType
var position: Int = 0
@@ -20,11 +20,9 @@ final class WorkoutItem: Identifiable {
var reps: Int = 8
// EXERCISE
var exercise: Exercise? {
didSet {
self.name = exercise?.name ?? "self.name"
}
}
var exercise: Exercise?
// TODO: Think about what's happening when an exercise (template) is deleted or changed
// { didSet { self.name = exercise?.name ?? "self.name" } }
init(_ reps: Int, _ exercise: String) {
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
//
// Created by Felix Förtsch on 17.08.24.
@@ -11,7 +11,7 @@ struct AddWorkout: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Bindable var workout: Workout
@State var workout: Workout
var body: some View {
Form {

View File

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

View File

@@ -1,5 +1,5 @@
//
// WorkoutDetailsView.swift
// WorkoutDetail.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
@@ -12,8 +12,8 @@ struct WorkoutDetail: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Bindable var workout: Workout
@State private var isPresenting = false
@State var workout: Workout
@State private var isPresentingExerciseLibrary = false
var body: some View {
@@ -47,18 +47,17 @@ struct WorkoutDetail: View {
EditButton()
}
}
.sheet(isPresented: $isPresenting) {
NavigationStack {
AddWorkoutItemToWorkout(workout: workout)
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.sheet(isPresented: $isPresentingExerciseLibrary) {
AddWorkoutItemToWorkout(workout: workout)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
private func addWorkoutItemToWorkout() {
withAnimation {
isPresenting = true
isPresentingExerciseLibrary = true
}
}
@@ -92,3 +91,20 @@ struct WorkoutDetail: View {
.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
//
// Created by Felix Förtsch on 10.08.24.