Refactor UI: move Splits to Settings, redesign ExerciseView

Schema & Models:
- Add notes, loadType, duration fields to WorkoutLog
- Align Watch schema with iOS (use duration Date instead of separate mins/secs)
- Add duration helper properties to Exercise and WorkoutLog

UI Changes:
- Remove Splits and Settings tabs, single Workout Logs view
- Add gear button in nav bar to access Settings as sheet
- Move Splits section into Settings view with inline list
- Redesign ExerciseView with read-only Plan/Notes tiles and Edit buttons
- Add PlanEditView and NotesEditView with Cancel/Save buttons
- Auto-dismiss ExerciseView when completing last set
- Navigate to ExerciseView when adding new exercise

Data Flow:
- Plan edits sync to both WorkoutLog and corresponding Exercise
- Changes propagate up navigation chain via CoreData
This commit is contained in:
2026-01-19 16:10:37 -05:00
parent c65040e756
commit 8b6250e4d6
14 changed files with 592 additions and 106 deletions

View File

@@ -10,7 +10,10 @@
"Bash(xcrun simctl install:*)", "Bash(xcrun simctl install:*)",
"Bash(xcrun simctl launch:*)", "Bash(xcrun simctl launch:*)",
"Bash(xcrun simctl get_app_container:*)", "Bash(xcrun simctl get_app_container:*)",
"Bash(log show:*)" "Bash(log show:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -21,6 +21,29 @@ public class Exercise: NSManagedObject, Identifiable {
get { LoadType(rawValue: Int(loadType)) ?? .weight } get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) } set { loadType = Int32(newValue.rawValue) }
} }
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
} }
// MARK: - Fetch Request // MARK: - Fetch Request

View File

@@ -7,23 +7,59 @@ public class WorkoutLog: NSManagedObject, Identifiable {
@NSManaged public var sets: Int32 @NSManaged public var sets: Int32
@NSManaged public var reps: Int32 @NSManaged public var reps: Int32
@NSManaged public var weight: Int32 @NSManaged public var weight: Int32
@NSManaged private var statusRaw: String?
@NSManaged public var order: Int32 @NSManaged public var order: Int32
@NSManaged public var exerciseName: String @NSManaged public var exerciseName: String
@NSManaged public var currentStateIndex: Int32 @NSManaged public var currentStateIndex: Int32
@NSManaged public var elapsedSeconds: Int32 @NSManaged public var elapsedSeconds: Int32
@NSManaged public var completed: Bool @NSManaged public var completed: Bool
@NSManaged public var loadType: Int32
@NSManaged public var duration: Date?
@NSManaged public var notes: String?
@NSManaged public var workout: Workout? @NSManaged public var workout: Workout?
public var id: NSManagedObjectID { objectID } public var id: NSManagedObjectID { objectID }
var status: WorkoutStatus? { var status: WorkoutStatus {
get { get {
guard let raw = statusRaw else { return nil } willAccessValue(forKey: "status")
return WorkoutStatus(rawValue: raw) let raw = primitiveValue(forKey: "status") as? String ?? "notStarted"
didAccessValue(forKey: "status")
return WorkoutStatus(rawValue: raw) ?? .notStarted
}
set {
willChangeValue(forKey: "status")
setPrimitiveValue(newValue.rawValue, forKey: "status")
didChangeValue(forKey: "status")
}
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) }
}
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
} }
set { statusRaw = newValue?.rawValue }
} }
} }

View File

@@ -31,8 +31,11 @@
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/> <attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="duration" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="exerciseName" attributeType="String" defaultValueString=""/> <attribute name="exerciseName" attributeType="String" defaultValueString=""/>
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="notes" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>

View File

@@ -15,22 +15,7 @@ struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext @Environment(\.managedObjectContext) private var viewContext
var body: some View { var body: some View {
TabView {
WorkoutLogsView() WorkoutLogsView()
.tabItem {
Label("Workout Logs", systemImage: "list.bullet.clipboard")
}
SplitsView()
.tabItem {
Label("Splits", systemImage: "dumbbell.fill")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}
} }
} }

