add Workout, Exercise and their Library, Add, and Details views

This commit is contained in:
Felix Förtsch
2024-08-19 15:33:32 +02:00
parent 97d4038fb6
commit 419e2bc699
19 changed files with 522 additions and 549 deletions

View File

@@ -13,14 +13,14 @@ struct ContentView: View {
var body: some View {
TabView {
WorkoutLibraryView()
WorkoutLibrary()
.tabItem {
Image(systemName: "gauge.with.needle.fill")
Image(systemName: "figure.run.square.stack")
Text("Workouts")
}
ExerciseLibraryView()
ExerciseLibrary()
.tabItem {
Image(systemName: "figure.run.square.stack.fill")
Image(systemName: "figure.run")
Text("Exercises")
}
Text("Settings")
@@ -34,5 +34,5 @@ struct ContentView: View {
#Preview {
ContentView()
.modelContainer(for: [Exercise.self, Workout.self], inMemory: true)
.modelContainer(SampleData.shared.modelContainer)
}

View File

@@ -1,20 +0,0 @@
//
// Item.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import Foundation
import SwiftData
@Model
final class Exercise {
var name: String
var timestamp: Date
init(name: String = "", timestamp: Date = Date.now) {
self.name = name
self.timestamp = timestamp
}
}

View File

@@ -0,0 +1,41 @@
//
// 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
@Bindable 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,30 @@
//
// Item.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import Foundation
import SwiftData
@Model
final class Exercise {
var name: String
static var systemImage = "figure.run"
var timestamp: Date
init(_ name: String = "", timestamp: Date = Date.now) {
self.name = name
self.timestamp = timestamp
}
static let sampleData = [
Exercise("Pull-up"),
Exercise("Push-up"),
Exercise("Dips"),
Exercise("Rows"),
Exercise("Split Squat")
]
}

View File

@@ -0,0 +1,45 @@
//
// ExerciseDetailsView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
struct ExerciseDetail: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Bindable var exercise: Exercise
var body: some View {
Form {
TextField("Exercise Name", text: $exercise.name)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
saveItem()
dismiss()
}
}
}
}
.navigationTitle("Exercise Details")
}
private func saveItem() {
if modelContext.hasChanges {
do {
try modelContext.save()
} catch {
print("Failed to save exercise: \(error.localizedDescription)")
}
}
}
}
#Preview {
ExerciseDetail(exercise: Exercise("New Exercise"))
}

View File

@@ -0,0 +1,96 @@
//
// ExerciseLibraryView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
struct ExerciseLibrary: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Exercise.name) private var exercises: [Exercise]
@State private var newExercise: Exercise?
var body: some View {
NavigationSplitView {
Group {
if !exercises.isEmpty {
List {
ForEach(exercises) { exercise in
NavigationLink {
ExerciseDetail(exercise: exercise)
} label: {
Text(exercise.name)
}
}
.onDelete(perform: deleteExercise)
}
} else {
ContentUnavailableView {
Label("No Exercises", systemImage: Exercise.systemImage)
}
}
}
.navigationBarTitle("Exercises")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
EditButton()
}
ToolbarItem {
Button(action: addExercise) {
Label("Add Exercise", systemImage: "plus")
}
}
}
.sheet(item: $newExercise) { exercise in
NavigationStack {
AddExercise(exercise: exercise)
}
// TODO: It's possible to add a boolean here ("Terms accepted y/n"). Maybe add this for empty string
.interactiveDismissDisabled()
}
} detail: {
// TODO: What does this Detail do?
Text("Select a workout")
.navigationTitle("Movie")
}
}
private func addExercise() {
withAnimation {
let item = Exercise("")
modelContext.insert(item)
newExercise = item
}
}
private func saveExercise(exercise: Exercise) {
if !exercise.name.isEmpty {
modelContext.insert(exercise)
try? modelContext.save()
}
}
private func deleteExercise(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(exercises[index])
}
try? modelContext.save()
}
}
}
#Preview("With Sample Data") {
ExerciseLibrary()
.modelContainer(SampleData.shared.modelContainer)
}
#Preview("Empty Database") {
ExerciseLibrary()
.modelContainer(for: Exercise.self, inMemory: true)
}

View File

