This commit is contained in:
2025-07-15 16:33:55 -04:00
parent 39fd45e03f
commit 48bbbbf692
8 changed files with 242 additions and 85 deletions

View File

@ -48,6 +48,9 @@ extension Split: EditableEntity {
fileprivate struct SplitFormView: View { fileprivate struct SplitFormView: View {
@Binding var model: Split @Binding var model: Split
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: SplitExerciseAssignment? = nil
@State private var itemToDelete: SplitExerciseAssignment? = nil @State private var itemToDelete: SplitExerciseAssignment? = nil
var body: some View { var body: some View {
@ -62,37 +65,80 @@ fileprivate struct SplitFormView: View {
} }
Section(header: Text("Exercises")) { Section(header: Text("Exercises")) {
if let assignments = model.exercises, !assignments.isEmpty { NavigationLink {
ForEach(assignments) { item in NavigationStack {
ListItem( Form {
title: item.exercise?.name ?? Exercise.unnamed, List {
subtitle: "\(item.sets) × \(item.reps) @ \(item.weight) lbs" if let assignments = model.exercises, !assignments.isEmpty {
) let sortedAssignments = assignments.sorted(by: { $0.order == $1.order ? $0.exercise?.name ?? Exercise.unnamed < $1.exercise?.name ?? Exercise.unnamed : $0.order < $1.order })
.swipeActions { ForEach(sortedAssignments) { item in
Button(role: .destructive) { ListItem(
itemToDelete = item title: item.exercise?.name ?? Exercise.unnamed,
} label: { subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
Label("Delete", systemImage: "trash") )
.swipeActions {
Button(role: .destructive) {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
} else {
Text("No exercises added yet.")
}
}
}
.navigationTitle("Exercises")
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {
Image(systemName: "plus")
} }
} }
} }
} else { .sheet (isPresented: $showingAddSheet) {
Text("No exercises added yet.") ExercisePickerView { exercise in
} itemToEdit = SplitExerciseAssignment(
Button(action: { order: 0,
// TODO: Implement add exercise functionality sets: exercise.sets,
}) { reps: exercise.reps,
Label("Add Exercise", systemImage: "plus.circle") weight: exercise.weight,
} split: model,
} exercise: exercise
.confirmationDialog("Delete Exercise?", isPresented: .constant(itemToDelete != nil), titleVisibility: .visible) { )
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
model.exercises?.removeAll { $0.id == item.id }
itemToDelete = nil
} }
} }
.sheet(item: $itemToEdit) { item in
SplitExerciseAssignmentAddEditView(model: item)
}
.confirmationDialog(
"Delete Exercise?",
isPresented: .constant(itemToDelete != nil),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let item = itemToDelete {
withAnimation {
model.exercises?.removeAll { $0.id == item.id }
itemToDelete = nil
}
}
}
}
} label: {
ListItem(
text: "Exercises",
count: model.exercises?.count ?? 0
)
} }
} }
} }

View File

@ -10,18 +10,32 @@ struct CloudKitSyncObserver: ViewModifier {
content content
.id(refreshID) // Force view refresh when this changes .id(refreshID) // Force view refresh when this changes
.onReceive(NotificationCenter.default.publisher(for: .cloudKitDataDidChange)) { _ in .onReceive(NotificationCenter.default.publisher(for: .cloudKitDataDidChange)) { _ in
// When we receive a notification that CloudKit data changed:
// 1. Create a new UUID to force view refresh
refreshID = UUID() refreshID = UUID()
// 2. Optionally, you can also manually refresh the model context
// This is sometimes needed for complex relationships
Task { @MainActor in Task { @MainActor in
try? modelContext.fetch(FetchDescriptor<Exercise>()) // do {
// Add other model types as needed // for entity in modelContext.container.schema.entities {
// fetchAll<entity.Type>(of: entity.Type, from: modelContext)
// }
// } catch {
// print("ERROR: failed to fetch data on CloudKit change")
// }
//
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// try? modelContext.fetch(FetchDescriptor<ExerciseType>())
// try? modelContext.fetch(FetchDescriptor<Muscle>())
// try? modelContext.fetch(FetchDescriptor<MuscleGroup>())
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// try? modelContext.fetch(FetchDescriptor<Exercise>())
// TODO: add more entities?
} }
} }
} }
private func fetchAll<T: PersistentModel>(of type: T.Type,from modelContext: ModelContext) async throws -> [T]? {
try modelContext.fetch(FetchDescriptor<T>())
}
} }
// Extension to make it easier to use the modifier // Extension to make it easier to use the modifier