View File

@@ -21,6 +21,29 @@ public class Exercise: NSManagedObject, Identifiable {
get { LoadType(rawValue: Int(loadType)) ?? .weight } get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) } set { loadType = Int32(newValue.rawValue) }
} }
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
} }
// MARK: - Fetch Request // MARK: - Fetch Request

View File

@@ -12,6 +12,9 @@ public class WorkoutLog: NSManagedObject, Identifiable {
@NSManaged public var currentStateIndex: Int32 @NSManaged public var currentStateIndex: Int32
@NSManaged public var elapsedSeconds: Int32 @NSManaged public var elapsedSeconds: Int32
@NSManaged public var completed: Bool @NSManaged public var completed: Bool
@NSManaged public var loadType: Int32
@NSManaged public var duration: Date?
@NSManaged public var notes: String?
@NSManaged public var workout: Workout? @NSManaged public var workout: Workout?
@@ -30,6 +33,34 @@ public class WorkoutLog: NSManagedObject, Identifiable {
didChangeValue(forKey: "status") didChangeValue(forKey: "status")
} }
} }
var loadTypeEnum: LoadType {
get { LoadType(rawValue: Int(loadType)) ?? .weight }
set { loadType = Int32(newValue.rawValue) }
}
// Duration helpers for minutes/seconds conversion
var durationMinutes: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) / 60
}
set {
let seconds = durationSeconds
duration = Date(timeIntervalSince1970: TimeInterval(newValue * 60 + seconds))
}
}
var durationSeconds: Int {
get {
guard let duration = duration else { return 0 }
return Int(duration.timeIntervalSince1970) % 60
}
set {
let minutes = durationMinutes
duration = Date(timeIntervalSince1970: TimeInterval(minutes * 60 + newValue))
}
}
} }
// MARK: - Fetch Request // MARK: - Fetch Request

View File