@@ -1,59 +0,0 @@
//
// ExerciseDetailsView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
struct ExerciseDetailsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
var exercise: Exercise?
var isPresentedAsSheet: Bool = false
var body: some View {
Form {
TextField("Exercise Name", text: Binding(
get: { exercise?.name ?? "" },
set: { newName in
if exercise != nil {
exercise?.name = newName
}
}
))
.toolbar {
if (isPresentedAsSheet) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
saveItem()
dismiss()
}
}
}
}
}
private func saveItem() {
if modelContext.hasChanges {
do {
try modelContext.save()
} catch {
print("Failed to save exercise: \(error.localizedDescription)")
}
}
}
}
#Preview {
ExerciseDetailsView(exercise: Exercise(name: "New Exercises Preview"))
}

View File

@@ -1,100 +0,0 @@
//
// ExerciseLibraryView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
struct ExerciseLibraryView: View {
@Environment(\.modelContext) private var modelContext
@Query private var exercises: [Exercise]
@State private var isPresentingNewItemSheet = false
let initialDataSet = [
"Pull-up",
"Push-up",
"Dips",
"Rows",
"Split Squat"
]
var body: some View {
NavigationView {
List {
ForEach(exercises.sorted(by: { $0.name < $1.name })) { item in
NavigationLink(destination: ExerciseDetailsView(exercise: item)) {
Text(item.name)
}
}
.onDelete(perform: deleteExercise)
}
.onAppear {
if exercises.isEmpty {
loadInitialData(exercises: initialDataSet)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
createNewExercise()
} label: {
Label("Add Exercise", systemImage: "plus")
}
}
}
.sheet(isPresented: $isPresentingNewItemSheet) {
let newItem = Exercise(name: "")
NavigationView {
ExerciseDetailsView(exercise: newItem, isPresentedAsSheet: true)
.onDisappear {
if !newItem.name.isEmpty {
saveExercise(item: newItem)
}
}
}
}
}
}
private func createNewExercise() {
isPresentingNewItemSheet = true
}
private func saveExercise(item: Exercise) {
modelContext.insert(item)
try? modelContext.save()
}
private func deleteExercise(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(exercises[index])
}
try? modelContext.save()
}
}
private func loadInitialData(exercises: [String]) {
var items: [Exercise] = []
for exercise in exercises {
let item = Exercise(name: exercise)
items.append(item)
}
for item in items {
modelContext.insert(item)
}
try? modelContext.save()
}
}
#Preview {
ExerciseLibraryView()
.modelContainer(for: Exercise.self, inMemory: true)
}

View File

@@ -0,0 +1,48 @@
//
// SampleData.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 17.08.24.
//
import Foundation
import SwiftData
@MainActor // With your annotation, youre declaring that all code in this class must run on the main actor, including access to the mainContext property. Since all the SwiftUI code in an app runs on the main actor by default, youve satisfied the condition.
class SampleData {
static let shared = SampleData()
let modelContainer: ModelContainer
var context: ModelContext {
modelContainer.mainContext
}
private init() {
let schema = Schema([Exercise.self, Workout.self])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
do {
modelContainer = try ModelContainer(for: schema, configurations: [modelConfiguration])
insertSampleData()
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
func insertSampleData() {
for exercise in Exercise.sampleData {
context.insert(exercise)
}
for workout in Workout.sampleData {
context.insert(workout)
}
do {
try context.save()
} catch {
print("Sample data context failed to save.")
}
}
}

View File

@@ -1,20 +0,0 @@
//
// Item.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import Foundation
import SwiftData
@Model
final class Item {
var name: String
var timestamp: Date
init(name: String = "", timestamp: Date = Date.now) {
self.name = name
self.timestamp = timestamp
}
}

View File

@@ -1,61 +0,0 @@
//
// ExerciseDetailsView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
struct ItemDetailsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
var item: Item?
var isPresentedAsSheet: Bool = false
var body: some View {
Form {
TextField("Exercise Name", text: Binding(
get: { item?.name ?? "" },
set: { newName in
if item != nil {
item?.name = newName
}
}
))
.toolbar {
if (isPresentedAsSheet) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
saveItem()
dismiss()
}
}
}
}
}
private func saveItem() {
if modelContext.hasChanges {
do {
try modelContext.save()
} catch {
print("Failed to save item: \(error.localizedDescription)")
}
}
}
}
#Preview {
let sampleItem = Item(name: "Sample Item")
ItemDetailsView(item: sampleItem)
}

View File

