diff --git a/Artwork/Workouts.pxd b/Artwork/Workouts.pxd index d33376e..a94d752 100644 Binary files a/Artwork/Workouts.pxd and b/Artwork/Workouts.pxd differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-1024.png index eb8d3c5..75412f5 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20.png index d011548..51d957f 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png index 8616828..f2b28d5 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png index 7d5d8d7..34cd2a7 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29.png index 1506fc5..7542268 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png index be81885..74a412f 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png index a128105..9f92efe 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40.png index 8616828..f2b28d5 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png index aa088fc..98616f3 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png index d6e74ad..a82eebd 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png index d6e74ad..a82eebd 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png index 3d2ef2f..3f58ff0 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-76.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-76.png index 2ca8358..e2cde38 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-76.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-76.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png index f514c93..96e4f0d 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png differ diff --git a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png index a9fef6b..630c4f3 100644 Binary files a/Workouts/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png and b/Workouts/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png differ diff --git a/Workouts/Views/Common/CheckboxListItem.swift b/Workouts/Views/Common/CheckboxListItem.swift index c9c08c9..d892bbd 100644 --- a/Workouts/Views/Common/CheckboxListItem.swift +++ b/Workouts/Views/Common/CheckboxListItem.swift @@ -14,32 +14,39 @@ struct CheckboxListItem: View { var title: String var subtitle: String? var count: Int? + var onCheckboxTap: (() -> Void)? = nil var body: some View { HStack(alignment: .top) { - Image(systemName: status.systemName) - .resizable() - .scaledToFit() - .frame(width: 30) - .foregroundStyle(status.color) + Button { + onCheckboxTap?() + } label: { + Image(systemName: status.systemName) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .foregroundStyle(status.color) + } + .buttonStyle(.plain) VStack(alignment: .leading) { Text("\(title)") .font(.headline) + .foregroundColor(.primary) HStack(alignment: .bottom) { if let subtitle = subtitle { Text("\(subtitle)") .font(.footnote) + .foregroundColor(.secondary) } } } + Spacer() if let count = count { - Spacer() Text("\(count)") .font(.caption) .foregroundColor(.gray) } } .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } } diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift new file mode 100644 index 0000000..f939542 --- /dev/null +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -0,0 +1,165 @@ +// +// ExerciseView.swift +// Workouts +// +// Created by rzen on 7/18/25 at 5:44 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import CoreData +import Charts + +struct ExerciseView: View { + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dismiss) private var dismiss + + @ObservedObject var workoutLog: WorkoutLog + + var allLogs: [WorkoutLog] + var currentIndex: Int = 0 + + @State private var progress: Int = 0 + @State private var navigateTo: WorkoutLog? = nil + + let notStartedColor = Color.white + let completedColor = Color.green + + var body: some View { + Form { + Section(header: Text("Navigation")) { + HStack { + Button(action: navigateToPrevious) { + HStack { + Image(systemName: "chevron.left") + Text("Previous") + } + } + .disabled(currentIndex <= 0) + + Spacer() + Text("\(currentIndex + 1) of \(allLogs.count)") + .foregroundColor(.secondary) + Spacer() + + Button(action: navigateToNext) { + HStack { + Text("Next") + Image(systemName: "chevron.right") + } + } + .disabled(currentIndex >= allLogs.count - 1) + } + .padding(.vertical, 8) + } + + Section(header: Text("Progress")) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: Int(workoutLog.sets)), spacing: 2) { + ForEach(1...Int(workoutLog.sets), id: \.self) { index in + ZStack { + let completed = index <= progress + let color = completed ? completedColor : notStartedColor + RoundedRectangle(cornerRadius: 8) + .fill( + LinearGradient( + gradient: Gradient(colors: [color, color.darker(by: 0.2)]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .aspectRatio(0.618, contentMode: .fit) + .shadow(radius: 2) + Text("\(index)") + .foregroundColor(.primary) + .colorInvert() + } + .onTapGesture { + if progress == index { + progress = 0 + } else { + progress = index + } + updateLogStatus() + } + } + } + } + + Section(header: Text("Plan")) { + Stepper("\(workoutLog.sets) sets", value: Binding( + get: { Int(workoutLog.sets) }, + set: { workoutLog.sets = Int32($0) } + ), in: 1...10) + .font(.title) + + Stepper("\(workoutLog.reps) reps", value: Binding( + get: { Int(workoutLog.reps) }, + set: { workoutLog.reps = Int32($0) } + ), in: 1...25) + .font(.title) + + HStack { + Text("\(workoutLog.weight) lbs") + VStack(alignment: .trailing) { + Stepper("", value: Binding( + get: { Int(workoutLog.weight) }, + set: { workoutLog.weight = Int32($0) } + ), in: 1...500) + Stepper("", value: Binding( + get: { Int(workoutLog.weight) }, + set: { workoutLog.weight = Int32($0) } + ), in: 1...500, step: 5) + } + } + .font(.title) + } + + Section(header: Text("Progress Tracking")) { + WeightProgressionChartView(exerciseName: workoutLog.exerciseName) + } + } + .navigationTitle(workoutLog.exerciseName) + .navigationDestination(item: $navigateTo) { nextLog in + ExerciseView( + workoutLog: nextLog, + allLogs: allLogs, + currentIndex: allLogs.firstIndex(where: { $0.objectID == nextLog.objectID }) ?? 0 + ) + } + .onAppear { + progress = Int(workoutLog.currentStateIndex) + } + .onDisappear { + saveChanges() + } + } + + private func updateLogStatus() { + workoutLog.currentStateIndex = Int32(progress) + if progress >= Int(workoutLog.sets) { + workoutLog.status = .completed + } else if progress > 0 { + workoutLog.status = .inProgress + } else { + workoutLog.status = .notStarted + } + saveChanges() + } + + private func saveChanges() { + try? viewContext.save() + } + + private func navigateToPrevious() { + guard currentIndex > 0 else { return } + let previousIndex = currentIndex - 1 + navigateTo = allLogs[previousIndex] + } + + private func navigateToNext() { + guard currentIndex < allLogs.count - 1 else { return } + let nextIndex = currentIndex + 1 + navigateTo = allLogs[nextIndex] + } +} diff --git a/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift b/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift new file mode 100644 index 0000000..fe30cd6 --- /dev/null +++ b/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift @@ -0,0 +1,127 @@ +// +// WeightProgressionChartView.swift +// Workouts +// +// Created on 7/20/25. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import Charts +import CoreData + +struct WeightProgressionChartView: View { + @Environment(\.managedObjectContext) private var viewContext + + 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) { _ 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 + + let request: NSFetchRequest = WorkoutLog.fetchRequest() + request.predicate = NSPredicate(format: "exerciseName == %@ AND completed == YES", exerciseName) + request.sortDescriptors = [NSSortDescriptor(keyPath: \WorkoutLog.date, ascending: true)] + + if let logs = try? viewContext.fetch(request) { + weightData = logs.map { log in + WeightDataPoint(date: log.date, weight: Int(log.weight)) + } + generateMotivationalMessage() + } + + isLoading = false + } + + private func generateMotivationalMessage() { + guard weightData.count >= 2 else { + motivationalMessage = "Complete more workouts to track your progress!" + return + } + + let firstWeight = weightData.first?.weight ?? 0 + let currentWeight = weightData.last?.weight ?? 0 + let weightDifference = currentWeight - firstWeight + + 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 +} diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index dc0ae74..5ef760a 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -40,17 +40,23 @@ struct WorkoutLogListView: View { } else { Form { Section(header: Text("\(workout.label)")) { - ForEach(sortedWorkoutLogs, id: \.objectID) { log in + ForEach(Array(sortedWorkoutLogs.enumerated()), id: \.element.objectID) { index, log in let workoutLogStatus = log.status.checkboxStatus - CheckboxListItem( - status: workoutLogStatus, - title: log.exerciseName, - subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs" - ) - .contentShape(Rectangle()) - .onTapGesture { - cycleStatus(for: log) + NavigationLink { + ExerciseView( + workoutLog: log, + allLogs: sortedWorkoutLogs, + currentIndex: index + ) + } label: { + CheckboxListItem( + status: workoutLogStatus, + title: log.exerciseName, + subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs" + ) { + cycleStatus(for: log) + } } .swipeActions(edge: .leading, allowsFullSwipe: true) { Button {