@@ -6,17 +6,82 @@
// //
import SwiftUI import SwiftUI
import CoreData
import IndieAbout import IndieAbout
struct SettingsView: View { struct SettingsView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Split.order, ascending: true),
NSSortDescriptor(keyPath: \Split.name, ascending: true)
],
animation: .default
)
private var splits: FetchedResults<Split>
@State private var showingAddSplitSheet = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
// MARK: - Splits Section
Section(header: Text("Splits")) {
if splits.isEmpty {
HStack {
Spacer()
VStack(spacing: 8) {
Image(systemName: "dumbbell.fill")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Splits Yet")
.font(.headline)
.foregroundColor(.secondary)
Text("Create a split to organize your workout routine.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.vertical)
Spacer()
}
} else {
ForEach(splits, id: \.objectID) { split in
NavigationLink {
SplitDetailView(split: split)
} label: {
HStack {
Image(systemName: split.systemImage)
.foregroundColor(Color.color(from: split.color))
.frame(width: 24)
Text(split.name)
Spacer()
Text("\(split.exercisesArray.count)")
.foregroundColor(.secondary)
}
}
}
}
Button {
showingAddSplitSheet = true
} label: {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(.accentColor)
Text("Add Split")
}
}
}
// MARK: - Account Section
Section(header: Text("Account")) { Section(header: Text("Account")) {
Text("Settings coming soon") Text("Settings coming soon")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
// MARK: - About Section
Section { Section {
IndieAbout(configuration: AppInfoConfiguration( IndieAbout(configuration: AppInfoConfiguration(
documents: [ documents: [
@@ -28,10 +93,14 @@ struct SettingsView: View {
} }
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.sheet(isPresented: $showingAddSplitSheet) {
SplitAddEditView(split: nil)
}
} }
} }
} }
#Preview { #Preview {
SettingsView() SettingsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
} }

View File

@@ -17,46 +17,19 @@ struct ExerciseView: View {
@ObservedObject var workoutLog: WorkoutLog @ObservedObject var workoutLog: WorkoutLog
var allLogs: [WorkoutLog]
var currentIndex: Int = 0
@State private var progress: Int = 0 @State private var progress: Int = 0
@State private var navigateTo: WorkoutLog? = nil @State private var showingPlanEdit = false
@State private var showingNotesEdit = false
let notStartedColor = Color.white let notStartedColor = Color.white
let completedColor = Color.green let completedColor = Color.green
var body: some View { var body: some View {
Form { Form {
Section(header: Text("Navigation")) { // MARK: - Progress Section
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")) { Section(header: Text("Progress")) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: Int(workoutLog.sets)), spacing: 2) { LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: Int(workoutLog.sets)), spacing: 4) {
ForEach(1...Int(workoutLog.sets), id: \.self) { index in ForEach(1...max(1, Int(workoutLog.sets)), id: \.self) { index in
ZStack { ZStack {
let completed = index <= progress let completed = index <= progress
let color = completed ? completedColor : notStartedColor let color = completed ? completedColor : notStartedColor
@@ -71,95 +44,181 @@ struct ExerciseView: View {
.aspectRatio(0.618, contentMode: .fit) .aspectRatio(0.618, contentMode: .fit)
.shadow(radius: 2) .shadow(radius: 2)
Text("\(index)") Text("\(index)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary) .foregroundColor(.primary)
.colorInvert() .colorInvert()
} }
.onTapGesture { .onTapGesture {
if progress == index { let totalSets = Int(workoutLog.sets)
let isLastTile = index == totalSets
let wasAlreadyAtThisProgress = progress == index
withAnimation(.easeInOut(duration: 0.2)) {
if wasAlreadyAtThisProgress {
progress = 0 progress = 0
} else { } else {
progress = index progress = index
} }
}
updateLogStatus() updateLogStatus()
// If tapping the last tile to complete, go back to list
if isLastTile && !wasAlreadyAtThisProgress {
dismiss()
}
} }
} }
} }
} }
Section(header: Text("Plan")) { // MARK: - Plan Section (Read-only with Edit button)
Stepper("\(workoutLog.sets) sets", value: Binding( Section {
get: { Int(workoutLog.sets) }, PlanTilesView(workoutLog: workoutLog)
set: { workoutLog.sets = Int32($0) } } header: {
), 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 { HStack {
Text("\(workoutLog.weight) lbs") Text("Plan")
VStack(alignment: .trailing) { Spacer()
Stepper("", value: Binding( Button("Edit") {
get: { Int(workoutLog.weight) }, showingPlanEdit = true
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(.subheadline)
.textCase(.none)
} }
.font(.title)
} }
// MARK: - Notes Section (Read-only with Edit button)
Section {
if let notes = workoutLog.notes, !notes.isEmpty {
Text(notes)
.foregroundColor(.primary)
} else {
Text("No notes")
.foregroundColor(.secondary)
.italic()
}
} header: {
HStack {
Text("Notes")
Spacer()
Button("Edit") {
showingNotesEdit = true
}
.font(.subheadline)
.textCase(.none)
}
}
// MARK: - Progress Tracking Chart
Section(header: Text("Progress Tracking")) { Section(header: Text("Progress Tracking")) {
WeightProgressionChartView(exerciseName: workoutLog.exerciseName) WeightProgressionChartView(exerciseName: workoutLog.exerciseName)
} }
} }
.navigationTitle(workoutLog.exerciseName) .navigationTitle(workoutLog.exerciseName)
.navigationDestination(item: $navigateTo) { nextLog in .sheet(isPresented: $showingPlanEdit) {
ExerciseView( PlanEditView(workoutLog: workoutLog)
workoutLog: nextLog, }
allLogs: allLogs, .sheet(isPresented: $showingNotesEdit) {
currentIndex: allLogs.firstIndex(where: { $0.objectID == nextLog.objectID }) ?? 0 NotesEditView(workoutLog: workoutLog)
)
} }
.onAppear { .onAppear {
progress = Int(workoutLog.currentStateIndex) progress = Int(workoutLog.currentStateIndex)
} }
.onDisappear {
saveChanges()
}
} }
private func updateLogStatus() { private func updateLogStatus() {
workoutLog.currentStateIndex = Int32(progress) workoutLog.currentStateIndex = Int32(progress)
if progress >= Int(workoutLog.sets) { if progress >= Int(workoutLog.sets) {
workoutLog.status = .completed workoutLog.status = .completed
workoutLog.completed = true
} else if progress > 0 { } else if progress > 0 {
workoutLog.status = .inProgress workoutLog.status = .inProgress
workoutLog.completed = false
} else { } else {
workoutLog.status = .notStarted workoutLog.status = .notStarted
workoutLog.completed = false
} }
updateWorkoutStatus()
saveChanges() saveChanges()
} }
private func updateWorkoutStatus() {
guard let workout = workoutLog.workout else { return }
let logs = workout.logsArray
let allCompleted = logs.allSatisfy { $0.status == .completed }
let anyInProgress = logs.contains { $0.status == .inProgress }
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
if allCompleted {
workout.status = .completed
workout.end = Date()
} else if anyInProgress || !allNotStarted {
workout.status = .inProgress
} else {
workout.status = .notStarted
}
}
private func saveChanges() { private func saveChanges() {
try? viewContext.save() try? viewContext.save()
} }
}
private func navigateToPrevious() { // MARK: - Plan Tiles View
guard currentIndex > 0 else { return }
let previousIndex = currentIndex - 1 struct PlanTilesView: View {
navigateTo = allLogs[previousIndex] @ObservedObject var workoutLog: WorkoutLog
var body: some View {
if workoutLog.loadTypeEnum == .duration {
// Duration layout: Sets | Duration
HStack(spacing: 0) {
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
PlanTile(label: "Duration", value: formattedDuration)
}
} else {
// Weight layout: Sets | Reps | Weight
HStack(spacing: 0) {
PlanTile(label: "Sets", value: "\(workoutLog.sets)")
PlanTile(label: "Reps", value: "\(workoutLog.reps)")
PlanTile(label: "Weight", value: "\(workoutLog.weight) lbs")
}
}
} }
private func navigateToNext() { private var formattedDuration: String {
guard currentIndex < allLogs.count - 1 else { return } let mins = workoutLog.durationMinutes
let nextIndex = currentIndex + 1 let secs = workoutLog.durationSeconds
navigateTo = allLogs[nextIndex] if mins > 0 && secs > 0 {
return "\(mins)m \(secs)s"
} else if mins > 0 {
return "\(mins) min"
} else if secs > 0 {
return "\(secs) sec"
} else {
return "0 sec"
}
}
}
struct PlanTile: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color(.systemGray6))
.cornerRadius(8)
} }
} }

View File

@@ -0,0 +1,52 @@
//
// NotesEditView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct NotesEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
@State private var notesText: String = ""
var body: some View {
NavigationStack {
Form {
Section {
TextEditor(text: $notesText)
.frame(minHeight: 200)
}
}
.navigationTitle("Edit Notes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveChanges()
dismiss()
}
}
}
.onAppear {
notesText = workoutLog.notes ?? ""
}
}
}
private func saveChanges() {
workoutLog.notes = notesText
try? viewContext.save()
}
}

