Add ExerciseView with navigation and progress tracking
- Copy ExerciseView from Workouts_old, adapt for CoreData - Add WeightProgressionChartView with historical weight data and chart - Refactor CheckboxListItem to use Button for checkbox tap handling - Update WorkoutLogListView to use NavigationLink for exercise details - Checkbox taps now only cycle status, row tap navigates to exercise
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 986 B After Width: | Height: | Size: 1023 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 10 KiB |
@@ -14,32 +14,39 @@ struct CheckboxListItem: View {
|
|||||||
var title: String
|
var title: String
|
||||||
var subtitle: String?
|
var subtitle: String?
|
||||||
var count: Int?
|
var count: Int?
|
||||||
|
var onCheckboxTap: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
|
Button {
|
||||||
|
onCheckboxTap?()
|
||||||
|
} label: {
|
||||||
Image(systemName: status.systemName)
|
Image(systemName: status.systemName)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 30)
|
.frame(width: 30, height: 30)
|
||||||
.foregroundStyle(status.color)
|
.foregroundStyle(status.color)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("\(title)")
|
Text("\(title)")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
HStack(alignment: .bottom) {
|
HStack(alignment: .bottom) {
|
||||||
if let subtitle = subtitle {
|
if let subtitle = subtitle {
|
||||||
Text("\(subtitle)")
|
Text("\(subtitle)")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let count = count {
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if let count = count {
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
165
Workouts/Views/WorkoutLogs/ExerciseView.swift
Normal file
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
127
Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift
Normal file
@@ -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> = 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
|
||||||
|
}
|
||||||
@@ -40,18 +40,24 @@ struct WorkoutLogListView: View {
|
|||||||
} else {
|
} else {
|
||||||
Form {
|
Form {
|
||||||
Section(header: Text("\(workout.label)")) {
|
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
|
let workoutLogStatus = log.status.checkboxStatus
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
ExerciseView(
|
||||||
|
workoutLog: log,
|
||||||
|
allLogs: sortedWorkoutLogs,
|
||||||
|
currentIndex: index
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
CheckboxListItem(
|
CheckboxListItem(
|
||||||
status: workoutLogStatus,
|
status: workoutLogStatus,
|
||||||
title: log.exerciseName,
|
title: log.exerciseName,
|
||||||
subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
|
||||||
)
|
) {
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
cycleStatus(for: log)
|
cycleStatus(for: log)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||||
Button {
|
Button {
|
||||||
completeLog(log)
|
completeLog(log)
|
||||||
|
|||||||