This commit is contained in:
2025-07-17 07:04:38 -04:00
parent 2d0e327334
commit f63bb0ba41
25 changed files with 592 additions and 92 deletions

2
UI.md
View File

@ -4,7 +4,7 @@
Each tab is a root of its own navigation stack. Each tab is a root of its own navigation stack.
- Workout Log - Workouts
- Reports - Reports
- Settings - Settings

View File

@ -6,8 +6,37 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A45FA0892E21B3DC00581607 /* Project object */;
proxyType = 1;
remoteGlobalIDString = A45FA1F02E27171A00581607;
remoteInfo = "Worksouts Watch App";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
A45FA2022E27171B00581607 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
A45FA1FE2E27171B00581607 /* Worksouts Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; }; A45FA0912E21B3DD00581607 /* Workouts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Workouts.app; sourceTree = BUILT_PRODUCTS_DIR; };
A45FA1F12E27171A00581607 /* Worksouts Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Worksouts Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -29,6 +58,11 @@
path = Workouts; path = Workouts;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A45FA1F22E27171A00581607 /* Worksouts Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "Worksouts Watch App";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -39,6 +73,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A45FA1EE2E27171A00581607 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -46,6 +87,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A45FA0932E21B3DD00581607 /* Workouts */, A45FA0932E21B3DD00581607 /* Workouts */,
A45FA1F22E27171A00581607 /* Worksouts Watch App */,
A45FA0922E21B3DD00581607 /* Products */, A45FA0922E21B3DD00581607 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -54,6 +96,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A45FA0912E21B3DD00581607 /* Workouts.app */, A45FA0912E21B3DD00581607 /* Workouts.app */,
A45FA1F12E27171A00581607 /* Worksouts Watch App.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -68,10 +111,12 @@
A45FA08D2E21B3DD00581607 /* Sources */, A45FA08D2E21B3DD00581607 /* Sources */,
A45FA08E2E21B3DD00581607 /* Frameworks */, A45FA08E2E21B3DD00581607 /* Frameworks */,
A45FA08F2E21B3DD00581607 /* Resources */, A45FA08F2E21B3DD00581607 /* Resources */,
A45FA2022E27171B00581607 /* Embed Watch Content */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
A45FA1FD2E27171B00581607 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
A45FA0932E21B3DD00581607 /* Workouts */, A45FA0932E21B3DD00581607 /* Workouts */,
@ -83,6 +128,28 @@
productReference = A45FA0912E21B3DD00581607 /* Workouts.app */; productReference = A45FA0912E21B3DD00581607 /* Workouts.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
A45FA1F02E27171A00581607 /* Worksouts Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Worksouts Watch App" */;
buildPhases = (
A45FA1ED2E27171A00581607 /* Sources */,
A45FA1EE2E27171A00581607 /* Frameworks */,
A45FA1EF2E27171A00581607 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
A45FA1F22E27171A00581607 /* Worksouts Watch App */,
);
name = "Worksouts Watch App";
packageProductDependencies = (
);
productName = "Worksouts Watch App";
productReference = A45FA1F12E27171A00581607 /* Worksouts Watch App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@ -96,6 +163,9 @@
A45FA0902E21B3DD00581607 = { A45FA0902E21B3DD00581607 = {
CreatedOnToolsVersion = 16.2; CreatedOnToolsVersion = 16.2;
}; };
A45FA1F02E27171A00581607 = {
CreatedOnToolsVersion = 16.2;
};
}; };
}; };
buildConfigurationList = A45FA08C2E21B3DC00581607 /* Build configuration list for PBXProject "Workouts" */; buildConfigurationList = A45FA08C2E21B3DC00581607 /* Build configuration list for PBXProject "Workouts" */;
@ -113,6 +183,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
A45FA0902E21B3DD00581607 /* Workouts */, A45FA0902E21B3DD00581607 /* Workouts */,
A45FA1F02E27171A00581607 /* Worksouts Watch App */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -125,6 +196,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A45FA1EF2E27171A00581607 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -135,8 +213,23 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
A45FA1ED2E27171A00581607 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A45FA1FD2E27171B00581607 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A45FA1F02E27171A00581607 /* Worksouts Watch App */;
targetProxy = A45FA1FC2E27171B00581607 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
A45FA0A32E21B3DE00581607 /* Debug */ = { A45FA0A32E21B3DE00581607 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@ -319,6 +412,68 @@
}; };
name = Release; name = Release;
}; };
A45FA2002E27171B00581607 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Worksouts Watch App/Worksouts Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Worksouts Watch App/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Worksouts;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.2;
};
name = Debug;
};
A45FA2012E27171B00581607 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Worksouts Watch App/Worksouts Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Worksouts Watch App/Preview Content\"";
DEVELOPMENT_TEAM = C32Z8JNLG6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Worksouts;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.rzen.indie.Workouts;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.rzen.indie.Workouts.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.2;
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -340,6 +495,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
A45FA1FF2E27171B00581607 /* Build configuration list for PBXNativeTarget "Worksouts Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A45FA2002E27171B00581607 /* Debug */,
A45FA2012E27171B00581607 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = A45FA0892E21B3DC00581607 /* Project object */; rootObject = A45FA0892E21B3DC00581607 /* Project object */;

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "EEDBFE9D-0066-4E41-BFBC-8B95DBCF47E3"
type = "1"
version = "2.0">
</Bucket>

