From 68d90160c64021c8eb8c35c70ad9ecb344ca9c2b Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 20 Jul 2025 19:44:53 -0400 Subject: [PATCH] initial pre-viable version of watch app --- Workouts.xcodeproj/project.pbxproj | 26 ++ Workouts/ContentView.swift | 9 +- Workouts/Models/Exercise.swift | 5 +- Workouts/Models/Split.swift | 133 +++----- Workouts/Utils/Date+formatDate.swift | 9 + Workouts/Utils/Date+formatedDate.swift | 9 + Workouts/Views/Common/CheckboxListItem.swift | 24 -- Workouts/Views/Common/CheckboxStatus.swift | 35 ++ .../Views/Exercises/ExerciseAddEditView.swift | 42 ++- .../ExerciseListView.swift | 0 Workouts/Views/Exercises/ExerciseView.swift | 5 + .../WeightProgressionChartView.swift | 142 ++++++++ Workouts/Views/Settings/SettingsView.swift | 24 ++ Workouts/Views/Workouts/WorkoutListView.swift | 110 +++--- .../{ => _ATTIC_}/Cepo/EditableEntity.swift | 0 .../Cepo/EntityAddEditView.swift | 0 .../{ => _ATTIC_}/Cepo/EntityListView.swift | 0 .../Cepo/NavigationStackChecker.swift | 0 Workouts/_ATTIC_/ContentView_backup.swift | 55 +++ .../_ATTIC_/ExerciseProgressView_backup.swift | 317 ++++++++++++++++++ .../Splits => _ATTIC_}/SplitPickerView.swift | 0 Worksouts Watch App/ContentView.swift | 83 ++++- Worksouts Watch App/Schema/AppContainer.swift | 202 +++++++++++ .../Schema/SchemaVersion.swift | 10 + Worksouts Watch App/Utils/AppLogger.swift | 22 ++ Worksouts Watch App/Utils/Color+color.swift | 21 ++ .../Utils/Date+formatDate.swift | 9 + .../Utils/Date+formatDateET.swift | 14 + .../Utils/Date+formatedDate.swift | 9 + .../Utils/HapticFeedback.swift | 33 ++ .../Views/ActiveWorkoutListView.swift | 230 +++++++++++++ .../Views/ExerciseProgressControlView.swift | 268 +++++++++++++++ .../Views/ExerciseProgressView.swift | 317 ++++++++++++++++++ .../Views/WorkoutLogListView.swift | 117 +++++++ Worksouts Watch App/WorksoutsApp.swift | 7 +- 35 files changed, 2108 insertions(+), 179 deletions(-) create mode 100644 Workouts/Utils/Date+formatDate.swift create mode 100644 Workouts/Utils/Date+formatedDate.swift create mode 100644 Workouts/Views/Common/CheckboxStatus.swift rename Workouts/Views/{Splits => Exercises}/ExerciseListView.swift (100%) create mode 100644 Workouts/Views/Exercises/WeightProgressionChartView.swift create mode 100644 Workouts/Views/Settings/SettingsView.swift rename Workouts/{ => _ATTIC_}/Cepo/EditableEntity.swift (100%) rename Workouts/{ => _ATTIC_}/Cepo/EntityAddEditView.swift (100%) rename Workouts/{ => _ATTIC_}/Cepo/EntityListView.swift (100%) rename Workouts/{ => _ATTIC_}/Cepo/NavigationStackChecker.swift (100%) create mode 100644 Workouts/_ATTIC_/ContentView_backup.swift create mode 100644 Workouts/_ATTIC_/ExerciseProgressView_backup.swift rename Workouts/{Views/Splits => _ATTIC_}/SplitPickerView.swift (100%) create mode 100644 Worksouts Watch App/Schema/AppContainer.swift create mode 100644 Worksouts Watch App/Schema/SchemaVersion.swift create mode 100644 Worksouts Watch App/Utils/AppLogger.swift create mode 100644 Worksouts Watch App/Utils/Color+color.swift create mode 100644 Worksouts Watch App/Utils/Date+formatDate.swift create mode 100644 Worksouts Watch App/Utils/Date+formatDateET.swift create mode 100644 Worksouts Watch App/Utils/Date+formatedDate.swift create mode 100644 Worksouts Watch App/Utils/HapticFeedback.swift create mode 100644 Worksouts Watch App/Views/ActiveWorkoutListView.swift create mode 100644 Worksouts Watch App/Views/ExerciseProgressControlView.swift create mode 100644 Worksouts Watch App/Views/ExerciseProgressView.swift create mode 100644 Worksouts Watch App/Views/WorkoutLogListView.swift diff --git a/Workouts.xcodeproj/project.pbxproj b/Workouts.xcodeproj/project.pbxproj index 946e3e8..e248424 100644 --- a/Workouts.xcodeproj/project.pbxproj +++ b/Workouts.xcodeproj/project.pbxproj @@ -47,10 +47,27 @@ A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + _ATTIC_/ContentView_backup.swift, + _ATTIC_/ExerciseProgressView_backup.swift, Info.plist, ); target = A45FA0902E21B3DD00581607 /* Workouts */; }; + A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Worksouts Watch App" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + _ATTIC_/ContentView_backup.swift, + _ATTIC_/ExerciseProgressView_backup.swift, + Models/Exercise.swift, + Models/Split.swift, + Models/Workout.swift, + Models/WorkoutLog.swift, + Schema/SchemaV1.swift, + Views/Common/CheckboxStatus.swift, + Views/WorkoutLog/WorkoutStatus.swift, + ); + target = A45FA1F02E27171A00581607 /* Worksouts Watch App */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -58,6 +75,7 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( A45FA0A12E21B3DE00581607 /* Exceptions for "Workouts" folder in "Workouts" target */, + A45FA2C52E2D3CBD00581607 /* Exceptions for "Workouts" folder in "Worksouts Watch App" target */, ); path = Workouts; sourceTree = ""; @@ -94,6 +112,7 @@ A45FA0882E21B3DC00581607 = { isa = PBXGroup; children = ( + A45FA2C02E2D3C0900581607 /* Shared Models */, A45FA0932E21B3DD00581607 /* Workouts */, A45FA1F22E27171A00581607 /* Worksouts Watch App */, A45FA0922E21B3DD00581607 /* Products */, @@ -109,6 +128,13 @@ name = Products; sourceTree = ""; }; + A45FA2C02E2D3C0900581607 /* Shared Models */ = { + isa = PBXGroup; + children = ( + ); + path = "Shared Models"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index 480fb8d..a8336be 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -27,7 +27,6 @@ struct ContentView: View { } - // Reports Tab NavigationStack { Text("Reports Placeholder") .navigationTitle("Reports") @@ -36,6 +35,14 @@ struct ContentView: View { Label("Reports", systemImage: "chart.bar") } + NavigationStack { + Text("Achivements") + .navigationTitle("Achievements") + } + .tabItem { + Label("Achivements", systemImage: "star.fill") + } + // SettingsView() // .tabItem { // Label("Settings", systemImage: "gear") diff --git a/Workouts/Models/Exercise.swift b/Workouts/Models/Exercise.swift index 26ca85e..8c49397 100644 --- a/Workouts/Models/Exercise.swift +++ b/Workouts/Models/Exercise.swift @@ -8,16 +8,19 @@ final class Exercise { var sets: Int = 0 var reps: Int = 0 var weight: Int = 0 + var weightLastUpdated: Date = Date() + var weightReminderTimeIntervalWeeks: Int = 2 @Relationship(deleteRule: .nullify) var split: Split? - init(split: Split, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int) { + init(split: Split, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int, weightReminderTimeIntervalWeeks: Int = 2) { self.split = split self.name = exerciseName self.order = order self.sets = sets self.reps = reps self.weight = weight + self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks } } diff --git a/Workouts/Models/Split.swift b/Workouts/Models/Split.swift index be6e6ab..aab181d 100644 --- a/Workouts/Models/Split.swift +++ b/Workouts/Models/Split.swift @@ -31,29 +31,6 @@ final class Split { static let unnamed = "Unnamed Split" } -// MARK: - EditableEntity Conformance - -extension Split: EditableEntity { - var count: Int? { - return self.exercises?.count - } - - static func createNew() -> Split { - return Split(name: "") - } - - static var navigationTitle: String { - return "Splits" - } - - @ViewBuilder - static func formView(for model: Split) -> some View { - EntityAddEditView(model: model) { $model in - SplitFormView(model: $model) - } - } -} - // MARK: - Identifiable Conformance extension Split: Identifiable { @@ -64,58 +41,58 @@ extension Split: Identifiable { } } -// MARK: - Private Form View - -fileprivate struct SplitFormView: View { - @Binding var model: Split - - // Available colors for splits - private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"] - - // Available system images for splits - private let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"] - - var body: some View { - Section(header: Text("Name")) { - TextField("Name", text: $model.name) - .bold() - } - - Section(header: Text("Appearance")) { - Picker("Color", selection: $model.color) { - ForEach(availableColors, id: \.self) { colorName in - let tempSplit = Split(name: "", color: colorName) - HStack { - Circle() - .fill(tempSplit.getColor()) - .frame(width: 20, height: 20) - Text(colorName.capitalized) - } - .tag(colorName) - } - } - - Picker("Icon", selection: $model.systemImage) { - ForEach(availableIcons, id: \.self) { iconName in - HStack { - Image(systemName: iconName) - .frame(width: 24, height: 24) - Text(iconName.replacingOccurrences(of: ".fill", with: "").replacingOccurrences(of: "figure.", with: "").capitalized) - } - .tag(iconName) - } - } - } - - Section(header: Text("Exercises")) { - NavigationLink { - ExerciseListView(split: model) - } label: { - ListItem( - text: "Exercises", - count: model.exercises?.count ?? 0 - ) - } - } - } -} +//// MARK: - Private Form View +// +//fileprivate struct SplitFormView: View { +// @Binding var model: Split +// +// // Available colors for splits +// private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"] +// +// // Available system images for splits +// private let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"] +// +// var body: some View { +// Section(header: Text("Name")) { +// TextField("Name", text: $model.name) +// .bold() +// } +// +// Section(header: Text("Appearance")) { +// Picker("Color", selection: $model.color) { +// ForEach(availableColors, id: \.self) { colorName in +// let tempSplit = Split(name: "", color: colorName) +// HStack { +// Circle() +// .fill(tempSplit.getColor()) +// .frame(width: 20, height: 20) +// Text(colorName.capitalized) +// } +// .tag(colorName) +// } +// } +// +// Picker("Icon", selection: $model.systemImage) { +// ForEach(availableIcons, id: \.self) { iconName in +// HStack { +// Image(systemName: iconName) +// .frame(width: 24, height: 24) +// Text(iconName.replacingOccurrences(of: ".fill", with: "").replacingOccurrences(of: "figure.", with: "").capitalized) +// } +// .tag(iconName) +// } +// } +// } +// +// Section(header: Text("Exercises")) { +// NavigationLink { +// ExerciseListView(split: model) +// } label: { +// ListItem( +// text: "Exercises", +// count: model.exercises?.count ?? 0 +// ) +// } +// } +// } +//} diff --git a/Workouts/Utils/Date+formatDate.swift b/Workouts/Utils/Date+formatDate.swift new file mode 100644 index 0000000..ff3ff26 --- /dev/null +++ b/Workouts/Utils/Date+formatDate.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Date { + func formatDate() -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter.string(from: self) + } +} diff --git a/Workouts/Utils/Date+formatedDate.swift b/Workouts/Utils/Date+formatedDate.swift new file mode 100644 index 0000000..8c12688 --- /dev/null +++ b/Workouts/Utils/Date+formatedDate.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Date { + func formattedDate() -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter.string(from: self) + } +} diff --git a/Workouts/Views/Common/CheckboxListItem.swift b/Workouts/Views/Common/CheckboxListItem.swift index 79af1db..14224f9 100644 --- a/Workouts/Views/Common/CheckboxListItem.swift +++ b/Workouts/Views/Common/CheckboxListItem.swift @@ -9,30 +9,6 @@ import SwiftUI -enum CheckboxStatus { - case checked - case unchecked - case intermediate - case cancelled - - var color: Color { - switch (self) { - case .checked: .green - case .unchecked: .gray - case .intermediate: .yellow - case .cancelled: .red - } - } - - var systemName: String { - switch (self) { - case .checked: "checkmark.circle.fill" - case .unchecked: "circle" - case .intermediate: "ellipsis.circle" - case .cancelled: "cross.circle" - } - } -} struct CheckboxListItem: View { var status: CheckboxStatus diff --git a/Workouts/Views/Common/CheckboxStatus.swift b/Workouts/Views/Common/CheckboxStatus.swift new file mode 100644 index 0000000..da90f40 --- /dev/null +++ b/Workouts/Views/Common/CheckboxStatus.swift @@ -0,0 +1,35 @@ +// +// CheckboxStatus.swift +// Workouts +// +// Created by rzen on 7/20/25 at 11:07 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUICore + +enum CheckboxStatus { + case checked + case unchecked + case intermediate + case cancelled + + var color: Color { + switch (self) { + case .checked: .green + case .unchecked: .gray + case .intermediate: .yellow + case .cancelled: .red + } + } + + var systemName: String { + switch (self) { + case .checked: "checkmark.circle.fill" + case .unchecked: "circle" + case .intermediate: "ellipsis.circle" + case .cancelled: "cross.circle" + } + } +} diff --git a/Workouts/Views/Exercises/ExerciseAddEditView.swift b/Workouts/Views/Exercises/ExerciseAddEditView.swift index dd8d7a6..74efe0e 100644 --- a/Workouts/Views/Exercises/ExerciseAddEditView.swift +++ b/Workouts/Views/Exercises/ExerciseAddEditView.swift @@ -16,19 +16,26 @@ struct ExerciseAddEditView: View { @State var model: Exercise + @State var originalWeight: Int? = nil + var body: some View { NavigationStack { Form { Section(header: Text("Exercise")) { - Button(action: { - showingExercisePicker = true - }) { - HStack { - Text(model.name.isEmpty ? "Select Exercise" : model.name) - Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.gray) + let exerciseName = model.name + if exerciseName.isEmpty { + Button(action: { + showingExercisePicker = true + }) { + HStack { + Text(model.name.isEmpty ? "Select Exercise" : model.name) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } } + } else { + ListItem(title: exerciseName) } } @@ -52,6 +59,20 @@ struct ExerciseAddEditView: View { .frame(width: 130) } } + + Section (header: Text("Weight Increase")) { + HStack { + Text("Remind every \(model.weightReminderTimeIntervalWeeks) weeks") + Spacer() + Stepper("", value: $model.weightReminderTimeIntervalWeeks, in: 0...366) + } + HStack { + Text("Last weight change \(Date().humanTimeInterval(to: model.weightLastUpdated)) ago") + } + } + } + .onAppear { + originalWeight = model.weight } .sheet(isPresented: $showingExercisePicker) { ExercisePickerView { exerciseNames in @@ -68,6 +89,11 @@ struct ExerciseAddEditView: View { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { + if let originalWeight = originalWeight { + if originalWeight != model.weight { + model.weightLastUpdated = Date() + } + } try? modelContext.save() dismiss() } diff --git a/Workouts/Views/Splits/ExerciseListView.swift b/Workouts/Views/Exercises/ExerciseListView.swift similarity index 100% rename from Workouts/Views/Splits/ExerciseListView.swift rename to Workouts/Views/Exercises/ExerciseListView.swift diff --git a/Workouts/Views/Exercises/ExerciseView.swift b/Workouts/Views/Exercises/ExerciseView.swift index f58f797..d0cb2db 100644 --- a/Workouts/Views/Exercises/ExerciseView.swift +++ b/Workouts/Views/Exercises/ExerciseView.swift @@ -9,6 +9,7 @@ import SwiftUI import SwiftData +import Charts struct ExerciseView: View { @Environment(\.modelContext) private var modelContext @@ -98,6 +99,10 @@ struct ExerciseView: View { } .font(.title) } + + Section(header: Text("Progress Tracking")) { + WeightProgressionChartView(exerciseName: workoutLog.exerciseName) + } } .navigationTitle("\(workoutLog.exerciseName)") .navigationDestination(item: $navigateTo) { nextLog in diff --git a/Workouts/Views/Exercises/WeightProgressionChartView.swift b/Workouts/Views/Exercises/WeightProgressionChartView.swift new file mode 100644 index 0000000..3263c10 --- /dev/null +++ b/Workouts/Views/Exercises/WeightProgressionChartView.swift @@ -0,0 +1,142 @@ +// +// WeightProgressionChartView.swift +// Workouts +// +// Created on 7/20/25. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import Charts +import SwiftData + +struct WeightProgressionChartView: View { + @Environment(\.modelContext) private var modelContext + + let exerciseName: String + @State private var weightData: [WeightDataPoint] = [] + @State private var isLoading: Bool = true + @State private var motivationalMessage: String = "" + + var body: some View { + VStack(alignment: .leading) { + if isLoading { + ProgressView("Loading data...") + } else if weightData.isEmpty { + Text("No weight history available yet.") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else { + Text("Weight Progression") + .font(.headline) + .padding(.bottom, 4) + + Chart { + ForEach(weightData) { dataPoint in + LineMark( + x: .value("Date", dataPoint.date), + y: .value("Weight", dataPoint.weight) + ) + .foregroundStyle(Color.blue.gradient) + .interpolationMethod(.catmullRom) + + PointMark( + x: .value("Date", dataPoint.date), + y: .value("Weight", dataPoint.weight) + ) + .foregroundStyle(Color.blue) + } + } + .chartYScale(domain: .automatic(includesZero: false)) + .chartXAxis { + AxisMarks(values: .automatic) { value in + AxisGridLine() + AxisValueLabel(format: .dateTime.month().day()) + } + } + .frame(height: 200) + .padding(.bottom, 8) + + if !motivationalMessage.isEmpty { + Text(motivationalMessage) + .font(.subheadline) + .foregroundColor(.primary) + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } + } + } + .padding() + .onAppear { + loadWeightData() + } + } + + private func loadWeightData() { + isLoading = true + + // Create a fetch descriptor to get workout logs for this exercise + let descriptor = FetchDescriptor( + predicate: #Predicate { log in + log.exerciseName == exerciseName && log.completed == true + }, + sortBy: [SortDescriptor(\WorkoutLog.date)] + ) + + // Fetch the data + if let logs = try? modelContext.fetch(descriptor) { + // Convert to data points + weightData = logs.map { log in + WeightDataPoint(date: log.date, weight: log.weight) + } + + // Generate motivational message based on progress + generateMotivationalMessage() + } + + isLoading = false + } + + private func generateMotivationalMessage() { + guard weightData.count >= 2 else { + motivationalMessage = "Complete more workouts to track your progress!" + return + } + + // Calculate progress metrics + let firstWeight = weightData.first?.weight ?? 0 + let currentWeight = weightData.last?.weight ?? 0 + let weightDifference = currentWeight - firstWeight + + // Generate appropriate message based on progress + if weightDifference > 0 { + let percentIncrease = Int((Double(weightDifference) / Double(firstWeight)) * 100) + if percentIncrease >= 20 { + motivationalMessage = "Amazing progress! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)! 💪" + } else if percentIncrease >= 10 { + motivationalMessage = "Great job! You've increased your weight by \(weightDifference) lbs (\(percentIncrease)%)! 🎉" + } else { + motivationalMessage = "You're making progress! Weight increased by \(weightDifference) lbs. Keep it up! 👍" + } + } else if weightDifference == 0 { + motivationalMessage = "You're maintaining consistent weight. Focus on form and consider increasing when ready!" + } else { + motivationalMessage = "Your current weight is lower than when you started. Adjust your training as needed and keep pushing!" + } + } +} + +// Data structure for chart points +struct WeightDataPoint: Identifiable { + let id = UUID() + let date: Date + let weight: Int +} + +#Preview { + WeightProgressionChartView(exerciseName: "Bench Press") + .modelContainer(for: [WorkoutLog.self], inMemory: true) +} diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..6ebd139 --- /dev/null +++ b/Workouts/Views/Settings/SettingsView.swift @@ -0,0 +1,24 @@ +// +// SettingsView.swift +// Workouts +// +// Created by rzen on 7/20/25 at 8:14 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI + +struct SettingsView: View { + + + var body: some View { + NavigationStack { + Form { + Section (header: Text("Options")) { + + } + } + } + } +} diff --git a/Workouts/Views/Workouts/WorkoutListView.swift b/Workouts/Views/Workouts/WorkoutListView.swift index edead44..cfac4cf 100644 --- a/Workouts/Views/Workouts/WorkoutListView.swift +++ b/Workouts/Views/Workouts/WorkoutListView.swift @@ -20,7 +20,7 @@ struct WorkoutListView: View { @Query(sort: [SortDescriptor(\Workout.start, order: .reverse)]) var workouts: [Workout] - @State private var showingSplitPicker = false +// @State private var showingSplitPicker = false @State private var itemToDelete: Workout? = nil @State private var itemToEdit: Workout? = nil @@ -86,61 +86,61 @@ struct WorkoutListView: View { } message: { Text("Are you sure you want to delete this workout?") } - .sheet(isPresented: $showingSplitPicker) { - SplitPickerView { split in - let workout = Workout(start: Date(), end: Date(), split: split) - modelContext.insert(workout) - if let exercises = split.exercises { - for exercise in exercises { - let workoutLog = WorkoutLog( - workout: workout, - exerciseName: exercise.name, - date: Date(), - order: exercise.order, - sets: exercise.sets, - reps: exercise.reps, - weight: exercise.weight - ) - modelContext.insert(workoutLog) - } - } - try? modelContext.save() - } - } +// .sheet(isPresented: $showingSplitPicker) { +// SplitPickerView { split in +// let workout = Workout(start: Date(), end: Date(), split: split) +// modelContext.insert(workout) +// if let exercises = split.exercises { +// for exercise in exercises { +// let workoutLog = WorkoutLog( +// workout: workout, +// exerciseName: exercise.name, +// date: Date(), +// order: exercise.order, +// sets: exercise.sets, +// reps: exercise.reps, +// weight: exercise.weight +// ) +// modelContext.insert(workoutLog) +// } +// } +// try? modelContext.save() +// } +// } } } } -extension Date { - func formattedDate() -> String { - let calendar = Calendar.current - let now = Date() - - let timeFormatter = DateFormatter() - timeFormatter.dateFormat = "h:mm a" - - let dateFormatter = DateFormatter() - - let date = self - - if calendar.isDateInToday(date) { - return "Today @ \(timeFormatter.string(from: date))" - } else if calendar.isDateInYesterday(date) { - return "Yesterday @ \(timeFormatter.string(from: date))" - } else { - let dateComponents = calendar.dateComponents([.year], from: date) - let currentYearComponents = calendar.dateComponents([.year], from: now) - - if dateComponents.year == currentYearComponents.year { - dateFormatter.dateFormat = "M/d" - } else { - dateFormatter.dateFormat = "M/d/yyyy" - } - - let dateString = dateFormatter.string(from: date) - let timeString = timeFormatter.string(from: date) - return "\(dateString) @ \(timeString)" - } - } - -} +//extension Date { +// func formattedDate() -> String { +// let calendar = Calendar.current +// let now = Date() +// +// let timeFormatter = DateFormatter() +// timeFormatter.dateFormat = "h:mm a" +// +// let dateFormatter = DateFormatter() +// +// let date = self +// +// if calendar.isDateInToday(date) { +// return "Today @ \(timeFormatter.string(from: date))" +// } else if calendar.isDateInYesterday(date) { +// return "Yesterday @ \(timeFormatter.string(from: date))" +// } else { +// let dateComponents = calendar.dateComponents([.year], from: date) +// let currentYearComponents = calendar.dateComponents([.year], from: now) +// +// if dateComponents.year == currentYearComponents.year { +// dateFormatter.dateFormat = "M/d" +// } else { +// dateFormatter.dateFormat = "M/d/yyyy" +// } +// +// let dateString = dateFormatter.string(from: date) +// let timeString = timeFormatter.string(from: date) +// return "\(dateString) @ \(timeString)" +// } +// } +// +//} diff --git a/Workouts/Cepo/EditableEntity.swift b/Workouts/_ATTIC_/Cepo/EditableEntity.swift similarity index 100% rename from Workouts/Cepo/EditableEntity.swift rename to Workouts/_ATTIC_/Cepo/EditableEntity.swift diff --git a/Workouts/Cepo/EntityAddEditView.swift b/Workouts/_ATTIC_/Cepo/EntityAddEditView.swift similarity index 100% rename from Workouts/Cepo/EntityAddEditView.swift rename to Workouts/_ATTIC_/Cepo/EntityAddEditView.swift diff --git a/Workouts/Cepo/EntityListView.swift b/Workouts/_ATTIC_/Cepo/EntityListView.swift similarity index 100% rename from Workouts/Cepo/EntityListView.swift rename to Workouts/_ATTIC_/Cepo/EntityListView.swift diff --git a/Workouts/Cepo/NavigationStackChecker.swift b/Workouts/_ATTIC_/Cepo/NavigationStackChecker.swift similarity index 100% rename from Workouts/Cepo/NavigationStackChecker.swift rename to Workouts/_ATTIC_/Cepo/NavigationStackChecker.swift diff --git a/Workouts/_ATTIC_/ContentView_backup.swift b/Workouts/_ATTIC_/ContentView_backup.swift new file mode 100644 index 0000000..e4188a7 --- /dev/null +++ b/Workouts/_ATTIC_/ContentView_backup.swift @@ -0,0 +1,55 @@ +// +// ContentView.swift +// Workouts +// +// Created by rzen on 7/15/25 at 7:09 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +//import SwiftUI +//import SwiftData +// +//struct ContentView: View { +// @Environment(\.modelContext) private var modelContext +// +// let completedStatus = WorkoutStatus.completed +// +// @Query(filter: #Predicate { workout in +// workout.status?.rawValue != WorkoutStatus.completed.rawValue +// }, sort: \Workout.start, order: .reverse) var activeWorkouts: [Workout] +// +// var body: some View { +// NavigationStack { +// if activeWorkouts.isEmpty { +// NoActiveWorkoutView() +// } else if let currentWorkout = activeWorkouts.first { +// WorkoutLogListView(workout: currentWorkout) +// } +// } +// } +//} +// +//struct NoActiveWorkoutView: View { +// var body: some View { +// VStack(spacing: 16) { +// Image(systemName: "dumbbell.fill") +// .font(.system(size: 40)) +// .foregroundStyle(.gray) +// +// Text("No Active Workout") +// .font(.headline) +// +// Text("Start a workout in the main app") +// .font(.caption) +// .foregroundStyle(.gray) +// .multilineTextAlignment(.center) +// } +// .padding() +// } +//} +// +////#Preview { +//// ContentView() +//// .modelContainer(AppContainer.preview) +////} diff --git a/Workouts/_ATTIC_/ExerciseProgressView_backup.swift b/Workouts/_ATTIC_/ExerciseProgressView_backup.swift new file mode 100644 index 0000000..afab3f2 --- /dev/null +++ b/Workouts/_ATTIC_/ExerciseProgressView_backup.swift @@ -0,0 +1,317 @@ +//import SwiftUI +//import SwiftData +//import WatchKit +// +//// Enum to track the current phase of the exercise +//enum ExercisePhase { +// case notStarted +// case exercising(setNumber: Int) +// case resting(setNumber: Int, elapsedSeconds: Int) +// case completed +//} +// +//struct ExerciseProgressView: View { +// @Environment(\.modelContext) private var modelContext +// @Environment(\.dismiss) private var dismiss +// +// let log: WorkoutLog +// +// @State private var phase: ExercisePhase = .notStarted +// @State private var currentSetNumber: Int = 0 +// @State private var restingSeconds: Int = 0 +// @State private var timer: Timer? +// @State private var hapticTimer: Timer? +// @State private var hapticSeconds: Int = 0 +// +// var body: some View { +// ScrollView { +// VStack(spacing: 16) { +// Text(log.exerciseName) +// .font(.headline) +// .multilineTextAlignment(.center) +// +// switch phase { +// case .notStarted: +// startView +// case .exercising(let setNumber): +// exercisingView(setNumber: setNumber) +// case .resting(let setNumber, let elapsedSeconds): +// restingView(setNumber: setNumber, elapsedSeconds: elapsedSeconds) +// case .completed: +// completedView +// } +// } +// .padding() +// } +// .navigationTitle("Progress") +// .navigationBarTitleDisplayMode(.inline) +// .onDisappear { +// stopTimers() +// } +// } +// +// private var startView: some View { +// VStack(spacing: 16) { +// Text("Ready to start") +// .font(.title3) +// +// Text("\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs") +// .font(.subheadline) +// .foregroundStyle(.secondary) +// +// Button(action: startExercise) { +// Text("Start First Set") +// .font(.headline) +// .foregroundStyle(.white) +// .frame(maxWidth: .infinity) +// .padding(.vertical, 8) +// .background(Color.blue) +// .cornerRadius(8) +// } +// .buttonStyle(PlainButtonStyle()) +// } +// } +// +// private func exercisingView(setNumber: Int) -> some View { +// VStack(spacing: 16) { +// Text("Set \(setNumber) of \(log.sets)") +// .font(.title3) +// +// Text("\(log.reps) reps × \(log.weight) lbs") +// .font(.subheadline) +// .foregroundStyle(.secondary) +// +// Text("In progress: \(hapticSeconds)s") +// .font(.body) +// .monospacedDigit() +// +// HStack { +// Button(action: completeSet) { +// Text("Complete") +// .font(.headline) +// .foregroundStyle(.white) +// .frame(maxWidth: .infinity) +// .padding(.vertical, 8) +// .background(Color.green) +// .cornerRadius(8) +// } +// .buttonStyle(PlainButtonStyle()) +// +// Button(action: cancelSet) { +// Text("Cancel") +// .font(.headline) +// .foregroundStyle(.white) +// .frame(maxWidth: .infinity) +// .padding(.vertical, 8) +// .background(Color.red) +// .cornerRadius(8) +// } +// .buttonStyle(PlainButtonStyle()) +// } +// } +// .gesture( +// DragGesture(minimumDistance: 20) +// .onEnded { gesture in +// if gesture.translation.width < 0 { +// // Swipe left to complete +// completeSet() +// } else if gesture.translation.width > 0 { +// // Swipe right to cancel +// cancelSet() +// } +// } +// ) +// } +// +// private func restingView(setNumber: Int, elapsedSeconds: Int) -> some View { +// VStack(spacing: 16) { +// Text("Rest") +// .font(.title3) +// +// Text("After Set \(setNumber) of \(log.sets)") +// .font(.subheadline) +// .foregroundStyle(.secondary) +// +// Text("Resting: \(elapsedSeconds)s") +// .font(.body) +// .monospacedDigit() +// +// Button(action: { +// if setNumber < log.sets { +// startNextSet() +// } else { +// completeExercise() +// } +// }) { +// Text(setNumber < log.sets ? "Start Next Set" : "Complete Exercise") +// .font(.headline) +// .foregroundStyle(.white) +// .frame(maxWidth: .infinity) +// .padding(.vertical, 8) +// .background(Color.blue) +// .cornerRadius(8) +// } +// .buttonStyle(PlainButtonStyle()) +// } +// .gesture( +// DragGesture(minimumDistance: 20) +// .onEnded { gesture in +// if gesture.translation.width < 0 { +// // Swipe left to start next set or complete +// if setNumber < log.sets { +// startNextSet() +// } else { +// completeExercise() +// } +// } +// } +// ) +// } +// +// private var completedView: some View { +// VStack(spacing: 16) { +// Image(systemName: "checkmark.circle.fill") +// .font(.system(size: 50)) +// .foregroundStyle(.green) +// +// Text("Exercise Completed!") +// .font(.title3) +// +// Button(action: { +// dismiss() +// }) { +// Text("Return to Workout") +// .font(.headline) +// .foregroundStyle(.white) +// .frame(maxWidth: .infinity) +// .padding(.vertical, 8) +// .background(Color.blue) +// .cornerRadius(8) +// } +// .buttonStyle(PlainButtonStyle()) +// } +// } +// +// // MARK: - Actions +// +// private func startExercise() { +// currentSetNumber = 1 +// phase = .exercising(setNumber: currentSetNumber) +// +// // Update workout log status +// log.status = .inProgress +// try? modelContext.save() +// +// // Start haptic timer +// startHapticTimer() +// } +// +// private func completeSet() { +// stopHapticTimer() +// +// // Start rest phase +// restingSeconds = 0 +// phase = .resting(setNumber: currentSetNumber, elapsedSeconds: restingSeconds) +// +// // Start rest timer +// timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in +// restingSeconds += 1 +// phase = .resting(setNumber: currentSetNumber, elapsedSeconds: restingSeconds) +// } +// +// // Start haptic timer for rest phase +// startHapticTimer() +// +// // Play completion haptic +// WKInterfaceDevice.current().play(.success) +// } +// +// private func cancelSet() { +// // Just go back to the previous state +// if currentSetNumber > 1 { +// currentSetNumber -= 1 +// phase = .resting(setNumber: currentSetNumber, elapsedSeconds: 0) +// } else { +// phase = .notStarted +// } +// +// stopHapticTimer() +// stopTimers() +// } +// +// private func startNextSet() { +// stopTimers() +// +// currentSetNumber += 1 +// phase = .exercising(setNumber: currentSetNumber) +// +// // Start haptic timer for next set +// startHapticTimer() +// } +// +// private func completeExercise() { +// stopTimers() +// +// // Update workout log +// log.completed = true +// log.status = .completed +// try? modelContext.save() +// +// // Show completion screen +// phase = .completed +// +// // Play completion haptic +// WKInterfaceDevice.current().play(.success) +// } +// +// // MARK: - Timer Management +// +// private func startHapticTimer() { +// hapticSeconds = 0 +// hapticTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in +// hapticSeconds += 1 +// +// // Provide haptic feedback based on time intervals +// if hapticSeconds % 60 == 0 { +// // Triple tap every 60 seconds +// WKInterfaceDevice.current().play(.notification) +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { +// WKInterfaceDevice.current().play(.notification) +// } +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { +// WKInterfaceDevice.current().play(.notification) +// } +// } else if hapticSeconds % 30 == 0 { +// // Double tap every 30 seconds +// WKInterfaceDevice.current().play(.click) +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { +// WKInterfaceDevice.current().play(.click) +// } +// } else if hapticSeconds % 10 == 0 { +// // Light tap every 10 seconds +// WKInterfaceDevice.current().play(.click) +// } +// } +// } +// +// private func stopHapticTimer() { +// hapticTimer?.invalidate() +// hapticTimer = nil +// hapticSeconds = 0 +// } +// +// private func stopTimers() { +// timer?.invalidate() +// timer = nil +// stopHapticTimer() +// } +//} +// +//#Preview { +// let container = AppContainer.preview +// let workout = Workout(start: Date(), end: Date(), split: nil) +// let log = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135) +// +// return ExerciseProgressView(log: log) +// .modelContainer(container) +//} diff --git a/Workouts/Views/Splits/SplitPickerView.swift b/Workouts/_ATTIC_/SplitPickerView.swift similarity index 100% rename from Workouts/Views/Splits/SplitPickerView.swift rename to Workouts/_ATTIC_/SplitPickerView.swift diff --git a/Worksouts Watch App/ContentView.swift b/Worksouts Watch App/ContentView.swift index caf9e45..028f740 100644 --- a/Worksouts Watch App/ContentView.swift +++ b/Worksouts Watch App/ContentView.swift @@ -2,26 +2,89 @@ // ContentView.swift // Workouts // -// Created by rzen on 7/15/25 at 7:09 PM. +// Created by rzen on 7/15/25 at 7:09 PM. // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // - import SwiftUI +import SwiftData struct ContentView: View { + @Environment(\.modelContext) private var modelContext + + // Use string literal for completed status to avoid enum reference in predicate +// @Query(filter: #Predicate { workout in +// workout.status?.rawValue != 3 +// }, sort: \Workout.start, order: .reverse) var activeWorkouts: [Workout] + +// @Query(sort: [SortDescriptor(\Workout.start)]) var allWorkouts: [Workout] + + @State var activeWorkouts: [Workout] = [] + @State var splits: [Split] = [] + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + NavigationStack { + if activeWorkouts.isEmpty { + NoActiveWorkoutView() + } else { + ActiveWorkoutListView(workouts: activeWorkouts) + } + } + .onAppear { + loadSplits() + loadActiveWorkouts() + } + } + + func loadActiveWorkouts () { + do { + print("loading active workouts") + self.activeWorkouts = try modelContext.fetch(FetchDescriptor( + sortBy: [ + SortDescriptor(\Workout.start) + ] + )) + print("loaded active workouts \(activeWorkouts.count)") + } catch { + print("ERROR: failed to load active workouts \(error)") + } + } + + func loadSplits () { + do { + self.splits = try modelContext.fetch(FetchDescriptor( + sortBy: [ + SortDescriptor(\Split.order), + SortDescriptor(\Split.name) + ] + )) + } catch { + print("ERROR: failed to load splits \(error)") + } + } +} + +struct NoActiveWorkoutView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "dumbbell.fill") + .font(.system(size: 40)) + .foregroundStyle(.gray) + + Text("No Active Workout") + .font(.headline) + + Text("Start a workout in the main app") + .font(.caption) + .foregroundStyle(.gray) + .multilineTextAlignment(.center) } .padding() } } -#Preview { - ContentView() -} +//#Preview { +// ContentView() +// .modelContainer(AppContainer.preview) +//} diff --git a/Worksouts Watch App/Schema/AppContainer.swift b/Worksouts Watch App/Schema/AppContainer.swift new file mode 100644 index 0000000..1644978 --- /dev/null +++ b/Worksouts Watch App/Schema/AppContainer.swift @@ -0,0 +1,202 @@ +import Foundation +import SwiftData + +final class AppContainer { + static let logger = AppLogger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts.watchkitapp", + category: "AppContainer" + ) + + static func create() -> ModelContainer { + // Using the current models directly without migration plan to avoid reference errors + let schema = Schema(SchemaVersion.models) + + #if targetEnvironment(simulator) && os(watchOS) + // Use local-only storage for watchOS simulator + let configuration = ModelConfiguration(isStoredInMemoryOnly: false) + logger.info("Creating local-only database for watchOS simulator") + + do { + let container = try ModelContainer(for: schema, configurations: configuration) + + // Populate with test data if needed + Task { @MainActor in + await populateSimulatorData(container: container) + } + + return container + } catch { + logger.error("Failed to create simulator ModelContainer: \(error.localizedDescription)") + fatalError("Failed to create simulator ModelContainer: \(error.localizedDescription)") + } + #else + // Use CloudKit for real devices + let configuration = ModelConfiguration(cloudKitDatabase: .automatic) + logger.info("Creating CloudKit database for real device") + + let container = try! ModelContainer(for: schema, configurations: configuration) + return container + #endif + } + + @MainActor + static var preview: ModelContainer { + let configuration = ModelConfiguration(isStoredInMemoryOnly: true) + + do { + let schema = Schema(SchemaVersion.models) + let container = try ModelContainer(for: schema, configurations: configuration) + return container + } catch { + fatalError("Failed to create preview ModelContainer: \(error.localizedDescription)") + } + } + + @MainActor + private static func populateSimulatorData(container: ModelContainer) async { + let context = container.mainContext + + // Check if data already exists + let fetchDescriptor = FetchDescriptor() + guard (try? context.fetch(fetchDescriptor))?.isEmpty ?? true else { + logger.info("Simulator database already has data, skipping population") + return // Data already exists + } + + logger.info("Populating simulator database with test data from pf-starter-exercises.yaml") + + // Create splits + let upperBodySplit = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional", order: 0) + let lowerBodySplit = Split(name: "Lower Body", color: "green", systemImage: "figure.run", order: 1) + let fullBodySplit = Split(name: "Full Body", color: "purple", systemImage: "figure.mixed.cardio", order: 2) + let coreSplit = Split(name: "Core", color: "red", systemImage: "figure.core.training", order: 3) + + context.insert(upperBodySplit) + context.insert(lowerBodySplit) + context.insert(fullBodySplit) + context.insert(coreSplit) + + // Create exercises based on pf-starter-exercises.yaml + + // Upper Body Exercises + let latPullDown = Exercise(split: upperBodySplit, exerciseName: "Lat Pull Down", order: 0, sets: 3, reps: 12, weight: 120) + let seatedRow = Exercise(split: upperBodySplit, exerciseName: "Seated Row", order: 1, sets: 3, reps: 12, weight: 110) + let shoulderPress = Exercise(split: upperBodySplit, exerciseName: "Shoulder Press", order: 2, sets: 3, reps: 10, weight: 90) + let chestPress = Exercise(split: upperBodySplit, exerciseName: "Chest Press", order: 3, sets: 3, reps: 10, weight: 130) + let tricepPress = Exercise(split: upperBodySplit, exerciseName: "Tricep Press", order: 4, sets: 3, reps: 12, weight: 70) + let armCurl = Exercise(split: upperBodySplit, exerciseName: "Arm Curl", order: 5, sets: 3, reps: 12, weight: 60) + + context.insert(latPullDown) + context.insert(seatedRow) + context.insert(shoulderPress) + context.insert(chestPress) + context.insert(tricepPress) + context.insert(armCurl) + + // Core Exercises + let abdominal = Exercise(split: coreSplit, exerciseName: "Abdominal", order: 0, sets: 3, reps: 15, weight: 80) + let rotary = Exercise(split: coreSplit, exerciseName: "Rotary", order: 1, sets: 3, reps: 15, weight: 70) + let plank = Exercise(split: coreSplit, exerciseName: "Plank", order: 2, sets: 3, reps: 1, weight: 0) // Reps as time in minutes + let russianTwists = Exercise(split: coreSplit, exerciseName: "Russian Twists", order: 3, sets: 3, reps: 20, weight: 25) + + context.insert(abdominal) + context.insert(rotary) + context.insert(plank) + context.insert(russianTwists) + + // Lower Body Exercises + let legPress = Exercise(split: lowerBodySplit, exerciseName: "Leg Press", order: 0, sets: 3, reps: 12, weight: 200) + let legExtension = Exercise(split: lowerBodySplit, exerciseName: "Leg Extension", order: 1, sets: 3, reps: 12, weight: 110) + let legCurl = Exercise(split: lowerBodySplit, exerciseName: "Leg Curl", order: 2, sets: 3, reps: 12, weight: 90) + let adductor = Exercise(split: lowerBodySplit, exerciseName: "Adductor", order: 3, sets: 3, reps: 15, weight: 100) + let abductor = Exercise(split: lowerBodySplit, exerciseName: "Abductor", order: 4, sets: 3, reps: 15, weight: 90) + let calfs = Exercise(split: lowerBodySplit, exerciseName: "Calfs", order: 5, sets: 3, reps: 15, weight: 120) + + context.insert(legPress) + context.insert(legExtension) + context.insert(legCurl) + context.insert(adductor) + context.insert(abductor) + context.insert(calfs) + + // Full Body Exercises (selected from both upper and lower) + let fullBodyChestPress = Exercise(split: fullBodySplit, exerciseName: "Chest Press", order: 0, sets: 3, reps: 10, weight: 130) + let fullBodyLatPullDown = Exercise(split: fullBodySplit, exerciseName: "Lat Pull Down", order: 1, sets: 3, reps: 12, weight: 120) + let fullBodyLegPress = Exercise(split: fullBodySplit, exerciseName: "Leg Press", order: 2, sets: 3, reps: 12, weight: 200) + let fullBodyAbdominal = Exercise(split: fullBodySplit, exerciseName: "Abdominal", order: 3, sets: 3, reps: 15, weight: 80) + + context.insert(fullBodyChestPress) + context.insert(fullBodyLatPullDown) + context.insert(fullBodyLegPress) + context.insert(fullBodyAbdominal) + + // Create workouts + let now = Date() + + // Upper Body Workout (in progress) + let upperBodyWorkout = Workout(start: now, end: now, split: upperBodySplit) + upperBodyWorkout.status = .inProgress + upperBodyWorkout.end = nil + context.insert(upperBodyWorkout) + + // Lower Body Workout (scheduled for tomorrow) + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: now) ?? now + let lowerBodyWorkout = Workout(start: tomorrow, end: tomorrow, split: lowerBodySplit) + lowerBodyWorkout.status = .notStarted + context.insert(lowerBodyWorkout) + + // Full Body Workout (completed yesterday) + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now) ?? now + let fullBodyWorkout = Workout(start: yesterday, end: yesterday, split: fullBodySplit) + fullBodyWorkout.status = .completed + context.insert(fullBodyWorkout) + + // Create workout logs for Upper Body workout (in progress) + let chestPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: chestPress.name, date: now, order: 0, sets: chestPress.sets, reps: chestPress.reps, weight: chestPress.weight, status: .completed, completed: true) + let shoulderPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: shoulderPress.name, date: now, order: 1, sets: shoulderPress.sets, reps: shoulderPress.reps, weight: shoulderPress.weight, status: .completed, completed: true) + let latPullDownLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: latPullDown.name, date: now, order: 2, sets: latPullDown.sets, reps: latPullDown.reps, weight: latPullDown.weight, status: .inProgress, completed: false) + let seatedRowLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: seatedRow.name, date: now, order: 3, sets: seatedRow.sets, reps: seatedRow.reps, weight: seatedRow.weight, status: .notStarted, completed: false) + let tricepPressLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: tricepPress.name, date: now, order: 4, sets: tricepPress.sets, reps: tricepPress.reps, weight: tricepPress.weight, status: .notStarted, completed: false) + let armCurlLog = WorkoutLog(workout: upperBodyWorkout, exerciseName: armCurl.name, date: now, order: 5, sets: armCurl.sets, reps: armCurl.reps, weight: armCurl.weight, status: .notStarted, completed: false) + + context.insert(chestPressLog) + context.insert(shoulderPressLog) + context.insert(latPullDownLog) + context.insert(seatedRowLog) + context.insert(tricepPressLog) + context.insert(armCurlLog) + + // Create workout logs for Lower Body workout (scheduled) + let legPressLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legPress.name, date: tomorrow, order: 0, sets: legPress.sets, reps: legPress.reps, weight: legPress.weight, status: .notStarted, completed: false) + let legExtensionLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legExtension.name, date: tomorrow, order: 1, sets: legExtension.sets, reps: legExtension.reps, weight: legExtension.weight, status: .notStarted, completed: false) + let legCurlLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: legCurl.name, date: tomorrow, order: 2, sets: legCurl.sets, reps: legCurl.reps, weight: legCurl.weight, status: .notStarted, completed: false) + let adductorLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: adductor.name, date: tomorrow, order: 3, sets: adductor.sets, reps: adductor.reps, weight: adductor.weight, status: .notStarted, completed: false) + let abductorLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: abductor.name, date: tomorrow, order: 4, sets: abductor.sets, reps: abductor.reps, weight: abductor.weight, status: .notStarted, completed: false) + let calfsLog = WorkoutLog(workout: lowerBodyWorkout, exerciseName: calfs.name, date: tomorrow, order: 5, sets: calfs.sets, reps: calfs.reps, weight: calfs.weight, status: .notStarted, completed: false) + + context.insert(legPressLog) + context.insert(legExtensionLog) + context.insert(legCurlLog) + context.insert(adductorLog) + context.insert(abductorLog) + context.insert(calfsLog) + + // Create workout logs for Full Body workout (completed) + let fullBodyChestPressLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyChestPress.name, date: yesterday, order: 0, sets: fullBodyChestPress.sets, reps: fullBodyChestPress.reps, weight: fullBodyChestPress.weight, status: .completed, completed: true) + let fullBodyLatPullDownLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyLatPullDown.name, date: yesterday, order: 1, sets: fullBodyLatPullDown.sets, reps: fullBodyLatPullDown.reps, weight: fullBodyLatPullDown.weight, status: .completed, completed: true) + let fullBodyLegPressLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyLegPress.name, date: yesterday, order: 2, sets: fullBodyLegPress.sets, reps: fullBodyLegPress.reps, weight: fullBodyLegPress.weight, status: .completed, completed: true) + let fullBodyAbdominalLog = WorkoutLog(workout: fullBodyWorkout, exerciseName: fullBodyAbdominal.name, date: yesterday, order: 3, sets: fullBodyAbdominal.sets, reps: fullBodyAbdominal.reps, weight: fullBodyAbdominal.weight, status: .completed, completed: true) + + context.insert(fullBodyChestPressLog) + context.insert(fullBodyLatPullDownLog) + context.insert(fullBodyLegPressLog) + context.insert(fullBodyAbdominalLog) + + do { + try context.save() + logger.info("Successfully populated simulator database with test data from pf-starter-exercises.yaml") + } catch { + logger.error("Failed to save test data: \(error.localizedDescription)") + } + } +} diff --git a/Worksouts Watch App/Schema/SchemaVersion.swift b/Worksouts Watch App/Schema/SchemaVersion.swift new file mode 100644 index 0000000..d98e14f --- /dev/null +++ b/Worksouts Watch App/Schema/SchemaVersion.swift @@ -0,0 +1,10 @@ +import SwiftData + +enum SchemaVersion { + static var models: [any PersistentModel.Type] = [ + Split.self, + Exercise.self, + Workout.self, + WorkoutLog.self + ] +} diff --git a/Worksouts Watch App/Utils/AppLogger.swift b/Worksouts Watch App/Utils/AppLogger.swift new file mode 100644 index 0000000..047eb64 --- /dev/null +++ b/Worksouts Watch App/Utils/AppLogger.swift @@ -0,0 +1,22 @@ +import Foundation +import OSLog + +struct AppLogger { + private let logger: Logger + + init(subsystem: String, category: String) { + self.logger = Logger(subsystem: subsystem, category: category) + } + + func debug(_ message: String) { + logger.debug("\(message)") + } + + func info(_ message: String) { + logger.info("\(message)") + } + + func error(_ message: String) { + logger.error("\(message)") + } +} diff --git a/Worksouts Watch App/Utils/Color+color.swift b/Worksouts Watch App/Utils/Color+color.swift new file mode 100644 index 0000000..dc2cbf7 --- /dev/null +++ b/Worksouts Watch App/Utils/Color+color.swift @@ -0,0 +1,21 @@ +import SwiftUI + +extension Color { + static func color(from name: String) -> Color { + switch name { + case "red": return .red + case "orange": return .orange + case "yellow": return .yellow + case "green": return .green + case "mint": return .mint + case "teal": return .teal + case "cyan": return .cyan + case "blue": return .blue + case "indigo": return .indigo + case "purple": return .purple + case "pink": return .pink + case "brown": return .brown + default: return .indigo + } + } +} diff --git a/Worksouts Watch App/Utils/Date+formatDate.swift b/Worksouts Watch App/Utils/Date+formatDate.swift new file mode 100644 index 0000000..ff3ff26 --- /dev/null +++ b/Worksouts Watch App/Utils/Date+formatDate.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Date { + func formatDate() -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter.string(from: self) + } +} diff --git a/Worksouts Watch App/Utils/Date+formatDateET.swift b/Worksouts Watch App/Utils/Date+formatDateET.swift new file mode 100644 index 0000000..c705a4d --- /dev/null +++ b/Worksouts Watch App/Utils/Date+formatDateET.swift @@ -0,0 +1,14 @@ +import Foundation + +extension Date { + func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(identifier: "America/New_York") + formatter.dateFormat = format + return formatter.string(from: self) + } + + static var ISO8601: String { + "yyyy-MM-dd'T'HH:mm:ssZ" + } +} diff --git a/Worksouts Watch App/Utils/Date+formatedDate.swift b/Worksouts Watch App/Utils/Date+formatedDate.swift new file mode 100644 index 0000000..8c12688 --- /dev/null +++ b/Worksouts Watch App/Utils/Date+formatedDate.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Date { + func formattedDate() -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter.string(from: self) + } +} diff --git a/Worksouts Watch App/Utils/HapticFeedback.swift b/Worksouts Watch App/Utils/HapticFeedback.swift new file mode 100644 index 0000000..6e21e66 --- /dev/null +++ b/Worksouts Watch App/Utils/HapticFeedback.swift @@ -0,0 +1,33 @@ +import Foundation +import WatchKit + +struct HapticFeedback { + static func success() { + WKInterfaceDevice.current().play(.success) + } + + static func notification() { + WKInterfaceDevice.current().play(.notification) + } + + static func click() { + WKInterfaceDevice.current().play(.click) + } + + static func doubleTap() { + WKInterfaceDevice.current().play(.click) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + WKInterfaceDevice.current().play(.click) + } + } + + static func tripleTap() { + WKInterfaceDevice.current().play(.notification) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + WKInterfaceDevice.current().play(.notification) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + WKInterfaceDevice.current().play(.notification) + } + } +} diff --git a/Worksouts Watch App/Views/ActiveWorkoutListView.swift b/Worksouts Watch App/Views/ActiveWorkoutListView.swift new file mode 100644 index 0000000..f78c8b9 --- /dev/null +++ b/Worksouts Watch App/Views/ActiveWorkoutListView.swift @@ -0,0 +1,230 @@ +// +// ActiveWorkoutListView.swift +// Workouts +// +// Created by rzen on 7/20/25 at 6:35 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct ActiveWorkoutListView: View { + @Environment(\.modelContext) private var modelContext + let workouts: [Workout] + + var body: some View { + List { + ForEach(workouts) { workout in + NavigationLink { + WorkoutDetailView(workout: workout) + } label: { + WorkoutCardView(workout: workout) + } +// .listRowSeparator(.hidden) + .listRowBackground( + RoundedRectangle(cornerRadius: 12) + .fill(Color.secondary.opacity(0.2)) + .padding( + EdgeInsets( + top: 4, + leading: 8, + bottom: 4, + trailing: 8 + ) + ) + ) + } + } + .listStyle(.carousel) +// .navigationTitle("Workouts") + } +} + +struct WorkoutCardView: View { + let workout: Workout + + var body: some View { + VStack(alignment: .leading) { + // Split icon + if let split = workout.split { + Image(systemName: split.systemImage) + .font(.system(size: 48)) + .foregroundStyle(split.getColor()) + } else { + Image(systemName: "dumbbell.fill") + .font(.system(size: 24)) + .foregroundStyle(.gray) + } + +// VStack(alignment: .leading, spacing: 4) { + // Split name + Text(workout.split?.name ?? "Workout") + .font(.headline) + .foregroundStyle(.white) + + // Workout status + Text(workout.status?.name ?? "Not Started") + .font(.caption) + .foregroundStyle(Color.accentColor) +// } + +// Spacer() + } +// .padding(.vertical, 8) + } +} + +struct WorkoutDetailView: View { + let workout: Workout + + var body: some View { + VStack(alignment: .center, spacing: 8) { + if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty { + List { + ForEach(logs) { log in + NavigationLink { + WorkoutLogDetailView(log: log) + } label: { + WorkoutLogCardView(log: log) + } + .listRowBackground( + RoundedRectangle(cornerRadius: 12) + .fill(Color.secondary.opacity(0.2)) + .padding( + EdgeInsets( + top: 4, + leading: 8, + bottom: 4, + trailing: 8 + ) + ) + ) + } + } + .listStyle(.carousel) + } else { + Text("No exercises in this workout") + .font(.body) + .foregroundStyle(.secondary) + .padding() + + Spacer() + } + } + } +} + +struct WorkoutLogCardView: View { + let log: WorkoutLog + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Exercise name + Text(log.exerciseName) + .font(.headline) + .lineLimit(1) + + // Status + Text(log.status?.name ?? "Not Started") + .font(.caption) + .foregroundStyle(Color.accentColor) + + // Sets, Reps, Weight + HStack(spacing: 12) { + Text("\(log.weight) lbs") + + Spacer() + + Text("\(log.sets) × \(log.reps)") + } + } + } +} + +struct WorkoutLogDetailView: View { + let log: WorkoutLog + + var body: some View { + VStack(spacing: 16) { + Text(log.exerciseName) + .font(.headline) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Sets:") + .foregroundStyle(.secondary) + Spacer() + Text("\(log.sets)") + } + + HStack { + Text("Reps:") + .foregroundStyle(.secondary) + Spacer() + Text("\(log.reps)") + } + + HStack { + Text("Weight:") + .foregroundStyle(.secondary) + Spacer() + Text("\(log.weight)") + } + + HStack { + Text("Status:") + .foregroundStyle(.secondary) + Spacer() + Text(log.status?.name ?? "Not Started") + .foregroundStyle(statusColor(for: log.status)) + } + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(10) + + NavigationLink { + ExerciseProgressControlView(log: log) + } label: { + Text("Start Exercise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.accentColor) + + Spacer() + } + .padding() + } + + private func statusColor(for status: WorkoutStatus?) -> Color { + guard let status = status else { return .secondary } + + switch status { + case .notStarted: + return .secondary + case .inProgress: + return .blue + case .completed: + return .green + case .skipped: + return .red + } + } +} + +//#Preview { +// let container = AppContainer.preview +// let split = Split(name: "Upper Body", color: "blue", systemImage: "figure.strengthtraining.traditional") +// let workout1 = Workout(start: Date(), end: nil, split: split) +// workout1.status = .inProgress +// +// let split2 = Split(name: "Lower Body", color: "red", systemImage: "figure.run") +// let workout2 = Workout(start: Date().addingTimeInterval(-3600), end: nil, split: split2) +// workout2.status = .notStarted +// +// return ActiveWorkoutListView(workouts: [workout1, workout2]) +// .modelContainer(container) +//} diff --git a/Worksouts Watch App/Views/ExerciseProgressControlView.swift b/Worksouts Watch App/Views/ExerciseProgressControlView.swift new file mode 100644 index 0000000..e67d533 --- /dev/null +++ b/Worksouts Watch App/Views/ExerciseProgressControlView.swift @@ -0,0 +1,268 @@ +// +// ExerciseProgressControlView.swift +// Workouts +// +// Created by rzen on 7/20/25 at 7:19 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +enum ExerciseState: Identifiable { + case set(number: Int) + case rest(afterSet: Int) + case done + + var id: String { + switch self { + case .set(let number): + return "set_\(number)" + case .rest(let afterSet): + return "rest_\(afterSet)" + case .done: + return "done" + } + } + + var isRest: Bool { + if case .rest = self { + return true + } + return false + } + + var isSet: Bool { + if case .set = self { + return true + } + return false + } + + var isDone: Bool { + if case .done = self { + return true + } + return false + } +} + +struct ExerciseProgressControlView: View { + let log: WorkoutLog + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var exerciseStates: [ExerciseState] = [] + @State private var currentStateIndex: Int = 0 + @State private var elapsedSeconds: Int = 0 + @State private var timer: Timer? = nil + @State private var previousStateIndex: Int = 0 + + var body: some View { + TabView(selection: $currentStateIndex) { + ForEach(Array(exerciseStates.enumerated()), id: \.element.id) { index, state in + ExerciseStateView( + state: state, + elapsedSeconds: elapsedSeconds, + onComplete: { + moveToNextState() + } + ) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .onChange(of: currentStateIndex) { oldValue, newValue in + if oldValue != newValue { + // Reset timer when user swipes to a new state + elapsedSeconds = 0 + } + } + .onAppear { + setupExerciseStates() + startTimer() + } + .onDisappear { + stopTimer() + } + } + + private func setupExerciseStates() { + var states: [ExerciseState] = [] + + // Create states for each set and rest period + for setNumber in 1...log.sets { + states.append(.set(number: setNumber)) + + // Add rest period after each set except the last one + if setNumber < log.sets { + states.append(.rest(afterSet: setNumber)) + } + } + + // Add done state at the end + states.append(.done) + + exerciseStates = states + } + + private func startTimer() { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + elapsedSeconds += 1 + + // Check if we need to provide haptic feedback during rest periods + if let currentState = exerciseStates[safe: currentStateIndex] { + if currentState.isRest { + provideRestHapticFeedback() + } else if currentState.isDone && elapsedSeconds >= 10 { + // Auto-complete after 10 seconds on the DONE state + completeExercise() + } + } + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func moveToNextState() { + if currentStateIndex < exerciseStates.count - 1 { + withAnimation { + currentStateIndex += 1 + elapsedSeconds = 0 + } + } else { + // We've reached the end (DONE state) + completeExercise() + } + } + + private func provideRestHapticFeedback() { + // Provide haptic feedback based on elapsed time + if elapsedSeconds % 60 == 0 && elapsedSeconds > 0 { + // Triple tap every 60 seconds + HapticFeedback.tripleTap() + } else if elapsedSeconds % 30 == 0 && elapsedSeconds > 0 { + // Double tap every 30 seconds + HapticFeedback.doubleTap() + } else if elapsedSeconds % 10 == 0 && elapsedSeconds > 0 { + // Single tap every 10 seconds + HapticFeedback.success() + } + } + + private func completeExercise() { + // Update the workout log status to completed + log.status = .completed + + // Provide "tada" haptic feedback + HapticFeedback.tripleTap() + + // Dismiss this view to return to WorkoutDetailView + dismiss() + } +} + +struct ExerciseStateView: View { + let state: ExerciseState + let elapsedSeconds: Int + let onComplete: () -> Void + + var body: some View { + VStack(spacing: 20) { + // Title based on state + Text(stateTitle) + .font(.title3) + .fontWeight(.bold) + + // Timer display + Text(timeFormatted) + .font(.system(size: 48, weight: .semibold, design: .monospaced)) + .foregroundStyle(state.isRest ? .orange : .accentColor) + + // Only show Done button and countdown for the final state + if state.isDone { + // Countdown message + if elapsedSeconds < 10 { + Text("Completing automatically in \(10 - elapsedSeconds) seconds") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } else { + Text("Auto-completing...") + .font(.caption) + .foregroundStyle(.secondary) + } + + // Done button + Button(action: onComplete) { + Text("Done") + .font(.headline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.green) + .padding(.horizontal) + } + } + .padding() + } + + private var stateTitle: String { + switch state { + case .set(let number): + return "Set \(number) in progress" + case .rest: + return "Resting" + case .done: + return "Exercise Complete" + } + } + + private var buttonTitle: String { + switch state { + case .set: + return "Complete Set" + case .rest: + return "Start Next Set" + case .done: + return "DONE" + } + } + + private var buttonColor: Color { + switch state { + case .set: + return .accentColor + case .rest: + return .orange + case .done: + return .green + } + } + + private var timeFormatted: String { + let minutes = elapsedSeconds / 60 + let seconds = elapsedSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } +} + +// Extension to safely access array elements +extension Array { + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + +//#Preview { +// let container = AppContainer.preview +// let workout = Workout(start: Date(), end: nil, split: nil) +// let log = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135) +// +// ExerciseProgressControlView(log: log) +// .modelContainer(container) +//} diff --git a/Worksouts Watch App/Views/ExerciseProgressView.swift b/Worksouts Watch App/Views/ExerciseProgressView.swift new file mode 100644 index 0000000..42fb07f --- /dev/null +++ b/Worksouts Watch App/Views/ExerciseProgressView.swift @@ -0,0 +1,317 @@ +import SwiftUI +import SwiftData +import WatchKit + +// Enum to track the current phase of the exercise +enum ExercisePhase { + case notStarted + case exercising(setNumber: Int) + case resting(setNumber: Int, elapsedSeconds: Int) + case completed +} + +struct ExerciseProgressView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + let log: WorkoutLog + + @State private var phase: ExercisePhase = .notStarted + @State private var hapticSeconds: Int = 0 + @State private var restSeconds: Int = 0 + @State private var hapticTimer: Timer? = nil + @State private var restTimer: Timer? = nil + + var body: some View { + ScrollView { + VStack(spacing: 15) { + exerciseHeader + + switch phase { + case .notStarted: + startPhaseView + case .exercising(let setNumber): + exercisingPhaseView(setNumber: setNumber) + case .resting(let setNumber, let elapsedSeconds): + restingPhaseView(setNumber: setNumber, elapsedSeconds: elapsedSeconds) + case .completed: + completedPhaseView + } + } + .padding() + } + .navigationTitle(log.exerciseName) + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + stopTimers() + } + .gesture( + DragGesture(minimumDistance: 50) + .onEnded { gesture in + if gesture.translation.width < 0 { + // Swipe left - progress to next phase + handleSwipeLeft() + } else if gesture.translation.height < 0 && gesture.translation.height < -50 { + // Swipe up - cancel current set + handleSwipeUp() + } + } + ) + } + + // MARK: - View Components + + private var exerciseHeader: some View { + VStack(alignment: .leading, spacing: 5) { + Text(log.exerciseName) + .font(.headline) + .foregroundColor(.primary) + + Text("\(log.sets) sets × \(log.reps) reps") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("\(log.weight) lbs") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 5) + } + + private var startPhaseView: some View { + VStack(spacing: 20) { + Text("Ready to start?") + .font(.headline) + + Button(action: startFirstSet) { + Text("Start First Set") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + } + + private func exercisingPhaseView(setNumber: Int) -> some View { + VStack(spacing: 20) { + Text("Set \(setNumber) of \(log.sets)") + .font(.headline) + + Text("Exercising...") + .foregroundColor(.secondary) + + HStack(spacing: 20) { + Button(action: completeSet) { + Text("Complete") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.green) + + Button(action: cancelSet) { + Text("Cancel") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.red) + } + + Text("Or swipe left to complete") + .font(.caption) + .foregroundColor(.secondary) + + Text("Swipe up to cancel") + .font(.caption) + .foregroundColor(.secondary) + } + } + + private func restingPhaseView(setNumber: Int, elapsedSeconds: Int) -> some View { + VStack(spacing: 20) { + Text("Rest after Set \(setNumber)") + .font(.headline) + + Text("Rest time: \(formatSeconds(elapsedSeconds))") + .foregroundColor(.secondary) + + if setNumber < (log.sets) { + Button(action: { startNextSet(after: setNumber) }) { + Text("Start Set \(setNumber + 1)") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + + Text("Or swipe left to start next set") + .font(.caption) + .foregroundColor(.secondary) + } else { + Button(action: completeExercise) { + Text("Complete Exercise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.green) + + Text("Or swipe left to complete") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var completedPhaseView: some View { + VStack(spacing: 20) { + Text("Exercise Completed!") + .font(.headline) + .foregroundColor(.green) + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 50)) + .foregroundColor(.green) + + Button(action: { dismiss() }) { + Text("Return to Workout") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + } + + // MARK: - Action Handlers + + private func handleSwipeLeft() { + switch phase { + case .notStarted: + startFirstSet() + case .exercising: + completeSet() + case .resting(let setNumber, _): + if setNumber < (log.sets) { + startNextSet(after: setNumber) + } else { + completeExercise() + } + case .completed: + dismiss() + } + } + + private func handleSwipeUp() { + if case .exercising = phase { + cancelSet() + } + } + + private func startFirstSet() { + phase = .exercising(setNumber: 1) + startHapticTimer() + } + + private func startNextSet(after completedSetNumber: Int) { + stopTimers() + let nextSetNumber = completedSetNumber + 1 + phase = .exercising(setNumber: nextSetNumber) + startHapticTimer() + } + + private func completeSet() { + stopTimers() + + if case .exercising(let setNumber) = phase { + // Start rest timer + phase = .resting(setNumber: setNumber, elapsedSeconds: 0) + startRestTimer() + startHapticTimer() + + // Play completion haptic + HapticFeedback.success() + } + } + + private func cancelSet() { + // Just go back to the previous state + stopTimers() + phase = .notStarted + } + + private func completeExercise() { + stopTimers() + + // Update workout log + log.completed = true + log.status = .completed + try? modelContext.save() + + // Show completion screen + phase = .completed + + // Play completion haptic + HapticFeedback.success() + } + + // MARK: - Timer Management + + private func startHapticTimer() { + hapticSeconds = 0 + hapticTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + hapticSeconds += 1 + + // Provide haptic feedback based on time intervals + if hapticSeconds % 60 == 0 { + // Triple tap every 60 seconds + HapticFeedback.tripleTap() + } else if hapticSeconds % 30 == 0 { + // Double tap every 30 seconds + HapticFeedback.doubleTap() + } else if hapticSeconds % 10 == 0 { + // Light tap every 10 seconds + HapticFeedback.click() + } + } + } + + private func startRestTimer() { + restSeconds = 0 + restTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + restSeconds += 1 + + if case .resting(let setNumber, _) = phase { + phase = .resting(setNumber: setNumber, elapsedSeconds: restSeconds) + } + } + } + + private func stopTimers() { + hapticTimer?.invalidate() + hapticTimer = nil + + restTimer?.invalidate() + restTimer = nil + } + + // MARK: - Helper Functions + + private func formatSeconds(_ seconds: Int) -> String { + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + return String(format: "%d:%02d", minutes, remainingSeconds) + } +} + +//#Preview { +// let config = ModelConfiguration(isStoredInMemoryOnly: true) +// let container = try! ModelContainer(for: SchemaV1.models, configurations: config) +// +// // Create sample data +// let exercise = Exercise(name: "Bench Press", sets: 3, reps: 8, weight: 60.0) +// let workout = Workout(name: "Chest Day", date: Date()) +// let log = WorkoutLog(exercise: exercise, workout: workout) +// +// NavigationStack { +// ExerciseProgressView(log: log) +// .modelContainer(container) +// } +//} diff --git a/Worksouts Watch App/Views/WorkoutLogListView.swift b/Worksouts Watch App/Views/WorkoutLogListView.swift new file mode 100644 index 0000000..a6cb1c9 --- /dev/null +++ b/Worksouts Watch App/Views/WorkoutLogListView.swift @@ -0,0 +1,117 @@ +import SwiftUI +import SwiftData + +struct WorkoutLogListView: View { + @Environment(\.modelContext) private var modelContext + let workout: Workout + + @State private var selectedLogIndex: Int = 0 + + var body: some View { + VStack { + if let logs = workout.logs?.sorted(by: { $0.order < $1.order }), !logs.isEmpty { + TabView(selection: $selectedLogIndex) { + ForEach(Array(logs.enumerated()), id: \.element.id) { index, log in + WorkoutLogCard(log: log, index: index) + .tag(index) + } + } + .tabViewStyle(.page) +// .indexViewStyle(.page(backgroundDisplayMode: .always)) + } else { + Text("No exercises in this workout") + .foregroundStyle(.secondary) + } + } + .navigationTitle(workout.split?.name ?? "Workout") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct WorkoutLogCard: View { + let log: WorkoutLog + let index: Int + + var body: some View { + VStack(spacing: 12) { + Text(log.exerciseName) + .font(.headline) + .multilineTextAlignment(.center) + + HStack { + VStack { + Text("\(log.sets)") + .font(.title2) + Text("Sets") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + + VStack { + Text("\(log.reps)") + .font(.title2) + Text("Reps") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + + VStack { + Text("\(log.weight)") + .font(.title2) + Text("Weight") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + NavigationLink { + ExerciseProgressView(log: log) + } label: { + Text("Start") + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.blue) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + + Text(log.status?.name ?? "Not Started") + .font(.caption) + .foregroundStyle(statusColor(for: log.status)) + } + .padding() + .background(Color.secondary.opacity(0.2)) + .cornerRadius(12) + .padding(.horizontal) + } + + private func statusColor(for status: WorkoutStatus?) -> Color { + guard let status = status else { return .secondary } + + switch status { + case .notStarted: + return .secondary + case .inProgress: + return .blue + case .completed: + return .green + case .skipped: + return .red + } + } +} + +#Preview { + let container = AppContainer.preview + let workout = Workout(start: Date(), end: Date(), split: nil) + let log1 = WorkoutLog(workout: workout, exerciseName: "Bench Press", date: Date(), sets: 3, reps: 10, weight: 135) + let log2 = WorkoutLog(workout: workout, exerciseName: "Squats", date: Date(), order: 1, sets: 3, reps: 8, weight: 225) + + return WorkoutLogListView(workout: workout) + .modelContainer(container) +} diff --git a/Worksouts Watch App/WorksoutsApp.swift b/Worksouts Watch App/WorksoutsApp.swift index 30678bf..1db47fe 100644 --- a/Worksouts Watch App/WorksoutsApp.swift +++ b/Worksouts Watch App/WorksoutsApp.swift @@ -2,19 +2,22 @@ // WorksoutsApp.swift // Workouts // -// Created by rzen on 7/15/25 at 7:09 PM. +// Created by rzen on 7/15/25 at 7:09 PM. // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // - import SwiftUI +import SwiftData @main struct Worksouts_Watch_AppApp: App { + let container = AppContainer.create() + var body: some Scene { WindowGroup { ContentView() + .modelContainer(container) } } }