diff --git a/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSession.swift b/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSession.swift index aabf2fe..28001b5 100644 --- a/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSession.swift +++ b/WorkoutsPlus/ActiveWorkout/ActiveWorkoutSession.swift @@ -1,60 +1,158 @@ -// -// ActiveWorkout.swift -// WorkoutsPlus -// -// Created by Felix Förtsch on 07.09.24. -// - import SwiftUI +import SwiftData struct ActiveWorkoutSession: View { - @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext - @State var workout: Workout + @Default(\.isWorkingOut) var isWorkingOut - @State var workoutSession: WorkoutSession? - @State var currentExercise: Int = 0 + @Query(sort: \WorkoutSession.name) private var workoutSessions: [WorkoutSession] + @State private var activeWorkoutSession: WorkoutSession? + @Default(\.activeWorkoutSessionId) var activeWorkoutSessionId - let startDate = Date() - 100 + @Query(sort: \Workout.name) private var workouts: [Workout] + @State private var activeWorkout: Workout? + @Default(\.activeWorkoutId) var activeWorkoutId - @State private var currentTime = Date() - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - - var body: some View { - Button("Close") { dismiss() } - Text("\(workout.name)") - Text("Elapsed Time: \(elapsedTimeText(startDate: startDate))") - .onReceive(timer) { time in - currentTime = time // Updates the current time every second - } + var body: some View { + VStack { List { - // TODO: Unwrap Sets - ForEach(workout.workoutItems) { workoutItem in - HStack { - Text("\(workoutItem.reps)") - Text("\(workoutItem.name)") + Section(footer: Text(activeWorkoutSession?.creationDate.ISO8601Format() ?? "Unknown Date")) { + NavigationLink(destination: { + ItemPicker(items: workouts, selectedItem: $activeWorkout) + }) { + Text(activeWorkout?.name ?? "Select your next Workout") + } + .onChange(of: activeWorkout) { _, newWorkout in + if let workout = newWorkout { + activeWorkoutId = workout.id.uuidString + activeWorkoutSession?.workout = workout + } + } + } + + if let activeWorkout = activeWorkout { + Section(header: Text("Exercises")) { + ForEach(activeWorkout.workoutItems.sorted(by: { $0.position < $1.position })) { 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) + } + } + } } } } - .listStyle(.plain) - Image(systemName: "play.circle.fill") - .resizable() - .frame(width: 100, height: 100) - .font(.title) - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .green) - - } + + if true { // This condition should be more meaningful. + VStack { + HStack { + Text(String(workoutSessions.count)) + Button(action: { + // save(workoutSession: activeWorkoutSession) + }) { + Text("Save Session") + } + .buttonStyle(.borderedProminent) + } + } + } + + // 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) + } + } + } + } + .navigationTitle("Workout Session") + .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) { + self.activeWorkout = workout + } + } else { + createNewWorkoutSession() + } + } + } - private func elapsedTimeText(startDate: Date) -> String { - let elapsedTime = currentTime.timeIntervalSince(startDate) - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute, .second] - formatter.unitsStyle = .positional // For HH:mm:ss format - return formatter.string(from: elapsedTime) ?? "00:00:00" + private func createNewWorkoutSession() { + let newWorkoutSession = WorkoutSession() + activeWorkoutSessionId = newWorkoutSession.id.uuidString + if let selectedWorkout = getItem(from: workouts, by: activeWorkoutId) { + newWorkoutSession.workout = selectedWorkout + } + self.activeWorkoutSession = newWorkoutSession + } + + private func getItem(from array: [Item], by id: String) -> Item? { + let filteredItems = array.filter { $0.id == UUID(uuidString: id) } + return filteredItems.count == 1 ? filteredItems.first : nil } } #Preview { - ActiveWorkoutSession(workout: Workout.sampleData) + NavigationStack { + ActiveWorkoutSession() + } + .modelContainer(SampleData.shared.modelContainer) +} + +#Preview("No Workout Selected") { + NavigationStack { + ActiveWorkoutSession() + } + .onAppear { + Defaults.shared.isWorkingOut = false + } + .modelContainer(SampleData.shared.modelContainer) +} + +#Preview("No Workout Data available") { + NavigationStack { + ActiveWorkoutSession() + } } diff --git a/WorkoutsPlus/Components/ItemPicker.swift b/WorkoutsPlus/Components/ItemPicker.swift new file mode 100644 index 0000000..c0a3381 --- /dev/null +++ b/WorkoutsPlus/Components/ItemPicker.swift @@ -0,0 +1,60 @@ +// +// ItemPicker.swift +// WorkoutsPlus +// +// Created by Felix Förtsch on 10.09.24. +// + +import SwiftUI +import SwiftData + +struct ItemPicker: View { + @Environment(\.dismiss) private var dismiss + + @State private var searchText = "" + var filteredItems: [Item] { + if searchText.isEmpty { + return items + } else { + return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + } + + var items: [Item] + @Binding var selectedItem: Item? + + var body: some View { + List { + ForEach(filteredItems) { item in + HStack { + Text(item.name) + Spacer() + if item == selectedItem { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + // This .contentShape makes the whole row tappable + .contentShape(Rectangle()) + .onTapGesture { + selectedItem = item + dismiss() + } + } + } + .searchable(text: $searchText) + .overlay { + if filteredItems.isEmpty { + ContentUnavailableView.search(text: searchText) + } + } + } +} + +#Preview { + @Previewable @State var selectedWorkout: Workout? = nil + NavigationStack { + ItemPicker(items: Workout.sampleData, selectedItem: $selectedWorkout) + } + .modelContainer(SampleData.shared.modelContainer) +} diff --git a/WorkoutsPlus/Components/TimerView.swift b/WorkoutsPlus/Components/TimerView.swift new file mode 100644 index 0000000..11928f0 --- /dev/null +++ b/WorkoutsPlus/Components/TimerView.swift @@ -0,0 +1,32 @@ +// +// TimerView.swift +// WorkoutsPlus +// +// Created by Felix Förtsch on 12.09.24. +// + +import SwiftUI + +struct TimerView: View { + @Binding var isActive: Bool + @State private var time = 0 + + 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 + } + } + .onDisappear { + self.timer.upstream.connect().cancel() + } + } +} + + +#Preview { + TimerView(isActive: .constant(true)) +} diff --git a/WorkoutsPlus/Configuration/Defaults.swift b/WorkoutsPlus/Configuration/Defaults.swift index 8166c71..b119dee 100644 --- a/WorkoutsPlus/Configuration/Defaults.swift +++ b/WorkoutsPlus/Configuration/Defaults.swift @@ -9,15 +9,21 @@ import Foundation import SwiftUI public class Defaults: ObservableObject { + @AppStorage("isDebug") public var isDebug = true + @AppStorage("isFirstAppStart") public var isFirstAppStart = true @AppStorage("isOnboarding") public var isOnboarding = true + @AppStorage("isTrainerMode") public var isTrainerMode = false + @AppStorage("isWorkingOut") public var isWorkingOut = false + @AppStorage("userId") public var userId = UUID().uuidString + @AppStorage("defaultWorkoutId") public var defaultWorkoutId: String = "" @AppStorage("sets") public var sets = 8 @AppStorage("reps") public var reps = 8 - @AppStorage("isWorkingOut") public var isWorkingOut = false - @AppStorage("activeWorkoutId") public var activeWorkoutId: String? + @AppStorage("activeWorkoutSessionId") public var activeWorkoutSessionId: String = "" + @AppStorage("activeWorkoutId") public var activeWorkoutId: String = "" public static let shared = Defaults() } diff --git a/WorkoutsPlus/ContentView.swift b/WorkoutsPlus/ContentView.swift index 2b44751..467b06c 100644 --- a/WorkoutsPlus/ContentView.swift +++ b/WorkoutsPlus/ContentView.swift @@ -10,50 +10,113 @@ import SwiftData struct ContentView: View { @Environment(\.modelContext) private var modelContext + @Default(\.isOnboarding) var isOnboarding + @Default(\.isWorkingOut) var isWorkingOut var body: some View { - ZStack(alignment: .bottom) { - TabView { - WorkoutLog() - // Text("Training Plans") - // .tabItem { - // Image(systemName: "calendar.badge.clock") - // Text("Plans & Goals") - // } - WorkoutLibrary() - .tabItem { - Image(systemName: "figure.run.square.stack") - Text("Workouts") + NavigationStack { + List { + Section { + VStack { + HStack { + Image(systemName: "person.crop.circle.badge.plus") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 50, height: 50) + .padding(.horizontal) + .foregroundStyle(.blue) + VStack(alignment: .leading) { + Text("Felix Förtsch") + .font(.title) + .fontWeight(.bold) + Text("You got this!") + .font(.headline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 5) + // TODO: Food Log (Foto + Kalender + Macros + // TODO: Goal page + // ActivityLog() + // .frame(height: 200) } - ExerciseLibrary() - .tabItem { - Image(systemName: "figure.run") - Text("Exercises") + } + Section { + NavigationLink(destination: ActiveWorkoutSession()) { + if (isWorkingOut) { + HStack { + Label("Back to Session", systemImage: "memories") + .symbolEffect(.rotate) + Text("35 min") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } else { + Label("Start Workout Session", systemImage: "play") + } } - DebugList() - .tabItem { - Image(systemName: "hammer") - Text("Debug") + NavigationLink(destination: WorkoutLog()) { + Label("Workout Log", systemImage: "calendar.badge.clock") } - Settings() - .tabItem { - Image(systemName: "gear") - Text("Settings") + } + Section { + NavigationLink(destination: WorkoutLibrary()) { + Label("Workouts", systemImage: "figure.run.square.stack") } - + NavigationLink(destination: ExerciseLibrary()) { + Label("Exercises", systemImage: "figure.run") + } + } + Section { + + NavigationLink(destination: Settings()) { + Label("Settings", systemImage: "gear") + } + } + Section(header: Text("Debug")) { + NavigationLink(destination: DebugList()) { + Label("Debug", systemImage: "hammer") + } + } } - .sheet(isPresented: $isOnboarding) { Onboarding() } - } - .safeAreaInset(edge: .bottom) { - if true { - MiniPlayer() + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + HStack { + ZStack { + Image(systemName: "hexagon.fill") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 35, height: 35) + .foregroundStyle(systemColors.randomElement()!) + Image(systemName: fitnessIcons.randomElement()!) + .foregroundStyle(.white) + } + Text("Workout+") + .font(.title) + .fontWeight(.bold) + } + } } + } + .sheet(isPresented: $isOnboarding) { Onboarding() } } } -#Preview { +#Preview("No Workout Session") { ContentView() + .onAppear { + Defaults.shared.isWorkingOut = false + } + .modelContainer(SampleData.shared.modelContainer) +} + +#Preview("With Active Workout Session") { + ContentView() + .onAppear { + Defaults.shared.isWorkingOut = true + } .modelContainer(SampleData.shared.modelContainer) } diff --git a/WorkoutsPlus/Debug/DebugList.swift b/WorkoutsPlus/Debug/DebugList.swift index 9430f1e..c917fa0 100644 --- a/WorkoutsPlus/Debug/DebugList.swift +++ b/WorkoutsPlus/Debug/DebugList.swift @@ -12,6 +12,7 @@ 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] + @Query(sort: \WorkoutSession.name) private var workoutSessions: [WorkoutSession] var body: some View { List { @@ -36,7 +37,13 @@ struct DebugList: View { } } } + Section(header: Text("Workout Sessions")) { + ForEach(workoutSessions) { workoutSession in + DebugListItem(item: workoutSession) + } + } } + .navigationTitle("Debug") .tabItem { Image(systemName: "wrench") Text("Exercise Debug") diff --git a/WorkoutsPlus/Debug/ExampleView.swift b/WorkoutsPlus/Debug/ExampleView.swift index c4d1516..3dce5d0 100644 --- a/WorkoutsPlus/Debug/ExampleView.swift +++ b/WorkoutsPlus/Debug/ExampleView.swift @@ -15,7 +15,7 @@ struct ExampleView: View { @State private var showPopoverSource: Bool = false var body: some View { - NavigationView { + NavigationStack { List { NavigationLink(destination: Text("Detail View")) { Text("NavigationLink") diff --git a/WorkoutsPlus/Debug/SampleData.swift b/WorkoutsPlus/Debug/SampleData.swift index 4d30ab6..0d206f6 100644 --- a/WorkoutsPlus/Debug/SampleData.swift +++ b/WorkoutsPlus/Debug/SampleData.swift @@ -19,7 +19,7 @@ class SampleData { } private init() { - let schema = Schema([WorkoutItem.self, Exercise.self, Workout.self]) + let schema = WorkoutsPlusApp.swiftDataSchema let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) do { @@ -31,15 +31,25 @@ class SampleData { } func insertSampleData() { - for exercise in Exercise.sampleData { + for exercise in Exercise.sampleDataRecommendedRoutine { context.insert(exercise) } - for workoutItem in WorkoutItem.sampleData { + for exercise in Exercise.sampleDataRings { + context.insert(exercise) + } + + for workoutItem in WorkoutItem.sampleDataRecommendedRoutine { context.insert(workoutItem) } - context.insert(Workout.sampleData) + for workoutItem in WorkoutItem.sampleDataRings { + context.insert(workoutItem) + } + + for workout in Workout.sampleData { + context.insert(workout) + } do { try context.save() diff --git a/WorkoutsPlus/Exercise/ExerciseDetail.swift b/WorkoutsPlus/Exercise/ExerciseDetail.swift index 4e4f740..a46dec6 100644 --- a/WorkoutsPlus/Exercise/ExerciseDetail.swift +++ b/WorkoutsPlus/Exercise/ExerciseDetail.swift @@ -40,5 +40,7 @@ struct ExerciseDetail: View { } #Preview { - ExerciseDetail(exercise: Exercise.sampleData.first!) + NavigationStack { + ExerciseDetail(exercise: Exercise.sampleDataRecommendedRoutine.first!) + } } diff --git a/WorkoutsPlus/Exercise/ExerciseLibrary.swift b/WorkoutsPlus/Exercise/ExerciseLibrary.swift index d4c039a..a8dc599 100644 --- a/WorkoutsPlus/Exercise/ExerciseLibrary.swift +++ b/WorkoutsPlus/Exercise/ExerciseLibrary.swift @@ -29,39 +29,39 @@ struct ExerciseLibrary: View { // TODO: Add search bar to the top var body: some View { - NavigationView { - Group { - List { - ForEach(filteredItems) { exercise in - NavigationLink { - ExerciseDetail(exercise: exercise) - } label: { - Text(exercise.name) - } + + Group { + List { + ForEach(filteredItems) { exercise in + NavigationLink { + ExerciseDetail(exercise: exercise) + } label: { + Text(exercise.name) } - .onDelete(perform: deleteExercise) - if isAddingExercise { - TextField("New Exercise", text: $newExerciseName, onCommit: { - newExercise.name = newExerciseName - save(exercise: newExercise) - isAddingExercise = false - }) - .textInputAutocapitalization(.words) - .focused($isInputFieldFocused) - } - AddItemButton(label: "Exercise", action: addExercise) } - .searchable(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) + } + AddItemButton(label: "Exercise", action: addExercise) } - .navigationBarTitle("Exercises") - .toolbar { - ToolbarItem { - EditButton() - } + .searchable(text: $searchText) + } + .navigationTitle("Exercises") + .toolbar { + ToolbarItem { + EditButton() } } } + private func addExercise() { withAnimation { newExercise = Exercise("") @@ -89,12 +89,17 @@ struct ExerciseLibrary: View { } #Preview("With Sample Data") { - ExerciseLibrary() - .modelContainer(SampleData.shared.modelContainer) + NavigationStack { + ExerciseLibrary() + } + .modelContainer(SampleData.shared.modelContainer) + } #Preview("Empty Database") { - ExerciseLibrary() - .modelContainer(for: WorkoutItem.self, inMemory: true) + NavigationStack { + ExerciseLibrary() + } + .modelContainer(for: WorkoutItem.self, inMemory: true) } diff --git a/WorkoutsPlus/Home/ActivityLog.swift b/WorkoutsPlus/Home/ActivityLog.swift new file mode 100644 index 0000000..06cfecb --- /dev/null +++ b/WorkoutsPlus/Home/ActivityLog.swift @@ -0,0 +1,129 @@ +// +// ActivityLog.swift +// WorkoutsPlus +// +// Created by Felix Förtsch on 09.09.24. +// +// https://www.artemnovichkov.com/blog/github-contribution-graph-swift-charts +// https://github.com/artemnovichkov/awesome-swift-charts + +import SwiftUI +import Charts + +struct ActivityLog: View { + @State var activities: [Activity] = Activity.generate() + + var body: some View { + Chart(activities) { contribution in + RectangleMark( + xStart: .value("Start week", contribution.date, unit: .weekOfYear), + xEnd: .value("End week", contribution.date, unit: .weekOfYear), + yStart: .value("Start weekday", weekday(for: contribution.date)), + yEnd: .value("End weekday", weekday(for: contribution.date) + 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 4).inset(by: 2)) + .foregroundStyle(by: .value("Count", contribution.count)) + } + .chartPlotStyle { content in + content + .aspectRatio(aspectRatio, contentMode: .fit) + } + .chartForegroundStyleScale(range: Gradient(colors: colors)) + .chartXAxis { + AxisMarks(position: .top, values: .stride(by: .month)) { + AxisValueLabel(format: .dateTime.month()) + .foregroundStyle(Color(.label)) + } + } + .chartYAxis { + AxisMarks(position: .leading, values: [1, 3, 5]) { value in + if let value = value.as(Int.self) { + AxisValueLabel { + // Symbols from Calendar.current starting with Monday +// Text(shortWeekdaySymbols[value - 1]) + } + .foregroundStyle(Color(.label)) + } + } + } + .chartYScale(domain: .automatic(includesZero: false, reversed: true)) + .chartLegend { + HStack(spacing: 4) { + Text("Less") + ForEach(legendColors, id: \.self) { color in + color + .frame(width: 10, height: 10) + .cornerRadius(2) + } + Text("More") + } + .padding(4) + .foregroundStyle(Color(.label)) + .font(.caption2) + } + + + } + + private func weekday(for date: Date) -> Int { + let weekday = Calendar.current.component(.weekday, from: date) + let adjustedWeekday = (weekday == 1) ? 7 : (weekday - 1) + return adjustedWeekday + } + + private var aspectRatio: Double { + if activities.isEmpty { + return 1 + } + let firstDate = activities.first!.date + let lastDate = activities.last!.date + let firstWeek = Calendar.current.component(.weekOfYear, from: firstDate) + let lastWeek = Calendar.current.component(.weekOfYear, from: lastDate) + return Double(lastWeek - firstWeek + 1) / 7 + } + + private var colors: [Color] { + (0...10).map { index in + if index == 0 { + return Color(.systemGray5) + } + return Color(.systemGreen).opacity(Double(index) / 10) + } + } + + private var legendColors: [Color] { + Array(stride(from: 0, to: colors.count, by: 2).map { colors[$0] }) + } +} + +#Preview { + ActivityLog() +} + +struct Activity: Identifiable { + + let date: Date + let count: Int + + var id: Date { + date + } +} + +extension Activity { + + static func generate() -> [Activity] { + var contributions: [Activity] = [] + let toDate = Date.now + let fromDate = Calendar.current.date(byAdding: .day, value: -60, to: toDate)! + + var currentDate = fromDate + while currentDate <= toDate { + let contribution = Activity(date: currentDate, count: .random(in: 0...10)) + contributions.append(contribution) + currentDate = Calendar.current.date(byAdding: .day, value: 1, to: currentDate)! + } + + return contributions + } +} diff --git a/WorkoutsPlus/Home/Miniplayer.swift b/WorkoutsPlus/Home/Miniplayer.swift index 1067949..3a85e28 100644 --- a/WorkoutsPlus/Home/Miniplayer.swift +++ b/WorkoutsPlus/Home/Miniplayer.swift @@ -6,12 +6,20 @@ // import SwiftUI +import SwiftData +// Maybe: https://github.com/LeoNatan/LNPopupController/tree/master +// TODO:The Miniplayer is the same view that we use for the Live Activity +// .safeAreaInset(edge: .bottom) { } struct MiniPlayer: View { @Default(\.isWorkingOut) var isWorkingOut + @Default(\.activeWorkoutId) var selectedWorkoutId + +// @Query private var workouts: [Workout] @State private var isFullScreenCoverPresented = false @State private var workout = Workout.sampleData + @State private var workoutSession: WorkoutSession? var body: some View { Group { @@ -31,7 +39,7 @@ struct MiniPlayer: View { Button(action: { withAnimation() { // TODO: This button has to do something with the workout (pause it, next exercise, etc) - isWorkingOut.toggle() + }}) { Image(systemName: "pause.circle.fill") .font(.title) @@ -43,7 +51,7 @@ struct MiniPlayer: View { } .buttonStyle(.plain) .fullScreenCover(isPresented: $isFullScreenCoverPresented) { - ActiveWorkoutSession(workout: workout) +// ActiveWorkoutSession(workout: workout, workoutSession: workoutSession) } } else { Button(action: { @@ -53,7 +61,7 @@ struct MiniPlayer: View { Text("Start Workout") .font(.headline) // TODO: Replace this with the upcoming/planned workout - Text("→ Recommended Routine") + Text(selectedWorkoutId) .font(.subheadline) .foregroundColor(.secondary) } @@ -61,7 +69,11 @@ struct MiniPlayer: View { Button(action: { withAnimation() { // TODO: This button "quick starts" the workout (skips over the ActiveWorkoutSession fullscreen cover) - isWorkingOut.toggle() +// if let workoutId = selectedWorkoutId { + // If you selectedWorkoutId is set, get the corresponding workout. This idea is generally okay, but feels off (DRY?) +// workout = workouts.filter({ $0.id == UUID(uuidString: workoutId) }).first +// } + }}) { Image(systemName: "play.circle.fill") .font(.title) @@ -73,7 +85,7 @@ struct MiniPlayer: View { } .buttonStyle(.plain) .fullScreenCover(isPresented: $isFullScreenCoverPresented) { - ActiveWorkoutSession(workout: workout) +// ActiveWorkoutSession(workout: workout, workoutSession: workoutSession) } } } @@ -83,7 +95,7 @@ struct MiniPlayer: View { .cornerRadius(12) .shadow(radius: 10) .padding(.horizontal) - .padding(.bottom, 65) +// .padding(.bottom, 65) } } diff --git a/WorkoutsPlus/Home/WorkoutLog.swift b/WorkoutsPlus/Home/WorkoutLog.swift index a4761c5..0dd12f9 100644 --- a/WorkoutsPlus/Home/WorkoutLog.swift +++ b/WorkoutsPlus/Home/WorkoutLog.swift @@ -6,13 +6,17 @@ // import SwiftUI +import SwiftData struct WorkoutLog: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \WorkoutSession.name) private var workoutSessions: [WorkoutSession] + @Default(\.isWorkingOut) var isWorkingOut var body: some View { - NavigationView() { - List() { + List { + Section(header: Text("Dummies")) { NavigationLink(destination: Text("WorkoutLogDetails")) { HStack(alignment: .top) { Image(systemName: "figure.run") @@ -67,22 +71,39 @@ struct WorkoutLog: View { } } } - .navigationBarTitle("Workout Logs") + Section(header: Text("Workout Sessions")) { + ForEach(workoutSessions) { session in + VStack(alignment: .leading) { + Text(session.creationDate.ISO8601Format()) + if let workout = session.workout { + Text(workout.name) + .font(.subheadline) + } + } + }.onDelete(perform: deleteWorkoutSession) + } } - .tabItem { - Image(systemName: "pencil.and.list.clipboard") - Text("Log") + .navigationTitle("Workout Logs") + } + + private func deleteWorkoutSession(offsets: IndexSet) { + withAnimation { + for index in offsets { + modelContext.delete(workoutSessions[index]) + } + try? modelContext.save() } - } } #Preview("Active WorkoutSession") { - WorkoutLog() - .onAppear { - Defaults.shared.isWorkingOut = true - } + NavigationStack { + WorkoutLog() + .onAppear { + Defaults.shared.isWorkingOut = true + } + } } #Preview("No Active WorkoutSession") { diff --git a/WorkoutsPlus/Models/Exercise.swift b/WorkoutsPlus/Models/Exercise.swift index bfb7ece..b66d525 100644 --- a/WorkoutsPlus/Models/Exercise.swift +++ b/WorkoutsPlus/Models/Exercise.swift @@ -26,7 +26,22 @@ final class Exercise: Nameable { } extension Exercise { - static let sampleData: [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") + ] + + static let sampleDataRings: [Exercise] = [ Exercise("Dips"), Exercise("Chin-ups"), Exercise("Push-ups"), @@ -34,6 +49,6 @@ extension Exercise { Exercise("Hanging Knee Raises"), Exercise("Pistol Squats"), Exercise("Hanging Leg Curls"), - Exercise("Sissy Squats") + Exercise("Sissy Squats"), ] } diff --git a/WorkoutsPlus/Models/Protocols.swift b/WorkoutsPlus/Models/Protocols.swift index 13ca887..0cd83dd 100644 --- a/WorkoutsPlus/Models/Protocols.swift +++ b/WorkoutsPlus/Models/Protocols.swift @@ -7,7 +7,7 @@ import Foundation -protocol Nameable: Identifiable { +protocol Nameable: Identifiable, Hashable { var id: UUID { get } var name: String { get } } diff --git a/WorkoutsPlus/Models/Workout.swift b/WorkoutsPlus/Models/Workout.swift index 08106f6..c512366 100644 --- a/WorkoutsPlus/Models/Workout.swift +++ b/WorkoutsPlus/Models/Workout.swift @@ -9,7 +9,7 @@ import SwiftUI import SwiftData @Model -final class Workout: Nameable { +final class Workout: Nameable, Hashable { static var systemImage = "figure.run.square.stack" var id = UUID() @@ -50,6 +50,8 @@ final class Workout: Nameable { exercise.position = index } } + + func isSelected(workout: Workout) -> Bool { self.id == workout.id } } extension Workout { @@ -116,14 +118,18 @@ extension Workout { self.workoutItems = exercises } - static let sampleData: Workout = { - var workout = Workout(name: "Recommended Routine") - - for workoutItem in WorkoutItem.sampleData { - workout.add(workoutItem: workoutItem) + static let sampleData: [Workout] = { + var rr = Workout(name: "Recommended Routine") + for workoutItem in WorkoutItem.sampleDataRecommendedRoutine { + rr.add(workoutItem: workoutItem) } - return workout + var rings = Workout(name: "Fully Body Rings") + for workoutItem in WorkoutItem.sampleDataRings { + rings.add(workoutItem: workoutItem) + } + + return [rr, rings] }() } diff --git a/WorkoutsPlus/Models/WorkoutItem.swift b/WorkoutsPlus/Models/WorkoutItem.swift index 196b25a..d584a24 100644 --- a/WorkoutsPlus/Models/WorkoutItem.swift +++ b/WorkoutsPlus/Models/WorkoutItem.swift @@ -26,6 +26,8 @@ final class WorkoutItem: Nameable, Positionable { // The only relevant delete is delete Workout -> Delete Workoutitems // { didSet { self.name = exercise?.name ?? "self.name" } } + // TODO: Deload items -> Think about how to model deload/sick/rest/holiday week + init(_ reps: Int, _ exercise: String) { self.workoutItemType = .exercise @@ -67,18 +69,28 @@ extension WorkoutItem { } extension WorkoutItem { - static let sampleData: [WorkoutItem] = { + static let sampleDataRecommendedRoutine: [WorkoutItem] = { var exercises = [WorkoutItem]() - for exercise in Exercise.sampleData { + for exercise in Exercise.sampleDataRecommendedRoutine { exercises.append(WorkoutItem(from: exercise)) } - var set = WorkoutItem(workoutItems: [ - WorkoutItem(from: Exercise("Set item 1")), - WorkoutItem(from: Exercise("Set item 2")) - ]) - exercises.append(set) +// var set = WorkoutItem(workoutItems: [ +// WorkoutItem(from: Exercise("Set item 1")), +// WorkoutItem(from: Exercise("Set item 2")) +// ]) +// exercises.append(set) + + return exercises + }() + + static let sampleDataRings: [WorkoutItem] = { + var exercises = [WorkoutItem]() + + for exercise in Exercise.sampleDataRings { + exercises.append(WorkoutItem(from: exercise)) + } return exercises }() diff --git a/WorkoutsPlus/Models/WorkoutSession.swift b/WorkoutsPlus/Models/WorkoutSession.swift index f1c68f9..4425190 100644 --- a/WorkoutsPlus/Models/WorkoutSession.swift +++ b/WorkoutsPlus/Models/WorkoutSession.swift @@ -9,27 +9,50 @@ import Foundation import SwiftData @Model -final class WorkoutSession { - var workout: Workout - - // Time - var startDate = Date.now - var stopDate: Date? = nil - var duration: TimeInterval? = nil - func stopWorkout() { - self.stopDate = Date.now - if let stopDate = stopDate { - self.duration = stopDate.timeIntervalSince(startDate) +final class WorkoutSession: Nameable { + var id = UUID() + var name = "" + var workout: Workout? { + didSet { + self.name = workout?.name ?? "" } } + // Time + var creationDate = Date.now + private var startDate: Date? = nil + private var stopDate: Date? = nil + private var duration: TimeInterval? = nil + + // Exercise Progress + var currentExercise = 0 + + func isActive() -> Bool { + return startDate != nil && stopDate == nil + } + + func startWorkoutSession() { + self.startDate = Date.now + } + + func stopWorkoutSession() { + guard let startDate else { return } + self.stopDate = Date.now + self.duration = self.stopDate!.timeIntervalSince(startDate) + } + + func elapsedTime(since startDate: Date) -> String { + let elapsedTime = Date.now.timeIntervalSince(startDate) + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .positional + return formatter.string(from: elapsedTime) ?? "00:00:00" + } // var isCompleted: Bool // var isPaused: Bool // var isCancelled: Bool // var isDeleted: Bool // var isSynced: Bool - init (workout: Workout) { - self.workout = workout - } + init () { } } diff --git a/WorkoutsPlus/Onboarding/Onboarding.swift b/WorkoutsPlus/Onboarding/Onboarding.swift index d652c36..9addf72 100644 --- a/WorkoutsPlus/Onboarding/Onboarding.swift +++ b/WorkoutsPlus/Onboarding/Onboarding.swift @@ -7,6 +7,8 @@ import SwiftUI + +// TODO: Inspiration holen von https://github.com/SvenTiigi/WhatsNewKit struct Onboarding: View { @Default(\.isFirstAppStart) var isFirstAppStart @Default(\.isOnboarding) var isOnboarding diff --git a/WorkoutsPlus/README.md b/WorkoutsPlus/README.md index 3c9769d..55d4ba0 100644 --- a/WorkoutsPlus/README.md +++ b/WorkoutsPlus/README.md @@ -9,7 +9,7 @@ ## Workouts - time-based: 60 s -- rep-based: 3x +- 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) - Loops ## Trainingspläne diff --git a/WorkoutsPlus/Settings/Settings.swift b/WorkoutsPlus/Settings/Settings.swift index ba486f9..6ffe2e3 100644 --- a/WorkoutsPlus/Settings/Settings.swift +++ b/WorkoutsPlus/Settings/Settings.swift @@ -7,30 +7,42 @@ import SwiftUI +// TODO: Import/Export Workouts/Exercises +// https://www.avanderlee.com/swift/json-parsing-decoding/ struct Settings: View { + @Default(\.isDebug) var isDebug @Default(\.isFirstAppStart) var isFirstAppStart @Default(\.isOnboarding) var isOnboarding + @Default(\.isTrainerMode) var isTrainerMode @Default(\.userId) var userId @Default(\.reps) var reps var body: some View { List { - Section(header: Text("User")) { + Section( + header: Text("User"), + footer: Text("Trainer Mode enables you to add and manage trainees.")) { Text(userId) TextField("name", text: $userId) + Toggle(isOn: $isTrainerMode) { + Text("Trainer Mode") + } } Section(header: Text("Defaults")) { StepperListItem(itemName: "Rep Count", itemValue: $reps) } Text(String(reps)) Section(header: Text("Danger Zone")) { - + Toggle(isOn: $isDebug) { + Text("isDebug") + } Toggle(isOn: $isOnboarding) { - Text("isOnboarding") + Text("isOnboarding ") } Button("Reset App", role: .destructive, action: resetApp) } } + .navigationTitle("Settings") } private func resetApp() { @@ -40,5 +52,7 @@ struct Settings: View { } #Preview { - Settings() + NavigationStack { + Settings() + } } diff --git a/WorkoutsPlus/Workout/AddWorkout.swift b/WorkoutsPlus/Workout/AddWorkout.swift index 50f2fd1..5786731 100644 --- a/WorkoutsPlus/Workout/AddWorkout.swift +++ b/WorkoutsPlus/Workout/AddWorkout.swift @@ -36,6 +36,6 @@ struct AddWorkout: View { #Preview { Color.clear .sheet(isPresented: .constant(true)) { - AddWorkout(workout: Workout.sampleData) + AddWorkout(workout: Workout.sampleData.first!) } } diff --git a/WorkoutsPlus/Workout/WorkoutDetail.swift b/WorkoutsPlus/Workout/WorkoutDetail.swift index 4aa9979..16e3603 100644 --- a/WorkoutsPlus/Workout/WorkoutDetail.swift +++ b/WorkoutsPlus/Workout/WorkoutDetail.swift @@ -45,7 +45,7 @@ struct WorkoutDetail: View { AddItemButton(label: "Exercise", action: presentWorkoutItemLibrarySheet) } } - .navigationBarTitle("\(workout.name)") + .navigationTitle("\(workout.name)") .toolbar { // TODO: Add proper Sharing for workouts. ToolbarItem() { ShareLink(item: URL(filePath: "felixfoertsch.de")!) } @@ -88,14 +88,14 @@ struct WorkoutDetail: View { #Preview { NavigationStack { - WorkoutDetail(workout: Workout.sampleData) + WorkoutDetail(workout: Workout.sampleData.first!) .modelContainer(SampleData.shared.modelContainer) } } #Preview("Debug") { TabView { - WorkoutDetail(workout: Workout.sampleData) + WorkoutDetail(workout: Workout.sampleData.first!) .tabItem { Image(systemName: "figure.run.square.stack") Text("Workouts") diff --git a/WorkoutsPlus/Workout/WorkoutIconSelector.swift b/WorkoutsPlus/Workout/WorkoutIconSelector.swift index 8e557db..62cba34 100644 --- a/WorkoutsPlus/Workout/WorkoutIconSelector.swift +++ b/WorkoutsPlus/Workout/WorkoutIconSelector.swift @@ -57,14 +57,14 @@ struct WorkoutIconSelector: View { } .overlay { if filteredIcons.isEmpty { - ContentUnavailableView.search + ContentUnavailableView.search(text: searchText) } } } } #Preview { - NavigationView() { - WorkoutIconSelector(workout: Workout.sampleData) + NavigationStack() { + WorkoutIconSelector(workout: Workout.sampleData.first!) } } diff --git a/WorkoutsPlus/Workout/WorkoutItemLibrarySheet.swift b/WorkoutsPlus/Workout/WorkoutItemLibrarySheet.swift index f657d44..55e576b 100644 --- a/WorkoutsPlus/Workout/WorkoutItemLibrarySheet.swift +++ b/WorkoutsPlus/Workout/WorkoutItemLibrarySheet.swift @@ -56,11 +56,11 @@ struct WorkoutItemLibrarySheet: View { } #Preview("With Sample Data") { - WorkoutItemLibrarySheet(workout: Workout.sampleData) + WorkoutItemLibrarySheet(workout: Workout.sampleData.first!) .modelContainer(SampleData.shared.modelContainer) } #Preview("Empty Database") { - WorkoutItemLibrarySheet(workout: Workout.sampleData) + WorkoutItemLibrarySheet(workout: Workout.sampleData.first!) .modelContainer(for: Exercise.self, inMemory: true) } diff --git a/WorkoutsPlus/Workout/WorkoutLibrary.swift b/WorkoutsPlus/Workout/WorkoutLibrary.swift index 6885052..b63e5cf 100644 --- a/WorkoutsPlus/Workout/WorkoutLibrary.swift +++ b/WorkoutsPlus/Workout/WorkoutLibrary.swift @@ -19,15 +19,13 @@ struct WorkoutLibrary: View { @State private var searchText: String = "" var filteredItems: [Workout] { - if searchText.isEmpty { - return workouts - } else { - return workouts.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + if searchText.isEmpty { return workouts } + else { return workouts.filter { $0.name.localizedCaseInsensitiveContains(searchText) } } } var body: some View { - NavigationView { + Group { List { ForEach(filteredItems) { workout in @@ -53,13 +51,12 @@ struct WorkoutLibrary: View { } .searchable(text: $searchText) } - .navigationBarTitle("Workouts") + .navigationTitle("Workouts") .toolbar { ToolbarItem() { EditButton() } } - } } private func addWorkout() { @@ -71,7 +68,6 @@ struct WorkoutLibrary: View { } } - // TODO: Brauchen wir das? private func save(workout: Workout) { withAnimation { newWorkout.name = newWorkoutName @@ -94,12 +90,16 @@ struct WorkoutLibrary: View { } #Preview("With Sample Data") { - WorkoutLibrary() + NavigationStack { + WorkoutLibrary() + } .modelContainer(SampleData.shared.modelContainer) } #Preview("Empty Database") { - WorkoutLibrary() + NavigationStack { + WorkoutLibrary() + } .modelContainer(for: Workout.self, inMemory: true) } diff --git a/WorkoutsPlus/WorkoutsPlusApp.swift b/WorkoutsPlus/WorkoutsPlusApp.swift index d1f9426..f045b7d 100644 --- a/WorkoutsPlus/WorkoutsPlusApp.swift +++ b/WorkoutsPlus/WorkoutsPlusApp.swift @@ -11,11 +11,7 @@ import SwiftData @main struct WorkoutsPlusApp: App { var sharedModelContainer: ModelContainer = { - let schema = Schema([ - WorkoutItem.self, - Exercise.self, - Workout.self - ]) + let schema = WorkoutsPlusApp.swiftDataSchema let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { @@ -32,3 +28,11 @@ struct WorkoutsPlusApp: App { .modelContainer(sharedModelContainer) } } + +extension WorkoutsPlusApp { + static let swiftDataSchema = Schema([ + Exercise.self, + Workout.self, + WorkoutItem.self, + WorkoutSession.self]) +}