View File

@@ -0,0 +1,167 @@
//
// PlanEditView.swift
// Workouts
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct PlanEditView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
@State private var sets: Int = 3
@State private var reps: Int = 12
@State private var weight: Int = 0
@State private var durationMinutes: Int = 0
@State private var durationSeconds: Int = 0
@State private var selectedLoadType: LoadType = .weight
// Find the corresponding exercise in the split for syncing changes
private var correspondingExercise: Exercise? {
workoutLog.workout?.split?.exercisesArray.first { $0.name == workoutLog.exerciseName }
}
var body: some View {
NavigationStack {
Form {
// Sets and Reps side by side
Section {
HStack(spacing: 20) {
VStack {
Text("Sets")
.font(.headline)
.foregroundColor(.secondary)
Picker("Sets", selection: $sets) {
ForEach(1...7, id: \.self) { num in
Text("\(num)").tag(num)
}
}
.pickerStyle(.wheel)
.frame(height: 120)
}
.frame(maxWidth: .infinity)
VStack {
Text("Reps")
.font(.headline)
.foregroundColor(.secondary)
Picker("Reps", selection: $reps) {
ForEach(1...40, id: \.self) { num in
Text("\(num)").tag(num)
}
}
.pickerStyle(.wheel)
.frame(height: 120)
}
.frame(maxWidth: .infinity)
}
}
// Load Type Picker
Section {
Picker("Load Type", selection: $selectedLoadType) {
Text("Weight").tag(LoadType.weight)
Text("Time").tag(LoadType.duration)
}
.pickerStyle(.segmented)
}
// Weight or Time picker based on load type
Section {
if selectedLoadType == .weight {
VStack {
Text("Weight")
.font(.headline)
.foregroundColor(.secondary)
Picker("Weight", selection: $weight) {
ForEach(0...300, id: \.self) { num in
Text("\(num) lbs").tag(num)
}
}
.pickerStyle(.wheel)
.frame(height: 150)
}
} else {
HStack(spacing: 20) {
VStack {
Text("Mins")
.font(.headline)
.foregroundColor(.secondary)
Picker("Minutes", selection: $durationMinutes) {
ForEach(0...60, id: \.self) { num in
Text("\(num)").tag(num)
}
}
.pickerStyle(.wheel)
.frame(height: 120)
}
.frame(maxWidth: .infinity)
VStack {
Text("Secs")
.font(.headline)
.foregroundColor(.secondary)
Picker("Seconds", selection: $durationSeconds) {
ForEach(0...59, id: \.self) { num in
Text("\(num)").tag(num)
}
}
.pickerStyle(.wheel)
.frame(height: 120)
}
.frame(maxWidth: .infinity)
}
}
}
}
.navigationTitle("Edit Plan")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveChanges()
dismiss()
}
}
}
.onAppear {
sets = Int(workoutLog.sets)
reps = Int(workoutLog.reps)
weight = Int(workoutLog.weight)
durationMinutes = workoutLog.durationMinutes
durationSeconds = workoutLog.durationSeconds
selectedLoadType = workoutLog.loadTypeEnum
}
}
}
private func saveChanges() {
workoutLog.sets = Int32(sets)
workoutLog.reps = Int32(reps)
workoutLog.weight = Int32(weight)
workoutLog.durationMinutes = durationMinutes
workoutLog.durationSeconds = durationSeconds
workoutLog.loadTypeEnum = selectedLoadType
// Sync to corresponding exercise
if let exercise = correspondingExercise {
exercise.sets = workoutLog.sets
exercise.reps = workoutLog.reps
exercise.weight = workoutLog.weight
exercise.loadType = workoutLog.loadType
exercise.duration = workoutLog.duration
}
try? viewContext.save()
}
}

