add ActiveWorkoutSession logic, refactor Home, add additional sample data, add isDebug

This commit is contained in:
Felix Förtsch
2024-09-14 17:01:20 +02:00
parent f45d6288dd
commit 0a400ff349
27 changed files with 710 additions and 189 deletions

View File

@@ -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<Workout>(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<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 {
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()
}
}

View File

@@ -0,0 +1,60 @@
//
// ItemPicker.swift
// WorkoutsPlus
//
// Created by Felix Förtsch on 10.09.24.
//
import SwiftUI
import SwiftData
struct ItemPicker<Item: Nameable>: 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<Workout>(items: Workout.sampleData, selectedItem: $selectedWorkout)
}
.modelContainer(SampleData.shared.modelContainer)
}

View File

@@ -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))
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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()

View File

@@ -40,5 +40,7 @@ struct ExerciseDetail: View {
}
#Preview {
ExerciseDetail(exercise: Exercise.sampleData.first!)
NavigationStack {
ExerciseDetail(exercise: Exercise.sampleDataRecommendedRoutine.first!)
}
}

View File

@@ -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)
}

View 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
}
}

View File

@@ -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)
}
}

View File

@@ -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") {

View File

@@ -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"),
]
}

View File

@@ -7,7 +7,7 @@
import Foundation
protocol Nameable: Identifiable {
protocol Nameable: Identifiable, Hashable {
var id: UUID { get }
var name: String { get }
}

View File

@@ -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]
}()
}

View File

@@ -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
}()

View File

@@ -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 () { }
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -36,6 +36,6 @@ struct AddWorkout: View {
#Preview {
Color.clear
.sheet(isPresented: .constant(true)) {
AddWorkout(workout: Workout.sampleData)
AddWorkout(workout: Workout.sampleData.first!)
}
}

View File

@@ -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")

View File

@@ -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!)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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])
}