@@ -1,100 +0,0 @@
//
// ContentView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
struct ItemLibrary: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@State private var isPresentingNewItemSheet = false
let initialDataSet = [
"Pull-up",
"Push-up",
"Dips",
"Rows",
"Split Squat"
]
var body: some View {
NavigationView {
List {
ForEach(items.sorted(by: { $0.name < $1.name })) { item in
NavigationLink(destination: ItemDetailsView(item: item)) {
Text(item.name)
}
}
.onDelete(perform: deleteItem)
}
.onAppear {
if items.isEmpty {
loadInitialData(exercises: initialDataSet)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
createNewItem()
} label: {
Label("Add Exercise", systemImage: "plus")
}
}
}
.sheet(isPresented: $isPresentingNewItemSheet) {
let newItem = Item(name: "")
NavigationView {
ItemDetailsView(item: newItem, isPresentedAsSheet: true)
.onDisappear {
if !newItem.name.isEmpty {
saveItem(item: newItem)
}
}
}
}
}
}
private func createNewItem() {
isPresentingNewItemSheet = true
}
private func saveItem(item: Item) {
modelContext.insert(item)
try? modelContext.save()
}
private func deleteItem(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
try? modelContext.save()
}
}
private func loadInitialData(exercises: [String]) {
var items: [Item] = []
for exercise in exercises {
let item = Item(name: exercise)
items.append(item)
}
for item in items {
modelContext.insert(item)
}
try? modelContext.save()
}
}
#Preview {
ItemLibrary()
.modelContainer(for: Item.self, inMemory: true)
}

View File

@@ -1,26 +0,0 @@
//
// Item.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import Foundation
import SwiftData
@Model
final class Workout {
var name: String
var timestamp: Date
private var exercises: [Exercise] = []
init(name: String = "", timestamp: Date = Date.now) {
self.name = name
self.timestamp = timestamp
}
func addExercise(_ exercise: Exercise) {
exercises.append(exercise)
}
}

View File

@@ -0,0 +1,41 @@
//
// AddWorkoutView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 17.08.24.
//
import SwiftUI
struct AddWorkout: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Bindable var workout: Workout
var body: some View {
Form {
TextField("Workout Name", text: $workout.name)
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
modelContext.delete(workout)
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
dismiss()
}
}
}
}
}
#Preview {
Color.clear
.sheet(isPresented: .constant(true)) {
AddWorkout(workout: Workout(name: ""))
}
}

View File

@@ -0,0 +1,63 @@
//
// Item.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import Foundation
import SwiftData
@Model
final class Workout {
var isEdited = false
var name: String {
didSet { isEdited = true }
}
static var systemImage = "figure.run.square.stack"
// Other properties and methods
var timestamp: Date
var exercises: [Exercise] = []
init(name: String = "", timestamp: Date = Date.now) {
self.name = name
self.timestamp = timestamp
}
private init(name: String, exercises: [Exercise]) {
self.name = name
self.timestamp = Date.now
self.exercises = exercises
}
func addExercise(_ exercise: Exercise) {
exercises.append(exercise)
}
static let sampleData = [
Workout(name: "RR", exercises: [
Exercise("Warm-up"),
Exercise("Pull-up Progression"), Exercise("Squat Progression"),
Exercise("Dip Progression"), Exercise("Hinge Progression"),
Exercise("Row Progression"), Exercise("Push-up Progression"),
Exercise("Core Trilet")]),
Workout(name: "Minimalist", exercises:[
Exercise("Push"), Exercise("Pull"),
Exercise("Legs"), Exercise("Core")]),
Workout(name: "Rings", exercises: [
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")]),
Workout(name: "Intervalltraining", exercises: [
Exercise("400 m schnell"), Exercise("200 m langsam"),
Exercise("400 m schnell"), Exercise("200 m langsam"),
Exercise("400 m schnell"), Exercise("200 m langsam"),
Exercise("400 m schnell"), Exercise("200 m langsam"),
Exercise("400 m schnell"), Exercise("200 m langsam"),
Exercise("400 m schnell"), Exercise("200 m langsam")])
]
}

View File

@@ -0,0 +1,57 @@
//
// WorkoutDetailsView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
struct WorkoutDetail: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Bindable var workout: Workout
var body: some View {
Form {
Section(header: Text("Workout Name")) {
TextField("Workout Name", text: $workout.name)
}
Section(header: Text("Exercises")) {
List {
ForEach(workout.exercises) { exercise in
Text(exercise.name)
}
}
}
}
.navigationBarTitle("Edit \(workout.name)")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
}
private func saveWorkout() {
if modelContext.hasChanges {
do {
try modelContext.save()
} catch {
print("Failed to save workout: \(error.localizedDescription)")
}
}
}
}
#Preview {
NavigationStack {
WorkoutDetail(workout: Workout.sampleData[0])
.modelContainer(SampleData.shared.modelContainer)
}
}