View File

@ -0,0 +1,24 @@
//
// BadgeView.swift
// Workouts
//
// Created by rzen on 7/14/25 at 2:20PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct BadgeView: View {
var badge: Badge
var body: some View {
Text("\(badge.text)")
.bold()
.padding([.leading,.trailing], 5)
.background(badge.color)
.foregroundColor(.white)
.cornerRadius(4)
}
}

View File

@ -10,7 +10,8 @@
import SwiftUI import SwiftUI
struct ListItem: View { struct ListItem: View {
var title: String var title: String?
var text: String?
var subtitle: String? var subtitle: String?
var count: Int? var count: Int?
var badges: [Badge]? = [] var badges: [Badge]? = []
@ -18,17 +19,18 @@ struct ListItem: View {
var body: some View { var body: some View {
HStack { HStack {
VStack (alignment: .leading) { VStack (alignment: .leading) {
Text("\(title)") if let title = title {
.font(.headline) Text("\(title)")
.font(.headline)
} else if let text = text {
Text("\(text)")
} else {
Text("Untitled")
}
HStack (alignment: .bottom) { HStack (alignment: .bottom) {
if let badges = badges { if let badges = badges {
ForEach (badges, id: \.self) { badge in ForEach (badges, id: \.self) { badge in
Text("\(badge.text)") BadgeView(badge: badge)
.bold()
.padding([.leading,.trailing], 5)
.background(badge.color)
.foregroundColor(.white)
.cornerRadius(4)
} }
} }
if let subtitle = subtitle { if let subtitle = subtitle {
@ -44,6 +46,7 @@ struct ListItem: View {
.foregroundColor(.gray) .foregroundColor(.gray)
} }
} }
.frame(height: 40)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }

View File

@ -0,0 +1,59 @@
//
// SplitExerciseAssignment.swift
// Workouts
//
// Created by rzen on 7/15/25 at 7:12AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct SplitExerciseAssignmentAddEditView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State var model: SplitExerciseAssignment
var body: some View {
NavigationStack {
Form {
Section(header: Text("Sets/Reps")) {
Stepper("Sets: \(model.sets)", value: $model.sets, in: 1...10)
Stepper("Reps: \(model.reps)", value: $model.reps, in: 1...50)
}
// Weight section
Section(header: Text("Weight")) {
HStack {
VStack(alignment: .center) {
Text("\(model.weight) lbs")
.font(.headline)
}
Spacer()
VStack(alignment: .trailing) {
Stepper("±1", value: $model.weight, in: 0...1000)
Stepper("±5", value: $model.weight, in: 0...1000, step: 5)
}
.frame(width: 130)
}
}
}
.navigationTitle("\(model.exercise?.name ?? Exercise.unnamed)")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
try? modelContext.save()
dismiss()
}
}
}
}
}
}

View File

@ -14,7 +14,9 @@ struct ExercisePickerView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Exercise.name)]) private var exercises: [Exercise] @Query(sort: [SortDescriptor(\ExerciseType.name)]) private var exerciseTypes: [ExerciseType]
// @Query(sort: [SortDescriptor(\Exercise.name)]) private var exercises: [Exercise]
var onExerciseSelected: (Exercise) -> Void var onExerciseSelected: (Exercise) -> Void
@ -22,18 +24,24 @@ struct ExercisePickerView: View {
NavigationStack { NavigationStack {
VStack { VStack {
Form { Form {
Section (header: Text("This Split")) { ForEach (exerciseTypes) { exerciseType in
List { if let exercises = exerciseType.exercises, !exercises.isEmpty {
ForEach(exercises) { exercise in let sortedExercises = exercises.sorted(by: { $0.name < $1.name })
Button(action: { Section (header: Text("\(exerciseType.name)")) {
onExerciseSelected(exercise) List {
dismiss() ForEach(sortedExercises) { exercise in
}) { Button(action: {
ListItem(title: exercise.name) onExerciseSelected(exercise)
dismiss()
}) {
ListItem(text: exercise.name)
}
.buttonStyle(.plain)
}
} }
.buttonStyle(.plain)
} }
} }
} }
} }
} }

View File

@ -15,14 +15,14 @@ struct WorkoutEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State var workout: Workout @State var workout: Workout
@State var endDateHidden: Bool = false @State var workoutEndDate: Date = Date()
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section (header: Text("Split")) { // Section (header: Text("Split")) {
Text("\(workout.split?.name ?? Split.unnamed)") // Text("\(workout.split?.name ?? Split.unnamed)")
} // }
Section (header: Text("Start/End")) { Section (header: Text("Start/End")) {
DatePicker("Started", selection: $workout.start) DatePicker("Started", selection: $workout.start)
@ -31,38 +31,35 @@ struct WorkoutEditView: View {
set: { newValue in set: { newValue in
withAnimation { withAnimation {
if newValue { if newValue {
workout.end = Date() workoutEndDate = Date()
endDateHidden = false workout.end = workoutEndDate
} else { } else {
workout.end = nil workout.end = nil
endDateHidden = true
} }
} }
} }
)) ))
if !endDateHidden { if workout.end != nil {
DatePicker("Ended", selection: $workout.start) DatePicker("Ended", selection: $workoutEndDate)
} }
} }
.onAppear {
endDateHidden = workout.end == nil
}
Section (header: Text("Workout Log")) { // Section (header: Text("Workout Log")) {
if let workoutLogs = workout.logs { // if let workoutLogs = workout.logs {
List { // List {
ForEach (workoutLogs) { log in // ForEach (workoutLogs) { log in
ListItem( // ListItem(
title: log.exercise?.name ?? Exercise.unnamed, // title: log.exercise?.name ?? Exercise.unnamed,
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs" // subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs"
) // )
} // }
} // }
} else { // } else {
Text("No workout logs yet") // Text("No workout logs yet")
} // }
} // }
} }
.navigationTitle("\(workout.split?.name ?? Split.unnamed) Split")
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { Button("Cancel") {

View File

@ -20,7 +20,9 @@ struct WorkoutLogView: View {
var sortedWorkoutLogs: [WorkoutLog] { var sortedWorkoutLogs: [WorkoutLog] {
if let logs = workout.logs { if let logs = workout.logs {
logs.sorted(by: { $0.exercise!.name < $1.exercise!.name }) logs.sorted(by: {
$0.completed == $1.completed ? $0.exercise!.name < $1.exercise!.name : !$0.completed
})
} else { } else {
[] []
} }
@ -43,16 +45,20 @@ struct WorkoutLogView: View {
.swipeActions(edge: .leading, allowsFullSwipe: false) { .swipeActions(edge: .leading, allowsFullSwipe: false) {
if (log.completed) { if (log.completed) {
Button { Button {
log.completed = false withAnimation {
try? modelContext.save() log.completed = false
try? modelContext.save()
}
} label: { } label: {
Label("Complete", systemImage: "circle.fill") Label("Complete", systemImage: "circle.fill")
} }
.tint(.green) .tint(.green)
} else { } else {
Button { Button {
log.completed = true withAnimation {
try? modelContext.save() log.completed = true
try? modelContext.save()
}
} label: { } label: {
Label("Reset", systemImage: "checkmark.circle.fill") Label("Reset", systemImage: "checkmark.circle.fill")
} }
@ -76,7 +82,7 @@ struct WorkoutLogView: View {
} }
} }
} }
.navigationTitle("\(workout.split?.name ?? Split.unnamed)") .navigationTitle("\(workout.split?.name ?? Split.unnamed) Split")
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) { Button(action: { showingAddSheet.toggle() }) {