View File

@@ -17,6 +17,7 @@ struct WorkoutLogListView: View {
@State private var showingAddSheet = false @State private var showingAddSheet = false
@State private var itemToDelete: WorkoutLog? = nil @State private var itemToDelete: WorkoutLog? = nil
@State private var newlyAddedLog: WorkoutLog? = nil
var sortedWorkoutLogs: [WorkoutLog] { var sortedWorkoutLogs: [WorkoutLog] {
workout.logsArray workout.logsArray
@@ -40,20 +41,16 @@ struct WorkoutLogListView: View {
} else { } else {
Form { Form {
Section(header: Text("\(workout.label)")) { Section(header: Text("\(workout.label)")) {
ForEach(Array(sortedWorkoutLogs.enumerated()), id: \.element.objectID) { index, log in ForEach(sortedWorkoutLogs, id: \.objectID) { log in
let workoutLogStatus = log.status.checkboxStatus let workoutLogStatus = log.status.checkboxStatus
NavigationLink { NavigationLink {
ExerciseView( ExerciseView(workoutLog: log)
workoutLog: log,
allLogs: sortedWorkoutLogs,
currentIndex: index
)
} label: { } label: {
CheckboxListItem( CheckboxListItem(
status: workoutLogStatus, status: workoutLogStatus,
title: log.exerciseName, title: log.exerciseName,
subtitle: "\(log.sets) × \(log.reps) reps × \(log.weight) lbs" subtitle: subtitleForLog(log)
) { ) {
cycleStatus(for: log) cycleStatus(for: log)
} }
@@ -80,6 +77,9 @@ struct WorkoutLogListView: View {
} }
} }
} }
.navigationDestination(item: $newlyAddedLog) { log in
ExerciseView(workoutLog: log)
}
.navigationTitle("\(workout.split?.name ?? Split.unnamed)") .navigationTitle("\(workout.split?.name ?? Split.unnamed)")
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@@ -182,10 +182,31 @@ struct WorkoutLogListView: View {
log.sets = exercise.sets log.sets = exercise.sets
log.reps = exercise.reps log.reps = exercise.reps
log.weight = exercise.weight log.weight = exercise.weight
log.loadType = exercise.loadType
log.duration = exercise.duration
log.status = .notStarted log.status = .notStarted
log.workout = workout log.workout = workout
try? viewContext.save() try? viewContext.save()
// Navigate to the new exercise view
newlyAddedLog = log
}
private func subtitleForLog(_ log: WorkoutLog) -> String {
if log.loadTypeEnum == .duration {
let mins = log.durationMinutes
let secs = log.durationSeconds
if mins > 0 && secs > 0 {
return "\(log.sets) × \(mins)m \(secs)s"
} else if mins > 0 {
return "\(log.sets) × \(mins) min"
} else {
return "\(log.sets) × \(secs) sec"
}
} else {
return "\(log.sets) × \(log.reps) reps × \(log.weight) lbs"
}
} }
} }

View File

@@ -20,6 +20,7 @@ struct WorkoutLogsView: View {
private var workouts: FetchedResults<Workout> private var workouts: FetchedResults<Workout>
@State private var showingSplitPicker = false @State private var showingSplitPicker = false
@State private var showingSettings = false
@State private var itemToDelete: Workout? = nil @State private var itemToDelete: Workout? = nil
var body: some View { var body: some View {
@@ -55,12 +56,22 @@ struct WorkoutLogsView: View {
} }
.navigationTitle("Workout Logs") .navigationTitle("Workout Logs")
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
showingSettings = true
} label: {
Image(systemName: "gearshape.2")
}
}
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Start New") { Button("Start New") {
showingSplitPicker.toggle() showingSplitPicker.toggle()
} }
} }
} }
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(isPresented: $showingSplitPicker) { .sheet(isPresented: $showingSplitPicker) {
SplitPickerSheet() SplitPickerSheet()
} }

View File

@@ -31,8 +31,11 @@
<attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="completed" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="currentStateIndex" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/> <attribute name="date" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="duration" optional="YES" attributeType="Date" defaultDateTimeInterval="0" usesScalarValueType="NO"/>
<attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="elapsedSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="exerciseName" attributeType="String" defaultValueString=""/> <attribute name="exerciseName" attributeType="String" defaultValueString=""/>
<attribute name="loadType" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="notes" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="order" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="reps" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="sets" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>