change folders to the "feature" mindset
This commit is contained in:
24
WorkoutsPlus/Features/Exercise/Equipment.swift
Normal file
24
WorkoutsPlus/Features/Exercise/Equipment.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Equipment.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 01.10.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Equipment: Nameable {
|
||||
static var systemImage = "dumbbell.fill"
|
||||
|
||||
var id = UUID()
|
||||
var creationDate: Date = Date.now
|
||||
|
||||
@Attribute(.unique) var name: String
|
||||
var exercises: [Exercise] = []
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
75
WorkoutsPlus/Features/Exercise/Exercise.swift
Normal file
75
WorkoutsPlus/Features/Exercise/Exercise.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// Exercise.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 25.08.24.
|
||||
//
|
||||
|
||||
// TODO: The model currently has an issue. I think An exercise always (!) has reps 1x 30s or 8x 1 0 kg Push-up. I stumbled upon the issue through the fact that bodyweight exercises can be loaded and for the user it makes sense that a Push-up is the same a Push-up with 10 kg load.
|
||||
// var equipment: [Equipment]
|
||||
// var isPartOfProgression: Bool = false
|
||||
// var type: ExerciseType = [.cardio, .flexibility, .strength, .balance]
|
||||
// var focus: ExerciseFocus? // Strength, Flexibility, Speed, etc
|
||||
// TODO: add a way to save suggested metrics (distance, reps, etc)
|
||||
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Exercise: Nameable {
|
||||
static var systemImage = "figure.run"
|
||||
|
||||
var id = UUID()
|
||||
var creationDate: Date = Date.now
|
||||
|
||||
@Attribute(.unique) var name: String
|
||||
var exerciseDescription: String = ""
|
||||
var isPartOfProgression: Bool = false
|
||||
var metric: ExerciseMetric
|
||||
|
||||
var equipment: [Equipment] = []
|
||||
|
||||
init(_ name: String, _ metric: ExerciseMetric = .none) {
|
||||
self.name = name
|
||||
self.metric = metric
|
||||
}
|
||||
}
|
||||
|
||||
enum ExerciseMetric: String, Codable, CaseIterable, Identifiable {
|
||||
case none = ""
|
||||
case distance = "Distance"
|
||||
case time = "Time"
|
||||
case weight = "Weight"
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
extension Exercise {
|
||||
static let sampleDataRecommendedRoutine: [Exercise] = [
|
||||
Exercise("Shoulder Band Warm-up"),
|
||||
Exercise("Squat Sky Reaches"),
|
||||
Exercise("GMB Wrist Prep", .time),
|
||||
Exercise("Dead Bugs"),
|
||||
Exercise("Pull-up Progression", .weight),
|
||||
Exercise("Dip Progression", .weight),
|
||||
Exercise("Squat Progression", .weight),
|
||||
Exercise("Hinge Progression", .weight),
|
||||
Exercise("Row Progression", .weight),
|
||||
Exercise("Push-up Progression", .weight),
|
||||
Exercise("Handstand Practice", .time),
|
||||
Exercise("Support Practice", .time)
|
||||
]
|
||||
|
||||
static let sampleDataRings: [Exercise] = [
|
||||
Exercise("Dips", .weight),
|
||||
Exercise("Chin-ups", .weight),
|
||||
Exercise("Push-ups", .weight),
|
||||
Exercise("Inverted Rows", .weight),
|
||||
Exercise("Hanging Knee Raises", .weight),
|
||||
Exercise("Pistol Squats", .weight),
|
||||
Exercise("Hanging Leg Curls", .weight),
|
||||
Exercise("Sissy Squats")
|
||||
]
|
||||
|
||||
}
|
||||
46
WorkoutsPlus/Features/Exercise/ExerciseDetail.swift
Normal file
46
WorkoutsPlus/Features/Exercise/ExerciseDetail.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// ExerciseDetail.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
|
||||
|
||||
@State 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 {
|
||||
NavigationStack {
|
||||
ExerciseDetail(exercise: Exercise.sampleDataRecommendedRoutine.first!)
|
||||
}
|
||||
}
|
||||
108
WorkoutsPlus/Features/Exercise/ExerciseEditor.swift
Normal file
108
WorkoutsPlus/Features/Exercise/ExerciseEditor.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// AddExercise.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 21.09.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ExerciseEditor: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var isPresentedAsSheet: Bool = false
|
||||
|
||||
@State var exercise: Exercise?
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var description: String = ""
|
||||
@State private var metric: ExerciseMetric = .none
|
||||
|
||||
@State private var isPartOfProgression: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
ScrollViewReader { proxy in
|
||||
VStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Exercise Name", text: $name)
|
||||
// TODO: Add Autocomplete
|
||||
TextEditorWithPlaceholder(text: $description, placeholder: "Description (optional)")
|
||||
}
|
||||
Section(footer: Text("""
|
||||
Examples:
|
||||
• Pull-up → None
|
||||
• Weighted Pull-up → Weight
|
||||
• Sprint → Time or Distance
|
||||
""")) {
|
||||
Picker("Exercise Metric", selection: $metric) {
|
||||
ForEach(ExerciseMetric.allCases) { metric in
|
||||
Text(metric.rawValue.isEmpty ? "None" : metric.rawValue)
|
||||
.tag(metric)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
Section(footer: Text("Feature coming soon.")) {
|
||||
Toggle(isOn: $isPartOfProgression) {
|
||||
Text("Exercise is Part of a Progression")
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
.disabled(true)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit")
|
||||
.toolbar {
|
||||
if isPresentedAsSheet {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
withAnimation {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let exercise {
|
||||
self.name = exercise.name
|
||||
self.description = exercise.exerciseDescription
|
||||
self.metric = exercise.metric
|
||||
self.isPartOfProgression = exercise.isPartOfProgression
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let exerciseToSave: Exercise
|
||||
|
||||
if let exercise = exercise {
|
||||
exerciseToSave = exercise
|
||||
} else {
|
||||
exerciseToSave = Exercise(name)
|
||||
modelContext.insert(exerciseToSave)
|
||||
}
|
||||
|
||||
exerciseToSave.name = name
|
||||
exerciseToSave.exerciseDescription = description
|
||||
exerciseToSave.metric = metric
|
||||
exerciseToSave.isPartOfProgression = isPartOfProgression
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ExerciseEditor()
|
||||
}
|
||||
104
WorkoutsPlus/Features/Exercise/ExerciseLibrary.swift
Normal file
104
WorkoutsPlus/Features/Exercise/ExerciseLibrary.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// 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 = Exercise("")
|
||||
@State private var newExerciseName: String = ""
|
||||
@State private var isAddingExercise: Bool = false
|
||||
@FocusState private var isInputFieldFocused: Bool
|
||||
|
||||
@State private var searchText: String = ""
|
||||
var filteredItems: [Exercise] {
|
||||
if searchText.isEmpty {
|
||||
return exercises
|
||||
} else {
|
||||
return exercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
List {
|
||||
Section {
|
||||
ForEach(filteredItems) { exercise in
|
||||
NavigationLink {
|
||||
ExerciseEditor(exercise: exercise)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(exercise.name)
|
||||
Spacer()
|
||||
// Text(exercise.metric.rawValue)
|
||||
// .font(.footnote)
|
||||
// .foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteExercise)
|
||||
if filteredItems.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
AddItemButton(label: "Exercise", action: addExercise)
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText)
|
||||
}
|
||||
.navigationTitle("Exercises")
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addExercise) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isAddingExercise) {
|
||||
ExerciseEditor(isPresentedAsSheet: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func addExercise() {
|
||||
withAnimation {
|
||||
isAddingExercise = true
|
||||
isInputFieldFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteExercise(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(exercises[index])
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Sample Data") {
|
||||
NavigationStack {
|
||||
ExerciseLibrary()
|
||||
}
|
||||
.modelContainer(SampleData.shared.modelContainer)
|
||||
|
||||
}
|
||||
|
||||
#Preview("Empty Database") {
|
||||
NavigationStack {
|
||||
ExerciseLibrary()
|
||||
}
|
||||
.modelContainer(for: WorkoutItem.self, inMemory: true)
|
||||
}
|
||||
|
||||
20
WorkoutsPlus/Features/Food/Food.swift
Normal file
20
WorkoutsPlus/Features/Food/Food.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Food.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 28.09.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
//enum FoodUnit: String, CaseIterable, CustomStringConvertible {
|
||||
// case gram = "g"
|
||||
// case milliliter = "ml"
|
||||
//
|
||||
// var description: String { rawValue }
|
||||
//}
|
||||
|
||||
//struct FoodValue: ValueType {
|
||||
// var value: String = ""
|
||||
// var unit: FoodUnit = .gram
|
||||
//}
|
||||
27
WorkoutsPlus/Features/Food/Unit.swift
Normal file
27
WorkoutsPlus/Features/Food/Unit.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// Random.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 01.10.24.
|
||||
//
|
||||
|
||||
//import Foundation
|
||||
//
|
||||
//enum Unit: String, CaseIterable, Codable {
|
||||
// case none = ""
|
||||
//
|
||||
// case kilogram = "kg"
|
||||
// case gram = "g"
|
||||
//
|
||||
// case liter = "l"
|
||||
// case milliliter = "ml"
|
||||
//
|
||||
// case kilometer = "km"
|
||||
// case meter = "m"
|
||||
//
|
||||
// case hour = "h"
|
||||
// case minute = "min"
|
||||
// case second = "s"
|
||||
//
|
||||
// var description: String { rawValue }
|
||||
//}
|
||||
129
WorkoutsPlus/Features/Home/ActivityLog.swift
Normal file
129
WorkoutsPlus/Features/Home/ActivityLog.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
132
WorkoutsPlus/Features/Home/Miniplayer.swift
Normal file
132
WorkoutsPlus/Features/Home/Miniplayer.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// Miniplayer.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 07.09.24.
|
||||
//
|
||||
|
||||
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
|
||||
|
||||
|
||||
// @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 {
|
||||
if (isWorkingOut) {
|
||||
Button(action: {
|
||||
withAnimation() { isFullScreenCoverPresented.toggle() }}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("8x Push-ups")
|
||||
.font(.headline)
|
||||
Text("Recommended Routine - 35 minutes")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
// TODO: Maybe replace it with a clock
|
||||
Button(action: {
|
||||
withAnimation() {
|
||||
// TODO: This button has to do something with the workout (pause it, next exercise, etc)
|
||||
|
||||
}}) {
|
||||
Image(systemName: "pause.circle.fill")
|
||||
.font(.title)
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, .orange)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fullScreenCover(isPresented: $isFullScreenCoverPresented) {
|
||||
// ActiveWorkoutSession(workout: workout, workoutSession: workoutSession)
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
withAnimation() { isFullScreenCoverPresented.toggle() }}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Start Workout")
|
||||
.font(.headline)
|
||||
// TODO: Replace this with the upcoming/planned workout
|
||||
// Text(selectedWorkoutId)
|
||||
// .font(.subheadline)
|
||||
// .foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: {
|
||||
withAnimation() {
|
||||
// TODO: This button "quick starts" the workout (skips over the ActiveWorkoutSession fullscreen cover)
|
||||
// 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)
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, .green)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fullScreenCover(isPresented: $isFullScreenCoverPresented) {
|
||||
// ActiveWorkoutSession(workout: workout, workoutSession: workoutSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: 60)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
.shadow(radius: 10)
|
||||
.padding(.horizontal)
|
||||
// .padding(.bottom, 65)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView {
|
||||
List {
|
||||
Button(action: {
|
||||
Defaults.shared.isWorkingOut.toggle()
|
||||
}) {
|
||||
Text("Toggle")
|
||||
}
|
||||
ForEach(0..<20) { _ in
|
||||
Text("An item")
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
Text("Plans & Goals")
|
||||
}
|
||||
Text("Dummy")
|
||||
.tabItem {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
Text("Plans & Goals")
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
MiniPlayer()
|
||||
.onAppear {
|
||||
Defaults.shared.isWorkingOut = false
|
||||
}
|
||||
}
|
||||
}
|
||||
109
WorkoutsPlus/Features/Home/WorkoutLog.swift
Normal file
109
WorkoutsPlus/Features/Home/WorkoutLog.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// WorkoutLog.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 30.08.24.
|
||||
//
|
||||
|
||||
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 {
|
||||
List {
|
||||
Section(header: Text("Dummies")) {
|
||||
NavigationLink(destination: Text("WorkoutLogDetails")) {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "figure.run")
|
||||
.padding(.trailing)
|
||||
.padding(.top)
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("23.01.1988")
|
||||
Text("14:37 Uhr")
|
||||
Spacer()
|
||||
}
|
||||
Text("Marathon")
|
||||
.fontWeight(.semibold)
|
||||
HStack {
|
||||
Text("34 km/42 km")
|
||||
Text("•")
|
||||
Text("5:12 min/km")
|
||||
}.foregroundStyle(.gray)
|
||||
HStack {
|
||||
Text("1 h")
|
||||
Text("•")
|
||||
Text("1.337 kcal")
|
||||
}
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
NavigationLink(destination: Text("WorkoutLogDetails")) {
|
||||
HStack {
|
||||
Image(systemName: "figure.run")
|
||||
.padding(.trailing)
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text("23.01.1988")
|
||||
Text("14:37 Uhr")
|
||||
Spacer()
|
||||
}
|
||||
Text("Recommended Routine")
|
||||
.fontWeight(.semibold)
|
||||
HStack {
|
||||
Text("6/12 sets")
|
||||
Text("•")
|
||||
Text("50 %")
|
||||
}.foregroundStyle(.gray)
|
||||
HStack {
|
||||
Text("57 m")
|
||||
Text("•")
|
||||
Text("357 kcal")
|
||||
}
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("Workout Sessions")) {
|
||||
ForEach(workoutSessions) { session in
|
||||
VStack(alignment: .leading) {
|
||||
Text(session.startDate.ISO8601Format())
|
||||
Text(session.name)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}.onDelete(perform: deleteWorkoutSession)
|
||||
}
|
||||
|
||||
}
|
||||
.navigationTitle("Workout Logs")
|
||||
}
|
||||
|
||||
private func deleteWorkoutSession(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(workoutSessions[index])
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Active WorkoutSession") {
|
||||
NavigationStack {
|
||||
WorkoutLog()
|
||||
.onAppear {
|
||||
Defaults.shared.isWorkingOut = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("No Active WorkoutSession") {
|
||||
WorkoutLog()
|
||||
}
|
||||
83
WorkoutsPlus/Features/Onboarding/Onboarding.swift
Normal file
83
WorkoutsPlus/Features/Onboarding/Onboarding.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// Onboarding.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 04.09.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
// TODO: Inspiration holen von https://github.com/SvenTiigi/WhatsNewKit
|
||||
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()
|
||||
}
|
||||
70
WorkoutsPlus/Features/Settings/Settings.swift
Normal file
70
WorkoutsPlus/Features/Settings/Settings.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// Settings.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 30.08.24.
|
||||
//
|
||||
|
||||
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"),
|
||||
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 ")
|
||||
}
|
||||
Button("Reset App", role: .destructive, action: resetApp)
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("Credits") {
|
||||
Text(
|
||||
"""
|
||||
# Software Components
|
||||
## Duration Picker
|
||||
https://github.com/mac-gallagher/DurationPicker
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
|
||||
private func resetApp() {
|
||||
isFirstAppStart = true
|
||||
isOnboarding = true
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
Settings()
|
||||
}
|
||||
}
|
||||
34
WorkoutsPlus/Features/Sharing/Person.swift
Normal file
34
WorkoutsPlus/Features/Sharing/Person.swift
Normal 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
|
||||
}
|
||||
}
|
||||
41
WorkoutsPlus/Features/Workout/AddWorkout.swift
Normal file
41
WorkoutsPlus/Features/Workout/AddWorkout.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// AddWorkout.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
|
||||
|
||||
@State 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.sampleData.first!)
|
||||
}
|
||||
}
|
||||
145
WorkoutsPlus/Features/Workout/Workout.swift
Normal file
145
WorkoutsPlus/Features/Workout/Workout.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// Workout.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 10.08.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Workout: Nameable, Hashable {
|
||||
static var systemImage = "figure.run.square.stack"
|
||||
|
||||
var id = UUID()
|
||||
var creationDate = Date.now
|
||||
|
||||
// The name of my workout is: Recommended Routine, My Marathon Workout
|
||||
@Attribute(.unique) var name: String
|
||||
var defaultRestTime: TimeInterval = 60
|
||||
var useDefaultRestTime: Bool = false
|
||||
|
||||
// Icon
|
||||
var workoutIconSystemName = "figure.run"
|
||||
var workoutIconColorName = ColorName.black
|
||||
|
||||
// TODO: Expected Duration (learn from past workouts with ML and predict workout duration from that
|
||||
|
||||
@Relationship(deleteRule: .cascade) private var workoutItems: [WorkoutItem] = []
|
||||
func getWorkoutItems() -> [WorkoutItem] {
|
||||
return workoutItems.sorted { $0.position < $1.position }
|
||||
}
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
func start() -> WorkoutSession {
|
||||
return WorkoutSession(start: self)
|
||||
}
|
||||
|
||||
func add(workoutItem: WorkoutItem) {
|
||||
self.workoutItems.append(workoutItem)
|
||||
updateWorkoutItemsPositions()
|
||||
}
|
||||
|
||||
func add(workoutItems: [WorkoutItem]) {
|
||||
for workoutItem in workoutItems {
|
||||
self.workoutItems.append(workoutItem)
|
||||
}
|
||||
updateWorkoutItemsPositions()
|
||||
}
|
||||
|
||||
func moveWorkoutItem(from source: IndexSet, to destination: Int) {
|
||||
workoutItems.move(fromOffsets: source, toOffset: destination)
|
||||
updateWorkoutItemsPositions()
|
||||
}
|
||||
|
||||
private func updateWorkoutItemsPositions() {
|
||||
for (index, exercise) in workoutItems.enumerated() {
|
||||
exercise.position = index
|
||||
}
|
||||
}
|
||||
|
||||
func isSelected(workout: Workout) -> Bool { self.id == workout.id }
|
||||
}
|
||||
|
||||
extension Workout {
|
||||
// This enum maps the system colors to enable storing them with the SwiftData model data as String.
|
||||
// See Workout.workoutIconColorName for a usage example.
|
||||
// TODO: Use a Macro to reduce this horrible contraption to something maintainable.
|
||||
enum ColorName: String, Codable {
|
||||
case black
|
||||
case blue
|
||||
case cyan
|
||||
case gray
|
||||
case green
|
||||
case indigo
|
||||
case mint
|
||||
case orange
|
||||
case pink
|
||||
case purple
|
||||
case red
|
||||
case white
|
||||
case yellow
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .black: return .black
|
||||
case .blue: return .blue
|
||||
case .cyan: return .cyan
|
||||
case .gray: return .gray
|
||||
case .green: return .green
|
||||
case .indigo: return .indigo
|
||||
case .mint: return .mint
|
||||
case .orange: return .orange
|
||||
case .pink: return .pink
|
||||
case .purple: return .purple
|
||||
case .red: return .red
|
||||
case .white: return .white
|
||||
case .yellow: return .yellow
|
||||
}
|
||||
}
|
||||
|
||||
static func fromColor(_ color: Color) -> ColorName? {
|
||||
switch color {
|
||||
case .black: return .black
|
||||
case .blue: return .blue
|
||||
case .cyan: return .cyan
|
||||
case .gray: return .gray
|
||||
case .green: return .green
|
||||
case .indigo: return .indigo
|
||||
case .mint: return .mint
|
||||
case .orange: return .orange
|
||||
case .pink: return .pink
|
||||
case .purple: return .purple
|
||||
case .red: return .red
|
||||
case .white: return .white
|
||||
case .yellow: return .yellow
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Workout {
|
||||
private convenience init(name: String, workoutItems: [WorkoutItem]) {
|
||||
self.init(name: name)
|
||||
self.workoutItems = workoutItems
|
||||
}
|
||||
|
||||
static let sampleData: [Workout] = {
|
||||
var rr = Workout(name: "Recommended Routine")
|
||||
for workoutItem in WorkoutItem.sampleDataRecommendedRoutine {
|
||||
rr.add(workoutItem: workoutItem)
|
||||
}
|
||||
|
||||
var rings = Workout(name: "Fully Body Rings")
|
||||
for workoutItem in WorkoutItem.sampleDataRings {
|
||||
rings.add(workoutItem: workoutItem)
|
||||
}
|
||||
|
||||
return [rr, rings]
|
||||
}()
|
||||
}
|
||||
162
WorkoutsPlus/Features/Workout/WorkoutDetail.swift
Normal file
162
WorkoutsPlus/Features/Workout/WorkoutDetail.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
//
|
||||
// WorkoutDetail.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
|
||||
|
||||
@Default(\.isWorkingOut) var isWorkingOut
|
||||
|
||||
@Binding var activeWorkoutSession: WorkoutSession?
|
||||
|
||||
@State var workout: Workout
|
||||
@State private var isPresentingWorkoutItemLibrarySheet = false
|
||||
|
||||
var numberFormatter: NumberFormatter {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.maximumFractionDigits = 0
|
||||
return f
|
||||
}
|
||||
|
||||
@State var value: Decimal? = 60
|
||||
@State var textFieldIsActive: Bool = false
|
||||
|
||||
@State var defaultRestTime: Double = 45
|
||||
|
||||
@State private var selectedMinutes = 0
|
||||
@State private var selectedSeconds = 0
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(
|
||||
content: {
|
||||
NavigationLink(destination: WorkoutIconSelector(workout: workout)) {
|
||||
TextField("Workout Name", text: $workout.name)
|
||||
Image(systemName: workout.workoutIconSystemName)
|
||||
.scaledToFit()
|
||||
.foregroundStyle(workout.workoutIconColorName.color)
|
||||
}
|
||||
HStack {
|
||||
Text("Default Rest Time (min:s)")
|
||||
Spacer()
|
||||
// TODO: This is f-in horrible. But I need a break rn.
|
||||
NumbersOnlyTextField(value: $defaultRestTime)
|
||||
}
|
||||
},
|
||||
header: { Text("Name & Icon") },
|
||||
footer: { Text("Setting a Default Rest Time inserts the time between each exercise.") })
|
||||
Section(
|
||||
header: Text("Exercises"),
|
||||
footer: Text("Drag and drop to re-arrange or swipe to delete exercises.")) {
|
||||
ForEach(workout.getWorkoutItems()) { workoutItem in
|
||||
WorkoutListItem(workout, workoutItem)
|
||||
}
|
||||
.onDelete(perform: deleteWorkoutItem)
|
||||
.onMove(perform: move)
|
||||
.environment(\.editMode, .constant(.active)) // Always active drag mode
|
||||
AddItemButton(label: "Exercise", action: presentWorkoutItemLibrarySheet)
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(workout.name)")
|
||||
.toolbar {
|
||||
// TODO: Add proper Sharing for workouts.
|
||||
// ToolbarItem() { ShareLink(item: URL(filePath: "felixfoertsch.de")!) }
|
||||
if (isWorkingOut) {
|
||||
Button(action: {
|
||||
isWorkingOut = false
|
||||
activeWorkoutSession?.stop()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "stop.fill")
|
||||
Text("Stop")
|
||||
}
|
||||
}
|
||||
.bold()
|
||||
.fontDesign(.rounded)
|
||||
.tint(.red)
|
||||
}
|
||||
else {
|
||||
Button(action: {
|
||||
isWorkingOut = true
|
||||
activeWorkoutSession = workout.start()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
Text("Start")
|
||||
}
|
||||
}
|
||||
.bold()
|
||||
.fontDesign(.rounded)
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isPresentingWorkoutItemLibrarySheet) {
|
||||
WorkoutItemLibrarySheet(workout: workout)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentWorkoutItemLibrarySheet() {
|
||||
withAnimation {
|
||||
isPresentingWorkoutItemLibrarySheet = true
|
||||
}
|
||||
}
|
||||
|
||||
private func saveWorkout() {
|
||||
if modelContext.hasChanges {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("Failed to save workout: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteWorkoutItem(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(workout.getWorkoutItems()[index])
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
private func move(from source: IndexSet, to destination: Int) {
|
||||
workout.moveWorkoutItem(from: source, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@Previewable @State var activeWorkoutSession: WorkoutSession?
|
||||
|
||||
NavigationStack {
|
||||
WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: Workout.sampleData.first!)
|
||||
.modelContainer(SampleData.shared.modelContainer)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Debug") {
|
||||
@Previewable @State var activeWorkoutSession: WorkoutSession?
|
||||
|
||||
TabView {
|
||||
WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: Workout.sampleData.first!)
|
||||
.tabItem {
|
||||
Image(systemName: "figure.run.square.stack")
|
||||
Text("Workouts")
|
||||
}
|
||||
|
||||
DebugList()
|
||||
.tabItem {
|
||||
Image(systemName: "hammer")
|
||||
Text("Debug")
|
||||
}
|
||||
}
|
||||
.modelContainer(SampleData.shared.modelContainer)
|
||||
}
|
||||
70
WorkoutsPlus/Features/Workout/WorkoutIconSelector.swift
Normal file
70
WorkoutsPlus/Features/Workout/WorkoutIconSelector.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// WorkoutIconSelector.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 26.08.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WorkoutIconSelector: View {
|
||||
|
||||
@State var workout: Workout
|
||||
|
||||
@State private var searchText: String = ""
|
||||
var filteredIcons: [String] {
|
||||
if searchText.isEmpty {
|
||||
return fitnessIcons
|
||||
} else {
|
||||
return fitnessIcons.filter { $0.contains(searchText.lowercased()) }
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50))]) {
|
||||
ForEach(systemColors, id: \.self) { color in
|
||||
Button(action: {
|
||||
workout.workoutIconColorName = Workout.ColorName.fromColor(color)!
|
||||
}) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay(
|
||||
Circle()
|
||||
// TODO: change this from stroke to checkmark symbol in the circle
|
||||
.stroke(Color.black, lineWidth: workout.workoutIconColorName.color == color ? 4 : 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50, maximum: 100))]) {
|
||||
ForEach(filteredIcons, id: \.self) { iconName in
|
||||
Button(action: {
|
||||
workout.workoutIconSystemName = iconName
|
||||
}) {
|
||||
Image(systemName: iconName)
|
||||
.foregroundStyle(workout.workoutIconColorName.color)
|
||||
.padding()
|
||||
.background()
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.searchable(text: $searchText)
|
||||
}
|
||||
.overlay {
|
||||
if filteredIcons.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack() {
|
||||
WorkoutIconSelector(workout: Workout.sampleData.first!)
|
||||
}
|
||||
}
|
||||
108
WorkoutsPlus/Features/Workout/WorkoutItem.swift
Normal file
108
WorkoutsPlus/Features/Workout/WorkoutItem.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// WorkoutItem.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 10.08.24.
|
||||
//
|
||||
|
||||
// TODO: Think about what's happening when an exercise (template) is deleted or changed
|
||||
// If it is delete -> we just keep the WorkoutItem with the name :)
|
||||
// The only relevant delete is delete Workout -> Delete Workoutitems
|
||||
// { didSet { self.name = exercise?.name ?? "self.name" } }
|
||||
|
||||
// TODO: Wondering if a SortDescriptor in the Model is useful?
|
||||
// https://old.reddit.com/r/SwiftUI/comments/1fnvkud/extracting_the_creation_of_swiftdata_query_into/
|
||||
// static var sorted: SortDescriptor<WorkoutItem> {
|
||||
// SortDescriptor(\.position, order: .forward)
|
||||
// }
|
||||
|
||||
// struct ExerciseValue: ValueType {
|
||||
// var value: String = ""
|
||||
// var unit: ExerciseUnit
|
||||
// }
|
||||
|
||||
// TODO: Deload items -> Think about how to model deload/sick/rest/holiday week
|
||||
//enum PerformanceMetric: String, CaseIterable, CustomStringConvertible {
|
||||
// case speed = "m/s"
|
||||
// case pace = "s/m"
|
||||
//
|
||||
// var description: String { rawValue }
|
||||
//}
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class WorkoutItem: Nameable, Positionable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var workout: Workout?
|
||||
// TODO: Make WorkoutItem a protocol so the type can be distinguished by the class type
|
||||
var workoutItemType: WorkoutItemType // Differentiates between exercise/rest/set
|
||||
var position: Int = 0
|
||||
var set: [WorkoutItem] = []
|
||||
|
||||
var exercise: Exercise // Do Push-up | Run Marathon
|
||||
var plannedReps: Int // 8 times | 1 time
|
||||
var plannedValue: Double // With 10 | 42,187
|
||||
var metric: ExerciseMetric? // kg (weight) | km (distance)
|
||||
|
||||
enum WorkoutItemType: Codable {
|
||||
case exercise
|
||||
case rest
|
||||
case set
|
||||
}
|
||||
|
||||
init(_ exercise: Exercise) {
|
||||
self.exercise = exercise
|
||||
self.workoutItemType = .exercise
|
||||
|
||||
// Push-up
|
||||
self.name = exercise.name
|
||||
// 8x
|
||||
self.plannedReps = 1
|
||||
// 0
|
||||
self.plannedValue = 0
|
||||
// kg
|
||||
self.metric = exercise.metric
|
||||
}
|
||||
|
||||
// init(set: [WorkoutItem] = []) {
|
||||
// self.workoutItemType = .set
|
||||
// self.name = "Set"
|
||||
// self.plannedReps = 3
|
||||
// self.plannedValue = 0
|
||||
// set.forEach(addChild)
|
||||
// }
|
||||
// init(rest: Double) {
|
||||
// self.workoutItemType = .rest
|
||||
// self.name = "Rest"
|
||||
// self.plannedReps = 1
|
||||
// self.plannedValue = rest
|
||||
// self.metric = .time
|
||||
// }
|
||||
func addChild(_ child: WorkoutItem) {
|
||||
if self.workoutItemType == .set {
|
||||
self.set.append(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutItem {
|
||||
static let sampleDataRecommendedRoutine: [WorkoutItem] = {
|
||||
var exercises = [WorkoutItem]()
|
||||
for exercise in Exercise.sampleDataRecommendedRoutine {
|
||||
exercises.append(WorkoutItem(exercise))
|
||||
}
|
||||
return exercises
|
||||
}()
|
||||
|
||||
static let sampleDataRings: [WorkoutItem] = {
|
||||
var exercises = [WorkoutItem]()
|
||||
for exercise in Exercise.sampleDataRings {
|
||||
exercises.append(WorkoutItem(exercise))
|
||||
}
|
||||
return exercises
|
||||
}()
|
||||
}
|
||||
|
||||
69
WorkoutsPlus/Features/Workout/WorkoutItemLibrarySheet.swift
Normal file
69
WorkoutsPlus/Features/Workout/WorkoutItemLibrarySheet.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// AddExerciseToWorkout.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 22.08.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutItemLibrarySheet: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \Exercise.name) private var exercises: [Exercise]
|
||||
|
||||
@State var workout: Workout
|
||||
// TODO: Add (i) Button that allows editing an exercise (maybe requires NavigationStack?)
|
||||
|
||||
// TODO: Pass in some context? So that when we come from a Set and add a Set, it's treed. Or when we come from a Set and add an Exercise, we put it into it's child
|
||||
var body: some View {
|
||||
Group {
|
||||
List {
|
||||
// Section(header: Text("Utilities")) {
|
||||
// AddItemButton(label: "Set") {
|
||||
// addWorkoutItemtoWorkout(WorkoutItem(set: [
|
||||
// WorkoutItem(Exercise("Set item 1")),
|
||||
// WorkoutItem(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(exercise)
|
||||
addWorkoutItemtoWorkout(workoutItem)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView {
|
||||
// TODO: Add Button that allows adding an exercise
|
||||
Label("No Exercises", systemImage: Exercise.systemImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private func addWorkoutItemtoWorkout(_ workoutItem: WorkoutItem) {
|
||||
workout.add(workoutItem: workoutItem)
|
||||
// TODO: Handle saving in a way the user knows when it's saved
|
||||
// modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Sample Data") {
|
||||
WorkoutItemLibrarySheet(workout: Workout.sampleData.first!)
|
||||
.modelContainer(SampleData.shared.modelContainer)
|
||||
}
|
||||
|
||||
#Preview("Empty Database") {
|
||||
WorkoutItemLibrarySheet(workout: Workout.sampleData.first!)
|
||||
.modelContainer(for: Exercise.self, inMemory: true)
|
||||
}
|
||||
121
WorkoutsPlus/Features/Workout/WorkoutLibrary.swift
Normal file
121
WorkoutsPlus/Features/Workout/WorkoutLibrary.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// WorkoutLibrary.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 10.08.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLibrary: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Binding var activeWorkoutSession: WorkoutSession?
|
||||
|
||||
@Query(sort: \Workout.name) private var workouts: [Workout]
|
||||
|
||||
@State private var newWorkout: Workout = Workout(name: "")
|
||||
@State private var newWorkoutName: String = ""
|
||||
@State private var isAddingWorkout: Bool = false
|
||||
@FocusState private var isInputFieldFocused: Bool
|
||||
|
||||
@State private var searchText: String = ""
|
||||
var filteredItems: [Workout] {
|
||||
if searchText.isEmpty { return workouts }
|
||||
else { return workouts.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
Group {
|
||||
List {
|
||||
ForEach(filteredItems) { workout in
|
||||
NavigationLink {
|
||||
WorkoutDetail(activeWorkoutSession: $activeWorkoutSession, workout: workout)
|
||||
} label: {
|
||||
Image(systemName: workout.workoutIconSystemName)
|
||||
.foregroundStyle(workout.workoutIconColorName.color)
|
||||
Text(workout.name)
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
activeWorkoutSession = workout.start()
|
||||
} label: {
|
||||
Label("Start", systemImage: "play")
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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: {
|
||||
save(workout: newWorkout)
|
||||
})
|
||||
.textInputAutocapitalization(.words)
|
||||
.focused($isInputFieldFocused)
|
||||
}
|
||||
// TODO: When pressing the button again, it should check if something is being added already and if yes, save it.
|
||||
AddItemButton(label: "Workout", action: addWorkout)
|
||||
}
|
||||
.searchable(text: $searchText)
|
||||
}
|
||||
.navigationTitle("Workouts")
|
||||
.toolbar {
|
||||
ToolbarItem() {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addWorkout() {
|
||||
withAnimation {
|
||||
newWorkout = Workout(name: "")
|
||||
newWorkoutName = ""
|
||||
isAddingWorkout = true
|
||||
isInputFieldFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private func save(workout: Workout) {
|
||||
withAnimation {
|
||||
newWorkout.name = newWorkoutName
|
||||
if !workout.name.isEmpty {
|
||||
modelContext.insert(workout)
|
||||
try? modelContext.save()
|
||||
}
|
||||
isAddingWorkout = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteWorkout(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(workouts[index])
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Sample Data") {
|
||||
@Previewable @State var activeWorkoutSession: WorkoutSession?
|
||||
NavigationStack {
|
||||
WorkoutLibrary(activeWorkoutSession: $activeWorkoutSession)
|
||||
}
|
||||
.modelContainer(SampleData.shared.modelContainer)
|
||||
}
|
||||
|
||||
#Preview("Empty Database") {
|
||||
@Previewable @State var activeWorkoutSession: WorkoutSession?
|
||||
NavigationStack {
|
||||
WorkoutLibrary(activeWorkoutSession: $activeWorkoutSession)
|
||||
}
|
||||
.modelContainer(for: Workout.self, inMemory: true)
|
||||
}
|
||||
|
||||
50
WorkoutsPlus/Features/Workout/WorkoutListItem.swift
Normal file
50
WorkoutsPlus/Features/Workout/WorkoutListItem.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// WorkoutListItem.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 02.09.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// Recursive structure
|
||||
struct WorkoutListItem: View {
|
||||
var workout: Workout
|
||||
@State var workoutItem: WorkoutItem
|
||||
|
||||
init(_ workout: Workout, _ workoutItem: WorkoutItem ) {
|
||||
self.workout = workout
|
||||
self.workoutItem = workoutItem
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch workoutItem.workoutItemType {
|
||||
case .exercise:
|
||||
HStack {
|
||||
Text(workoutItem.name)
|
||||
Spacer()
|
||||
NumbersOnlyTextField(value: $workoutItem.plannedValue)
|
||||
}
|
||||
case .rest:
|
||||
// Trivial base case for rest items
|
||||
RestListItem(restTime: workoutItem.plannedValue)
|
||||
case .set:
|
||||
// Recursive case
|
||||
SetListItem(workout: workout, set: $workoutItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
// WorkoutListItem(Workout(name: "RR"), WorkoutItem(set: [
|
||||
// WorkoutItem(Exercise("Squat")),
|
||||
// WorkoutItem(Exercise("Squat")),
|
||||
// WorkoutItem(Exercise("Squat"))]))
|
||||
WorkoutListItem(Workout(name: "RR"), WorkoutItem(Exercise("Push-ups")))
|
||||
// WorkoutListItem(Workout(name: "RR"), WorkoutItem(rest: 15))
|
||||
WorkoutListItem(Workout(name: "RR"), WorkoutItem(Exercise("Push-ups")))
|
||||
WorkoutListItem(Workout(name: "RR"), WorkoutItem(Exercise("Sprint", .distance)))
|
||||
WorkoutListItem(Workout(name: "RR"), WorkoutItem(Exercise("Run", .time)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// This view can only be viewed, if there exists a WorkoutSession that can be considered active (a person working out).
|
||||
struct ActiveWorkoutSession: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@Default(\.isWorkingOut) var isWorkingOut
|
||||
@State var isTimerRunning: Bool = true
|
||||
|
||||
@Query(sort: \WorkoutSession.name) var workoutSessions: [WorkoutSession]
|
||||
@Binding var activeWorkoutSession: WorkoutSession?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section(header: Text("Workout")) {
|
||||
Text(activeWorkoutSession!.name)
|
||||
}
|
||||
Section(header: Text("Exercises")) {
|
||||
ForEach(activeWorkoutSession!.workoutSessionItems) { workoutItem in
|
||||
ActiveWorkoutSessionListItem(workoutItem: workoutItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Session")
|
||||
.toolbar {
|
||||
Button(action: {
|
||||
isWorkingOut = false
|
||||
activeWorkoutSession?.stop()
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "stop.fill")
|
||||
Text("Stop")
|
||||
}
|
||||
}
|
||||
.bold()
|
||||
.fontDesign(.rounded)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@Previewable @State var activeWorkoutSession: WorkoutSession?
|
||||
@Previewable @State var workout = Workout.sampleData.first!
|
||||
|
||||
NavigationStack {
|
||||
ActiveWorkoutSession(activeWorkoutSession: $activeWorkoutSession)
|
||||
}
|
||||
.onAppear {
|
||||
Defaults.shared.isWorkingOut = false
|
||||
}
|
||||
.modelContainer(SampleData.shared.modelContainer)
|
||||
}
|
||||
|
||||
//#Preview("Empty modelContainer") {
|
||||
// @Previewable @State var activeWorkoutSession: WorkoutSession?
|
||||
//
|
||||
// NavigationStack {
|
||||
// ActiveWorkoutSession(activeWorkoutSession: $activeWorkoutSession)
|
||||
// }
|
||||
// .onAppear {
|
||||
// Defaults.shared.isWorkingOut = false
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// 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())
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
}
|
||||
ProgressView("",
|
||||
value: session.getCurrentExerciseIndex(),
|
||||
total: session.getTotalExerciseCount() - 1
|
||||
)
|
||||
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(start: 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// ActiveWorkoutSessionListItem.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 19.09.24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ActiveWorkoutSessionListItem: View {
|
||||
var workoutItem: WorkoutSessionItem
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@Previewable @State var workoutSession = WorkoutSession(start: Workout.sampleData.first!)
|
||||
List {
|
||||
ForEach(WorkoutItem.sampleDataRecommendedRoutine) { item in
|
||||
ActiveWorkoutSessionListItem(workoutItem: WorkoutSessionItem(workoutSession: workoutSession, planned: item))
|
||||
}
|
||||
}
|
||||
}
|
||||
103
WorkoutsPlus/Features/WorkoutSession/WorkoutSession.swift
Normal file
103
WorkoutsPlus/Features/WorkoutSession/WorkoutSession.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// WorkoutSession.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 07.09.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class WorkoutSession: Nameable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
// TODO: Think about if a refrence to the workout makes sense; a Workout could change.
|
||||
// var workout: Workout
|
||||
var workoutSessionItems: [WorkoutSessionItem] = []
|
||||
|
||||
init(start workout: Workout) {
|
||||
self.name = workout.name
|
||||
for workoutItem in workout.getWorkoutItems() {
|
||||
workoutSessionItems.append(WorkoutSessionItem(workoutSession: self, planned: workoutItem))
|
||||
}
|
||||
}
|
||||
|
||||
// State
|
||||
// var isPaused: Bool
|
||||
// var isCancelled: Bool
|
||||
// var isDeleted: Bool
|
||||
// var isSynced: Bool
|
||||
|
||||
// My workout session started at:
|
||||
var startDate: Date = Date.now
|
||||
// My workout session was completed at:
|
||||
var stopDate: Date? = nil
|
||||
// My workout session took me x seconds.
|
||||
var duration: TimeInterval? = nil
|
||||
// My workout session is completed and moved to the workout log.
|
||||
var isCompleted: Bool = false
|
||||
|
||||
// Exercise Progress
|
||||
var currentExercise = 0
|
||||
|
||||
func isActive() -> Bool {
|
||||
return stopDate == nil
|
||||
}
|
||||
|
||||
// MARK: -- Workout Controls
|
||||
// func start(with workout: Workout) {
|
||||
// self.workout = workout
|
||||
// startDate = Date.now
|
||||
// }
|
||||
|
||||
func pause() {
|
||||
// TODO: Implement proper Pause
|
||||
}
|
||||
|
||||
// Call stop() to terminate the workout.
|
||||
func stop() {
|
||||
isCompleted = true
|
||||
stopDate = Date.now
|
||||
duration = stopDate!.timeIntervalSince(startDate)
|
||||
}
|
||||
|
||||
func prevExercise() {
|
||||
if currentExercise > 0 {
|
||||
currentExercise -= 1
|
||||
}
|
||||
}
|
||||
|
||||
func nextExercise() {
|
||||
|
||||
}
|
||||
|
||||
// MARK: -- Workout Information
|
||||
func getFormattedDuration() -> 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"
|
||||
}
|
||||
|
||||
func getTotalExerciseCount() -> Double {
|
||||
return 100.0
|
||||
}
|
||||
|
||||
func getCurrentExerciseIndex() -> Double {
|
||||
return Double(currentExercise)
|
||||
}
|
||||
|
||||
func getCurrentTodo() -> String {
|
||||
return getCurrentExerciseMetric() + " " + getCurrentExerciseName()
|
||||
}
|
||||
|
||||
func getCurrentExerciseName() -> String {
|
||||
return "Hello"
|
||||
}
|
||||
|
||||
func getCurrentExerciseMetric() -> String {
|
||||
return "Hello"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// WorkoutSessionItem.swift
|
||||
// WorkoutsPlus
|
||||
//
|
||||
// Created by Felix Förtsch on 15.10.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class WorkoutSessionItem: Nameable, Positionable {
|
||||
var id = UUID()
|
||||
var position: Int
|
||||
var name: String
|
||||
|
||||
var workoutSession: WorkoutSession
|
||||
|
||||
var plannedReps: Int // 8 times | 1 time
|
||||
var plannedValue: Double // With 10 | 42,187
|
||||
var metric: ExerciseMetric? // kg (weight) | km (distance)
|
||||
|
||||
var actualReps: Int?
|
||||
var actualValue: Double?
|
||||
|
||||
init(workoutSession: WorkoutSession, planned: WorkoutItem) {
|
||||
self.workoutSession = workoutSession
|
||||
self.name = planned.exercise.name
|
||||
self.position = planned.position
|
||||
self.plannedReps = planned.plannedReps
|
||||
self.plannedValue = planned.plannedValue
|
||||
self.metric = planned.metric
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user