View File

@@ -0,0 +1,96 @@
//
// WorkoutLibraryView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
struct WorkoutLibrary: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Workout.name) private var workouts: [Workout]
@State private var newWorkout: Workout?
var body: some View {
NavigationSplitView {
Group {
if !workouts.isEmpty {
List {
ForEach(workouts) { workout in
NavigationLink {
WorkoutDetail(workout: workout)
} label: {
Text(workout.name)
}
}
.onDelete(perform: deleteWorkout)
}
} else {
ContentUnavailableView {
Label("No Workouts", systemImage: "figure.run.square.stack")
}
}
}
.navigationBarTitle("Workouts")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
EditButton()
}
ToolbarItem {
Button(action: addWorkout) {
Label("Add Workout", systemImage: "plus")
}
}
}
.sheet(item: $newWorkout) { workout in
NavigationStack {
AddWorkout(workout: workout)
}
.interactiveDismissDisabled()
}
} detail: {
// TODO: What does this Detail do?
Text("Select a workout")
.navigationTitle("Movie")
}
}
private func addWorkout() {
withAnimation {
let item = Workout(name: "")
modelContext.insert(item)
newWorkout = item
}
}
// TODO: Brauchen wir das?
private func saveWorkout(workout: Workout) {
if !workout.name.isEmpty {
modelContext.insert(workout)
try? modelContext.save()
}
}
private func deleteWorkout(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(workouts[index])
}
try? modelContext.save()
}
}
}
#Preview("With Sample Data") {
WorkoutLibrary()
.modelContainer(SampleData.shared.modelContainer)
}
#Preview("Empty Database") {
WorkoutLibrary()
.modelContainer(for: Workout.self, inMemory: true)
}

View File

@@ -1,60 +0,0 @@
//
// ExerciseDetailsView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
struct WorkoutDetailsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
var workout: Workout?
var isPresentedAsSheet: Bool = false
var body: some View {
Form {
TextField("Workout Name", text: Binding(
get: { workout?.name ?? "" },
set: { newName in
if workout != nil {
workout?.name = newName
}
}
))
.toolbar {
if (isPresentedAsSheet) {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
saveWorkout()
dismiss()
}
}
}
}
}
private func saveWorkout() {
if modelContext.hasChanges {
do {
try modelContext.save()
} catch {
print("Failed to save workout: \(error.localizedDescription)")
}
}
}
}
#Preview {
WorkoutDetailsView(workout: Workout(name: ""))
}

View File

@@ -1,98 +0,0 @@
//
// WorkoutLibraryView.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.08.24.
//
import SwiftUI
import SwiftData
struct WorkoutLibraryView: View {
@Environment(\.modelContext) private var modelContext
@Query private var workouts: [Workout]
@State private var isPresentingNewItemSheet = false
let initialDataSet = [
"RR",
"Minimalist"
]
var body: some View {
NavigationView {
List {
ForEach(workouts.sorted(by: { $0.name < $1.name })) { workout in
NavigationLink(destination: WorkoutDetailsView(workout: workout)) {
Text(workout.name)
}
}
.onDelete(perform: deleteWorkout)
}
.onAppear {
if workouts.isEmpty {
// TODO: This behaviour puts something into [workouts] whenever(!) it is empty. It's not bound to the first ever start of the app or anything. Check if that's the behaviour we want.
loadInitialData(workouts: initialDataSet)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
createNewWorkout()
} label: {
Label("Add Workout", systemImage: "plus")
}
}
}
.sheet(isPresented: $isPresentingNewItemSheet) {
let newWorkout = Workout(name: "")
NavigationView {
WorkoutDetailsView(workout: newWorkout, isPresentedAsSheet: true)
.onDisappear {
if !newWorkout.name.isEmpty {
saveWorkout(workout: newWorkout)
}
}
}
}
}
}
private func createNewWorkout() {
isPresentingNewItemSheet = true
}
private func saveWorkout(workout: Workout) {
modelContext.insert(workout)
try? modelContext.save()
}
private func deleteWorkout(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(workouts[index])
}
try? modelContext.save()
}
}
private func loadInitialData(workouts: [String]) {
var items: [Workout] = []
for exercise in workouts {
let item = Workout(name: exercise)
items.append(item)
}
for item in items {
modelContext.insert(item)
}
try? modelContext.save()
}
}
#Preview {
WorkoutLibraryView()
.modelContainer(for: Workout.self, inMemory: true)
}