View File

@ -9,6 +9,11 @@
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>0</integer>
</dict> </dict>
<key>Worksouts Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict> </dict>
</dict> </dict>
</plist> </plist>

View File

@ -5,11 +5,7 @@ import SwiftUI
@Model @Model
final class Exercise { final class Exercise {
var name: String = "" var name: String = ""
var setup: String = ""
var descr: String = "" var descr: String = ""
var sets: Int = 0
var reps: Int = 0
var weight: Int = 0
@Relationship(deleteRule: .nullify, inverse: \ExerciseType.exercises) @Relationship(deleteRule: .nullify, inverse: \ExerciseType.exercises)
var type: ExerciseType? var type: ExerciseType?
@ -23,13 +19,9 @@ final class Exercise {
@Relationship(deleteRule: .nullify, inverse: \WorkoutLog.exercise) @Relationship(deleteRule: .nullify, inverse: \WorkoutLog.exercise)
var logs: [WorkoutLog]? = [] var logs: [WorkoutLog]? = []
init(name: String, setup: String, descr: String, sets: Int, reps: Int, weight: Int) { init(name: String, descr: String) {
self.name = name self.name = name
self.setup = setup
self.descr = descr self.descr = descr
self.sets = sets
self.reps = reps
self.weight = weight
} }
static let unnamed = "Unnamed Exercise" static let unnamed = "Unnamed Exercise"
@ -37,7 +29,7 @@ final class Exercise {
extension Exercise: EditableEntity { extension Exercise: EditableEntity {
static func createNew() -> Exercise { static func createNew() -> Exercise {
return Exercise(name: "", setup: "", descr: "", sets: 3, reps: 10, weight: 30) return Exercise(name: "", descr: "")
} }
static var navigationTitle: String { static var navigationTitle: String {
@ -76,20 +68,5 @@ fileprivate struct ExerciseFormView: View {
TextEditor(text: $model.descr) TextEditor(text: $model.descr)
.frame(minHeight: 100) .frame(minHeight: 100)
} }
Section(header: Text("Setup")) {
TextEditor(text: $model.setup)
.frame(minHeight: 100)
}
Section(header: Text("Weight")) {
HStack {
Text("\(model.weight)")
.bold()
Text("lbs")
Spacer()
Stepper("", value: $model.weight, in: 0...1000)
}
}
} }
} }

View File

@ -6,6 +6,27 @@ import SwiftUI
final class Split { final class Split {
var name: String = "" var name: String = ""
var intro: String = "" var intro: String = ""
var color: String = "indigo"
var systemImage: String = "dumbbell.fill"
// Returns the SwiftUI Color for the stored color name
func getColor() -> Color {
switch color {
case "red": return .red
case "orange": return .orange
case "yellow": return .yellow
case "green": return .green
case "mint": return .mint
case "teal": return .teal
case "cyan": return .cyan
case "blue": return .blue
case "indigo": return .indigo
case "purple": return .purple
case "pink": return .pink
case "brown": return .brown
default: return .indigo
}
}
@Relationship(deleteRule: .cascade, inverse: \SplitExerciseAssignment.split) @Relationship(deleteRule: .cascade, inverse: \SplitExerciseAssignment.split)
var exercises: [SplitExerciseAssignment]? = [] var exercises: [SplitExerciseAssignment]? = []
@ -13,9 +34,11 @@ final class Split {
@Relationship(deleteRule: .nullify, inverse: \Workout.split) @Relationship(deleteRule: .nullify, inverse: \Workout.split)
var workouts: [Workout]? = [] var workouts: [Workout]? = []
init(name: String, intro: String) { init(name: String, intro: String, color: String = "indigo", systemImage: String = "dumbbell.fill") {
self.name = name self.name = name
self.intro = intro self.intro = intro
self.color = color
self.systemImage = systemImage
} }
static let unnamed = "Unnamed Split" static let unnamed = "Unnamed Split"
@ -53,6 +76,12 @@ fileprivate struct SplitFormView: View {
@State private var itemToEdit: SplitExerciseAssignment? = nil @State private var itemToEdit: SplitExerciseAssignment? = nil
@State private var itemToDelete: SplitExerciseAssignment? = nil @State private var itemToDelete: SplitExerciseAssignment? = nil
// Available colors for splits
private let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
// Available system images for splits
private let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]
var body: some View { var body: some View {
Section(header: Text("Name")) { Section(header: Text("Name")) {
TextField("Name", text: $model.name) TextField("Name", text: $model.name)
@ -64,6 +93,32 @@ fileprivate struct SplitFormView: View {
.frame(minHeight: 100) .frame(minHeight: 100)
} }
Section(header: Text("Appearance")) {
Picker("Color", selection: $model.color) {
ForEach(availableColors, id: \.self) { colorName in
let tempSplit = Split(name: "", intro: "", color: colorName)
HStack {
Circle()
.fill(tempSplit.getColor())
.frame(width: 20, height: 20)
Text(colorName.capitalized)
}
.tag(colorName)
}
}
Picker("Icon", selection: $model.systemImage) {
ForEach(availableIcons, id: \.self) { iconName in
HStack {
Image(systemName: iconName)
.frame(width: 24, height: 24)
Text(iconName.replacingOccurrences(of: ".fill", with: "").replacingOccurrences(of: "figure.", with: "").capitalized)
}
.tag(iconName)
}
}
}
Section(header: Text("Exercises")) { Section(header: Text("Exercises")) {
NavigationLink { NavigationLink {
NavigationStack { NavigationStack {
@ -109,9 +164,9 @@ fileprivate struct SplitFormView: View {
ExercisePickerView { exercise in ExercisePickerView { exercise in
itemToEdit = SplitExerciseAssignment( itemToEdit = SplitExerciseAssignment(
order: 0, order: 0,
sets: exercise.sets, sets: 3,
reps: exercise.reps, reps: 10,
weight: exercise.weight, weight: 40,
split: model, split: model,
exercise: exercise exercise: exercise
) )

View File

@ -7,6 +7,9 @@ final class WorkoutLog {
var sets: Int = 0 var sets: Int = 0
var reps: Int = 0 var reps: Int = 0
var weight: Int = 0 var weight: Int = 0
var status: WorkoutStatus? = WorkoutStatus.notStarted
var order: Int = 0
var completed: Bool = false var completed: Bool = false
@Relationship(deleteRule: .nullify) @Relationship(deleteRule: .nullify)
@ -15,13 +18,15 @@ final class WorkoutLog {
@Relationship(deleteRule: .nullify) @Relationship(deleteRule: .nullify)
var exercise: Exercise? var exercise: Exercise?
init(workout: Workout, exercise: Exercise, date: Date, sets: Int, reps: Int, weight: Int, completed: Bool) { init(workout: Workout, exercise: Exercise, date: Date, order: Int = 0, sets: Int, reps: Int, weight: Int, status: WorkoutStatus = .notStarted, completed: Bool = false) {
self.date = date self.date = date
self.order = order
self.sets = sets self.sets = sets
self.reps = reps self.reps = reps
self.weight = weight self.weight = weight
self.completed = completed self.status = status
self.workout = workout self.workout = workout
self.exercise = exercise self.exercise = exercise
self.completed = completed
} }
} }

View File

@ -0,0 +1,22 @@
//
// WorkoutStatus.swift
// Workouts
//
// Created by rzen on 7/16/25 at 7:03PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
enum WorkoutStatus: Int, Codable {
case notStarted = 1
case inProgress = 2
case completed = 3
var checkboxStatus: CheckboxStatus {
switch (self) {
case .notStarted: .unchecked
case .inProgress: .intermediate
case .completed: .checked
}
}
}

View File

@ -92,8 +92,7 @@ struct DataLoader {
// 4. Load Exercises // 4. Load Exercises
let exerciseData = try loadJSON(forResource: "exercises", type: [ExerciseData].self) let exerciseData = try loadJSON(forResource: "exercises", type: [ExerciseData].self)
for data in exerciseData { for data in exerciseData {
let exercise = Exercise(name: data.name, setup: data.setup, descr: data.descr, let exercise = Exercise(name: data.name, descr: data.descr)
sets: data.sets, reps: data.reps, weight: data.weight)
// Set exercise type // Set exercise type
if let type = exerciseTypes[data.type] { if let type = exerciseTypes[data.type] {

View File

@ -0,0 +1,16 @@
import SwiftData
enum SchemaV2: VersionedSchema {
static var versionIdentifier: Schema.Version = .init(1, 0, 1)
static var models: [any PersistentModel.Type] = [
Exercise.self,
ExerciseType.self,
Muscle.self,
MuscleGroup.self,
Split.self,
SplitExerciseAssignment.self,
Workout.self,
WorkoutLog.self
]
}

View File

@ -0,0 +1,16 @@
import SwiftData
enum SchemaV3: VersionedSchema {
static var versionIdentifier: Schema.Version = .init(1, 0, 2)
static var models: [any PersistentModel.Type] = [
Exercise.self,
ExerciseType.self,
Muscle.self,
MuscleGroup.self,
Split.self,
SplitExerciseAssignment.self,
Workout.self,
WorkoutLog.self
]
}

View File

@ -2,6 +2,8 @@ import SwiftData
enum SchemaVersion: Int { enum SchemaVersion: Int {
case v1 case v1
case v2
case v3
static var current: SchemaVersion { .v1 } static var current: SchemaVersion { .v3 }
} }

View File

@ -8,9 +8,10 @@ final class WorkoutsContainer {
) )
static func create() -> ModelContainer { static func create() -> ModelContainer {
let schema = Schema(versionedSchema: SchemaV1.self) // Using the current models directly without migration plan to avoid reference errors
let schema = Schema(SchemaV2.models)
let configuration = ModelConfiguration(cloudKitDatabase: .automatic) let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
let container = try! ModelContainer(for: schema, migrationPlan: WorkoutsMigrationPlan.self, configurations: [configuration]) let container = try! ModelContainer(for: schema, configurations: configuration)
return container return container
} }
@ -19,7 +20,7 @@ final class WorkoutsContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: true) let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
do { do {
let schema = Schema(SchemaV1.models) let schema = Schema(SchemaV2.models)
let container = try ModelContainer(for: schema, configurations: configuration) let container = try ModelContainer(for: schema, configurations: configuration)
let context = ModelContext(container) let context = ModelContext(container)

View File

@ -2,10 +2,28 @@ import SwiftData
struct WorkoutsMigrationPlan: SchemaMigrationPlan { struct WorkoutsMigrationPlan: SchemaMigrationPlan {
static var schemas: [VersionedSchema.Type] = [ static var schemas: [VersionedSchema.Type] = [
SchemaV1.self SchemaV1.self,
SchemaV2.self
] ]
static var stages: [MigrationStage] = [ static var stages: [MigrationStage] = [
// Add migration stages here in the future // Migration from V1 to V2: Add status field to WorkoutLog
MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Get all WorkoutLog instances
let workoutLogs = try? context.fetch(FetchDescriptor<WorkoutLog>())
// Update each WorkoutLog with appropriate status based on completed flag
workoutLogs?.forEach { workoutLog in
// If completed is true, set status to .completed, otherwise set to .notStarted
workoutLog.status = workoutLog.completed ? WorkoutStatus.completed : WorkoutStatus.notStarted
}
},
didMigrate: { _ in
// No additional actions needed after migration
}
)
] ]
} }

View File

@ -0,0 +1,68 @@
//
// ListItem.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:42AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
enum CheckboxStatus {
case checked
case unchecked
case intermediate
var color: Color {
switch (self) {
case .checked: .green
case .unchecked: .gray
case .intermediate: .yellow
}
}
var systemName: String {
switch (self) {
case .checked: "checkmark.circle.fill"
case .unchecked: "circle"
case .intermediate: "ellipsis.circle"
}
}
}
struct CheckboxListItem: View {
var status: CheckboxStatus
var title: String
var subtitle: String?
var count: Int?
var body: some View {
HStack (alignment: .top) {
Image(systemName: status.systemName)
.resizable()
.scaledToFit()
.frame(width: 30)
.foregroundStyle(status.color)
VStack (alignment: .leading) {
Text("\(title)")
.font(.headline)
HStack (alignment: .bottom) {
if let subtitle = subtitle {
Text("\(subtitle)")
.font(.footnote)
}
}
}
if let count = count {
Spacer()
Text("\(count)")
.font(.caption)
.foregroundColor(.gray)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}

View File

@ -22,45 +22,39 @@ struct SplitPickerView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack { ScrollView {
Form { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
Section (header: Text("This Split")) {
List {
ForEach(splits) { split in ForEach(splits) { split in
Button(action: { Button(action: {
onSplitSelected(split) onSplitSelected(split)
dismiss() dismiss()
}) { }) {
HStack { VStack {
Text(split.name) ZStack {
.font(.headline) RoundedRectangle(cornerRadius: 12)
Spacer() .fill(split.getColor())
Text("\(split.exercises?.count ?? 0)") .aspectRatio(1, contentMode: .fit)
.font(.caption) .shadow(radius: 2)
}
.contentShape(Rectangle()) Image(systemName: split.systemImage)
} .font(.system(size: 30))
.buttonStyle(.plain) .foregroundColor(.white)
}
}
} }
// Section (header: Text("Additional Exercises")) { Text(split.name)
// List { .font(.headline)
// ForEach(exercises) { exercise in .lineLimit(1)
// Button(action: {
// onExerciseSelected(exercise) Text("\(split.exercises?.count ?? 0) exercises")
// dismiss() .font(.caption)
// }) { .foregroundColor(.secondary)
// Text(exercise.name)
// }
// .contentShape(Rectangle())
// .buttonStyle(.plain)
// }
// }
// }
} }
} }
.buttonStyle(PlainButtonStyle())
}
}
.padding()
}
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { Button("Cancel") {

View File

@ -8,6 +8,7 @@
// //
import SwiftUI import SwiftUI
import SwiftData
struct WorkoutLogView: View { struct WorkoutLogView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@ -21,7 +22,7 @@ struct WorkoutLogView: View {
var sortedWorkoutLogs: [WorkoutLog] { var sortedWorkoutLogs: [WorkoutLog] {
if let logs = workout.logs { if let logs = workout.logs {
logs.sorted(by: { logs.sorted(by: {
$0.completed == $1.completed ? $0.exercise!.name < $1.exercise!.name : !$0.completed $0.order == $1.order ? $0.exercise!.name < $1.exercise!.name : $0.order < $1.order
}) })
} else { } else {
[] []
@ -33,33 +34,54 @@ struct WorkoutLogView: View {
Section (header: Text("\(workout.label)")) { Section (header: Text("\(workout.label)")) {
List { List {
ForEach (sortedWorkoutLogs) { log in ForEach (sortedWorkoutLogs) { log in
let badges = log.completed ? [Badge(text: "Completed", color: .green)] : [] // Handle optional status, defaulting to a status based on completed flag if nil
ListItem( let _ = print("DEBUG: workoutLog.status=\(log.status)")
title: log.exercise?.name ?? "Untitled Exercise",
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs", let workoutLogStatus = log.status?.checkboxStatus ?? (log.completed ? CheckboxStatus.checked : CheckboxStatus.unchecked)
badges: badges
CheckboxListItem(
status: workoutLogStatus,
title: log.exercise?.name ?? Exercise.unnamed,
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs"
) )
.swipeActions(edge: .leading, allowsFullSwipe: false) { .swipeActions(edge: .leading, allowsFullSwipe: false) {
if (log.completed) { let status = log.status ?? WorkoutStatus.notStarted
if [.inProgress,.completed].contains(status) {
Button { Button {
withAnimation { withAnimation {
log.completed = false log.status = .notStarted
try? modelContext.save() try? modelContext.save()
} }
} label: { } label: {
Label("Complete", systemImage: "circle.fill") Label("Not Started", systemImage: WorkoutStatus.notStarted.checkboxStatus.systemName)
} }
.tint(.green) .tint(WorkoutStatus.notStarted.checkboxStatus.color)
} else { }
if [.notStarted,.completed].contains(status) {
Button { Button {
withAnimation { withAnimation {
log.completed = true log.status = .inProgress
try? modelContext.save() try? modelContext.save()
} }
} label: { } label: {
Label("Reset", systemImage: "checkmark.circle.fill") Label("In Progress", systemImage: WorkoutStatus.inProgress.checkboxStatus.systemName)
} }
.tint(.green) .tint(WorkoutStatus.inProgress.checkboxStatus.color)
}
if [.notStarted,.inProgress].contains(status) {
Button {
withAnimation {
log.status = .completed
try? modelContext.save()
}
} label: {
Label("Complete", systemImage: WorkoutStatus.completed.checkboxStatus.systemName)
}
.tint(WorkoutStatus.completed.checkboxStatus.color)
} }
} }
.swipeActions(edge: .trailing, allowsFullSwipe: false) { .swipeActions(edge: .trailing, allowsFullSwipe: false) {
@ -89,13 +111,14 @@ struct WorkoutLogView: View {
} }
.sheet(isPresented: $showingAddSheet) { .sheet(isPresented: $showingAddSheet) {
ExercisePickerView { exercise in ExercisePickerView { exercise in
let setsRepsWeight = getSetsRepsWeight(exercise, in: modelContext)
let workoutLog = WorkoutLog( let workoutLog = WorkoutLog(
workout: workout, workout: workout,
exercise: exercise, exercise: exercise,
date: Date(), date: Date(),
sets: exercise.sets, sets: setsRepsWeight.sets,
reps: exercise.reps, reps: setsRepsWeight.reps,
weight: exercise.weight, weight: setsRepsWeight.weight,
completed: false completed: false
) )
workout.logs?.append(workoutLog) workout.logs?.append(workoutLog)
@ -130,4 +153,34 @@ struct WorkoutLogView: View {
} }
} }
func getSetsRepsWeight(_ exercise: Exercise, in modelContext: ModelContext) -> SetsRepsWeight {
// Use a single expression predicate that works with SwiftData
let exerciseID = exercise.persistentModelID
print("Searching for exercise ID: \(exerciseID)")
var descriptor = FetchDescriptor<WorkoutLog>(
predicate: #Predicate<WorkoutLog> { log in
log.exercise?.persistentModelID == exerciseID
},
sortBy: [SortDescriptor(\WorkoutLog.date, order: .reverse)]
)
descriptor.fetchLimit = 1
let results = try? modelContext.fetch(descriptor)
if let log = results?.first {
return SetsRepsWeight(sets: log.sets, reps: log.reps, weight: log.weight)
} else {
return SetsRepsWeight(sets: 3, reps: 10, weight: 40)
}
}
}
struct SetsRepsWeight {
let sets: Int
let reps: Int
let weight: Int
} }

View File

@ -100,10 +100,10 @@ struct WorkoutsView: View {
workout: workout, workout: workout,
exercise: exercise, exercise: exercise,
date: Date(), date: Date(),
order: assignment.order,
sets: assignment.sets, sets: assignment.sets,
reps: assignment.reps, reps: assignment.reps,
weight: assignment.weight, weight: assignment.weight
completed: false
) )
modelContext.insert(workoutLog) modelContext.insert(workoutLog)
} else { } else {

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,27 @@
//
// ContentView.swift
// Workouts
//
// Created by rzen on 7/15/25 at 7:09PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.dev.rzen.indie.Workouts</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,20 @@
//
// WorksoutsApp.swift
// Workouts
//
// Created by rzen on 7/15/25 at 7:09PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
@main
struct Worksouts_Watch_AppApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}