initial pre-viable version of watch app
This commit is contained in:
@ -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()
|
||||
}
|
||||
|
196
Workouts/Views/Exercises/ExerciseListView.swift
Normal file
196
Workouts/Views/Exercises/ExerciseListView.swift
Normal file
@ -0,0 +1,196 @@
|
||||
//
|
||||
// SplitExercisesListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 8:38 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ExerciseListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Use a @Query to observe the Split and its exercises
|
||||
@Query private var splits: [Split]
|
||||
private var split: Split
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
@State private var createdWorkout: Workout? = nil
|
||||
|
||||
// Initialize with a Split and set up a query to observe it
|
||||
init(split: Split) {
|
||||
self.split = split
|
||||
// Create a predicate to fetch only this specific split
|
||||
let splitId = split.persistentModelID
|
||||
self._splits = Query(filter: #Predicate<Split> { s in
|
||||
s.persistentModelID == splitId
|
||||
})
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Use the first Split from our query if available, otherwise fall back to the original split
|
||||
let currentSplit = splits.first ?? split
|
||||
|
||||
NavigationStack {
|
||||
Form {
|
||||
List {
|
||||
if let assignments = currentSplit.exercises, !assignments.isEmpty {
|
||||
let sortedAssignments = assignments.sorted(by: { $0.order == $1.order ? $0.name < $1.name : $0.order < $1.order })
|
||||
|
||||
ForEach(sortedAssignments) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs \(item.order)"
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove(perform: { indices, destination in
|
||||
var exerciseArray = Array(sortedAssignments)
|
||||
exerciseArray.move(fromOffsets: indices, toOffset: destination)
|
||||
for (index, exercise) in exerciseArray.enumerated() {
|
||||
exercise.order = index
|
||||
}
|
||||
if let modelContext = exerciseArray.first?.modelContext {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("Error saving after reordering: \(error)")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
|
||||
} else {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(currentSplit.name)")
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Start This Split") {
|
||||
let split = currentSplit
|
||||
let workout = Workout(start: Date(), end: Date(), split: split)
|
||||
modelContext.insert(workout)
|
||||
if let exercises = split.exercises {
|
||||
for assignment in exercises {
|
||||
let workoutLog = WorkoutLog(
|
||||
workout: workout,
|
||||
exerciseName: assignment.name,
|
||||
date: Date(),
|
||||
order: assignment.order,
|
||||
sets: assignment.sets,
|
||||
reps: assignment.reps,
|
||||
weight: assignment.weight
|
||||
)
|
||||
modelContext.insert(workoutLog)
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
|
||||
// Set the created workout to trigger navigation
|
||||
createdWorkout = workout
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $createdWorkout, destination: { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
})
|
||||
// .toolbar {
|
||||
// ToolbarItem(placement: .navigationBarTrailing) {
|
||||
// Button(action: { showingAddSheet.toggle() }) {
|
||||
// Image(systemName: "plus")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
.sheet (isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
let splitId = currentSplit.persistentModelID
|
||||
print("exerciseNames: \(exerciseNames)")
|
||||
if exerciseNames.count == 1 {
|
||||
itemToEdit = Exercise(
|
||||
split: split,
|
||||
exerciseName: exerciseNames.first ?? "Exercise.unnamed",
|
||||
order: 0,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
weight: 40
|
||||
)
|
||||
} else {
|
||||
for exerciseName in exerciseNames {
|
||||
var duplicateExercise: [Exercise]? = nil
|
||||
do {
|
||||
duplicateExercise = try modelContext.fetch(FetchDescriptor<Exercise>(predicate: #Predicate{ exercise in
|
||||
exerciseName == exercise.name && splitId == exercise.split?.persistentModelID
|
||||
}))
|
||||
} catch {
|
||||
print("ERROR: failed to fetch \(exerciseName)")
|
||||
}
|
||||
|
||||
if let dup = duplicateExercise, dup.count > 0 {
|
||||
print("Skipping duplicate \(exerciseName) found \(dup.count) duplicate(s)")
|
||||
} else {
|
||||
print("Creating \(exerciseName) for \(split.name)")
|
||||
modelContext.insert(Exercise(
|
||||
split: split,
|
||||
exerciseName: exerciseName,
|
||||
order: 0,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
weight: 40
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
try? modelContext.save()
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(model: item)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: .constant(itemToDelete != nil),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = itemToDelete {
|
||||
withAnimation {
|
||||
modelContext.delete(item)
|
||||
try? modelContext.save()
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -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
|
||||
|
142
Workouts/Views/Exercises/WeightProgressionChartView.swift
Normal file
142
Workouts/Views/Exercises/WeightProgressionChartView.swift
Normal file
@ -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<WorkoutLog>(
|
||||
predicate: #Predicate<WorkoutLog> { 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)
|
||||
}
|
Reference in New Issue
Block a user