From d4514805e94e78ecda3cd617c23a443313ee3644 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 13 Jul 2025 17:51:52 -0400 Subject: [PATCH] wip --- MODEL.md | 20 +-- Workouts/ContentView.swift | 55 ++---- Workouts/Item.swift | 21 --- Workouts/Models/Exercise.swift | 33 ++++ Workouts/Models/ExerciseType.swift | 16 ++ Workouts/Models/Muscle.swift | 21 +++ Workouts/Models/MuscleGroup.swift | 16 ++ Workouts/Models/Split.swift | 19 ++ Workouts/Models/SplitExerciseAssignment.swift | 25 +++ Workouts/Models/Workout.swift | 20 +++ Workouts/Models/WorkoutLog.swift | 27 +++ Workouts/Protocols/ListableItem.swift | 12 ++ Workouts/Schema/InitialData.swift | 165 ++++++++++++++++++ Workouts/Schema/SchemaV1.swift | 16 ++ Workouts/Schema/SchemaVersion.swift | 13 ++ Workouts/Schema/WorkoutsContainer.swift | 40 +++++ Workouts/Schema/WorkoutsMigrationPlan.swift | 11 ++ Workouts/Utils/AppLogger.swift | 37 ++++ Workouts/Utils/Badge.swift | 15 ++ Workouts/Utils/Date+Extensions.swift | 14 ++ Workouts/Utils/ListItem.swift | 50 ++++++ .../ExerciseTypeAddEditView.swift | 48 +++++ .../ExerciseTypes/ExerciseTypeListView.swift | 75 ++++++++ .../Exercises/ExerciseAddEditView.swift | 13 ++ .../Exercises/ExercisesListView.swift | 82 +++++++++ .../MuscleGroups/MuscleGroupAddEditView.swift | 48 +++++ .../MuscleGroups/MuscleGroupsListView.swift | 70 ++++++++ .../Settings/Muscles/MuscleAddEditView.swift | 59 +++++++ .../Settings/Muscles/MusclesListView.swift | 79 +++++++++ Workouts/Views/Settings/SettingsView.swift | 77 ++++++++ .../Settings/Splits/SplitAddEditView.swift | 78 +++++++++ .../Settings/Splits/SplitsListView.swift | 78 +++++++++ Workouts/WorkoutsApp.swift | 22 ++- 33 files changed, 1295 insertions(+), 80 deletions(-) delete mode 100644 Workouts/Item.swift create mode 100644 Workouts/Models/Exercise.swift create mode 100644 Workouts/Models/ExerciseType.swift create mode 100644 Workouts/Models/Muscle.swift create mode 100644 Workouts/Models/MuscleGroup.swift create mode 100644 Workouts/Models/Split.swift create mode 100644 Workouts/Models/SplitExerciseAssignment.swift create mode 100644 Workouts/Models/Workout.swift create mode 100644 Workouts/Models/WorkoutLog.swift create mode 100644 Workouts/Protocols/ListableItem.swift create mode 100644 Workouts/Schema/InitialData.swift create mode 100644 Workouts/Schema/SchemaV1.swift create mode 100644 Workouts/Schema/SchemaVersion.swift create mode 100644 Workouts/Schema/WorkoutsContainer.swift create mode 100644 Workouts/Schema/WorkoutsMigrationPlan.swift create mode 100644 Workouts/Utils/AppLogger.swift create mode 100644 Workouts/Utils/Badge.swift create mode 100644 Workouts/Utils/Date+Extensions.swift create mode 100644 Workouts/Utils/ListItem.swift create mode 100644 Workouts/Views/Settings/ExerciseTypes/ExerciseTypeAddEditView.swift create mode 100644 Workouts/Views/Settings/ExerciseTypes/ExerciseTypeListView.swift create mode 100644 Workouts/Views/Settings/Exercises/ExerciseAddEditView.swift create mode 100644 Workouts/Views/Settings/Exercises/ExercisesListView.swift create mode 100644 Workouts/Views/Settings/MuscleGroups/MuscleGroupAddEditView.swift create mode 100644 Workouts/Views/Settings/MuscleGroups/MuscleGroupsListView.swift create mode 100644 Workouts/Views/Settings/Muscles/MuscleAddEditView.swift create mode 100644 Workouts/Views/Settings/Muscles/MusclesListView.swift create mode 100644 Workouts/Views/Settings/SettingsView.swift create mode 100644 Workouts/Views/Settings/Splits/SplitAddEditView.swift create mode 100644 Workouts/Views/Settings/Splits/SplitsListView.swift diff --git a/MODEL.md b/MODEL.md index b23d410..a65a46f 100644 --- a/MODEL.md +++ b/MODEL.md @@ -27,21 +27,21 @@ ExerciseType - name (String) - descr (String) -- exercises (Set?) // deleteRule: nullify, inverse: Exercise.types +- exercises (Set?) // deleteRule: nullify MuscleGroup - name (String) - descr (String) -- muscles (Set?) // deleteRule: nullify, inverse: Muscle.groups +- muscles (Set?) // deleteRule: nullify Muscle - name (String) - descr (String) -- groups (Set) // deleteRule: nullify, inverse: MuscleGroup.muscles -- exercises (Set?) // deleteRule: nullify, inverse: Exercise.muscles +- muscleGroup (MuscleGroup?) // deleteRule: nullify, inverse: MuscleGroup.muscles +- exercises (Set?) // deleteRule: nullify Exercise -- types (Set?) // deleteRule: .nullify, inverse: ExerciseType.exercises +- type (ExerciseType?) // deleteRule: .nullify, inverse: ExerciseType.exercises - name (String) - setup (String) - descr (String) @@ -53,8 +53,8 @@ Exercise - logs (Set?) // deleteRule: .nullify, inverse: WorkoutLog.exercise SplitExerciseAssignment -- split (Split?) // deleteRule: .nullify, inverse: Split.exercises -- exercise (Exercise?) // deleteRule: .nullify, inverse: Exercise.splits +- split (Split?) // deleteRule: .nullify +- exercise (Exercise?) // deleteRule: .nullify - order (Int) - sets (Int) - reps (Int) @@ -66,8 +66,8 @@ Split - exercises (Set?) // deleteRule: .cascade, inverse: SplitExerciseAssignment.split WorkoutLog -- workout (Workout?) // deleteRule: .nullify, inverse: Workout.logs -- exercise (Exercise?) // deleteRule: .nullify, inverse: Exercise.logs +- workout (Workout?) // deleteRule: .nullify +- exercise (Exercise?) // deleteRule: .nullify - date (Date) - sets (Int) - reps (Int) @@ -75,7 +75,7 @@ WorkoutLog - completed (Bool) Workout -- split (Split?) // deleteRule: .nullify, inverse: Split.workouts +- split (Split?) // deleteRule: .nullify - start (Date) - end (Date?) - logs (Set?) // deleteRule: .cascade, inverse: WorkoutLog.workout diff --git a/Workouts/ContentView.swift b/Workouts/ContentView.swift index b4c9aaf..ab72fe7 100644 --- a/Workouts/ContentView.swift +++ b/Workouts/ContentView.swift @@ -13,52 +13,33 @@ import SwiftData struct ContentView: View { @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } + TabView { + Text("Placeholder") + .tabItem { + Label("Workout", systemImage: "figure.strengthtraining.traditional") } - .onDelete(perform: deleteItems) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") - } - } - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) + + // Reports Tab + NavigationStack { + Text("Reports Placeholder") + .navigationTitle("Reports") } + .tabItem { + Label("Reports", systemImage: "chart.bar") + } + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } } + } } #Preview { ContentView() - .modelContainer(for: Item.self, inMemory: true) } diff --git a/Workouts/Item.swift b/Workouts/Item.swift deleted file mode 100644 index 2ccbc2e..0000000 --- a/Workouts/Item.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Item.swift -// Workouts -// -// Created by rzen on 7/11/25 at 5:04 PM. -// -// Copyright 2025 Rouslan Zenetl. All Rights Reserved. -// - - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/Workouts/Models/Exercise.swift b/Workouts/Models/Exercise.swift new file mode 100644 index 0000000..a0c7cf6 --- /dev/null +++ b/Workouts/Models/Exercise.swift @@ -0,0 +1,33 @@ +import Foundation +import SwiftData + +@Model +final class Exercise: ListableItem { + @Attribute(.unique) var name: String = "" + var setup: String = "" + var descr: String = "" + var sets: Int = 0 + var reps: Int = 0 + var weight: Int = 0 + + @Relationship(deleteRule: .nullify, inverse: \ExerciseType.exercises) + var type: ExerciseType? + + @Relationship(deleteRule: .nullify, inverse: \Muscle.exercises) + var muscles: [Muscle]? = [] + + @Relationship(deleteRule: .nullify, inverse: \SplitExerciseAssignment.exercise) + var splits: [SplitExerciseAssignment]? = [] + + @Relationship(deleteRule: .nullify, inverse: \WorkoutLog.exercise) + var logs: [WorkoutLog]? = [] + + init(name: String, setup: String, descr: String, sets: Int, reps: Int, weight: Int) { + self.name = name + self.setup = setup + self.descr = descr + self.sets = sets + self.reps = reps + self.weight = weight + } +} diff --git a/Workouts/Models/ExerciseType.swift b/Workouts/Models/ExerciseType.swift new file mode 100644 index 0000000..5f55229 --- /dev/null +++ b/Workouts/Models/ExerciseType.swift @@ -0,0 +1,16 @@ +import Foundation +import SwiftData + +@Model +final class ExerciseType: ListableItem { + @Attribute(.unique) var name: String = "" + var descr: String = "" + + @Relationship(deleteRule: .nullify) + var exercises: [Exercise]? = [] + + init(name: String, descr: String) { + self.name = name + self.descr = descr + } +} diff --git a/Workouts/Models/Muscle.swift b/Workouts/Models/Muscle.swift new file mode 100644 index 0000000..9608857 --- /dev/null +++ b/Workouts/Models/Muscle.swift @@ -0,0 +1,21 @@ +import Foundation +import SwiftData + +@Model +final class Muscle: ListableItem { + @Attribute(.unique) var name: String = "" + + var descr: String = "" + + @Relationship(deleteRule: .nullify, inverse: \MuscleGroup.muscles) + var muscleGroup: MuscleGroup? + + @Relationship(deleteRule: .nullify) + var exercises: [Exercise]? = [] + + init(name: String, descr: String, muscleGroup: MuscleGroup) { + self.name = name + self.descr = descr + self.muscleGroup = muscleGroup + } +} diff --git a/Workouts/Models/MuscleGroup.swift b/Workouts/Models/MuscleGroup.swift new file mode 100644 index 0000000..b5c8e7e --- /dev/null +++ b/Workouts/Models/MuscleGroup.swift @@ -0,0 +1,16 @@ +import Foundation +import SwiftData + +@Model +final class MuscleGroup: ListableItem { + @Attribute(.unique) var name: String = "" + var descr: String = "" + + @Relationship(deleteRule: .nullify) + var muscles: [Muscle]? = [] + + init(name: String, descr: String) { + self.name = name + self.descr = descr + } +} diff --git a/Workouts/Models/Split.swift b/Workouts/Models/Split.swift new file mode 100644 index 0000000..ae854a5 --- /dev/null +++ b/Workouts/Models/Split.swift @@ -0,0 +1,19 @@ +import Foundation +import SwiftData + +@Model +final class Split: ListableItem { + @Attribute(.unique) var name: String = "" + var intro: String = "" + + @Relationship(deleteRule: .cascade, inverse: \SplitExerciseAssignment.split) + var exercises: [SplitExerciseAssignment]? = [] + + @Relationship(deleteRule: .nullify, inverse: \Workout.split) + var workouts: [Workout]? = [] + + init(name: String, intro: String) { + self.name = name + self.intro = intro + } +} diff --git a/Workouts/Models/SplitExerciseAssignment.swift b/Workouts/Models/SplitExerciseAssignment.swift new file mode 100644 index 0000000..a8ab989 --- /dev/null +++ b/Workouts/Models/SplitExerciseAssignment.swift @@ -0,0 +1,25 @@ +import Foundation +import SwiftData + +@Model +final class SplitExerciseAssignment { + var order: Int = 0 + var sets: Int = 0 + var reps: Int = 0 + var weight: Int = 0 + + @Relationship(deleteRule: .nullify) + var split: Split? + + @Relationship(deleteRule: .nullify) + var exercise: Exercise? + + init(order: Int, sets: Int, reps: Int, weight: Int, split: Split, exercise: Exercise) { + self.order = order + self.sets = sets + self.reps = reps + self.weight = weight + self.split = split + self.exercise = exercise + } +} diff --git a/Workouts/Models/Workout.swift b/Workouts/Models/Workout.swift new file mode 100644 index 0000000..5eed9ed --- /dev/null +++ b/Workouts/Models/Workout.swift @@ -0,0 +1,20 @@ +import Foundation +import SwiftData + +@Model +final class Workout { + var start: Date = Date() + var end: Date? + + @Relationship(deleteRule: .nullify) + var split: Split? + + @Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout) + var logs: [WorkoutLog]? = [] + + init(start: Date, end: Date? = nil, split: Split?) { + self.start = start + self.end = end + self.split = split + } +} diff --git a/Workouts/Models/WorkoutLog.swift b/Workouts/Models/WorkoutLog.swift new file mode 100644 index 0000000..29846d2 --- /dev/null +++ b/Workouts/Models/WorkoutLog.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftData + +@Model +final class WorkoutLog { + var date: Date = Date() + var sets: Int = 0 + var reps: Int = 0 + var weight: Int = 0 + var completed: Bool = false + + @Relationship(deleteRule: .nullify) + var workout: Workout? + + @Relationship(deleteRule: .nullify) + var exercise: Exercise? + + init(date: Date, sets: Int, reps: Int, weight: Int, completed: Bool, workout: Workout, exercise: Exercise) { + self.date = date + self.sets = sets + self.reps = reps + self.weight = weight + self.completed = completed + self.workout = workout + self.exercise = exercise + } +} diff --git a/Workouts/Protocols/ListableItem.swift b/Workouts/Protocols/ListableItem.swift new file mode 100644 index 0000000..5eab74a --- /dev/null +++ b/Workouts/Protocols/ListableItem.swift @@ -0,0 +1,12 @@ +// +// ListableItem.swift +// Workouts +// +// Created by rzen on 7/13/25 at 10:40 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +protocol ListableItem { + var name: String { get set } +} diff --git a/Workouts/Schema/InitialData.swift b/Workouts/Schema/InitialData.swift new file mode 100644 index 0000000..0273517 --- /dev/null +++ b/Workouts/Schema/InitialData.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftData + +struct InitialData { + static let logger = AppLogger(subsystem: "Workouts", category: "InitialData") + + // Data structures for JSON decoding + private struct ExerciseTypeData: Codable { + let name: String + let descr: String + } + + private struct MuscleGroupData: Codable { + let name: String + let descr: String + } + + private struct MuscleData: Codable { + let name: String + let descr: String + let muscleGroup: String + } + + private struct ExerciseData: Codable { + let name: String + let setup: String + let descr: String + let sets: Int + let reps: Int + let weight: Int + let type: String + let muscles: [String] + } + + private struct SplitExerciseAssignmentData: Codable { + let exercise: String + let weight: Int + let sets: Int + let reps: Int + } + + private struct SplitData: Codable { + let name: String + let intro: String + let splitExerciseAssignments: [SplitExerciseAssignmentData] + } + + @MainActor + static func create(modelContext: ModelContext) { + logger.info("Creating initial data from JSON files") + + // Load and insert data + do { + // Dictionaries to store references + var exerciseTypes: [String: ExerciseType] = [:] + var muscleGroups: [String: MuscleGroup] = [:] + var muscles: [String: Muscle] = [:] + var exercises: [String: Exercise] = [:] + + // 1. Load Exercise Types + let exerciseTypeData = try loadJSON(forResource: "exercise-types", type: [ExerciseTypeData].self) + for typeData in exerciseTypeData { + let exerciseType = ExerciseType(name: typeData.name, descr: typeData.descr) + exerciseTypes[typeData.name] = exerciseType + modelContext.insert(exerciseType) + } + + // 2. Load Muscle Groups + let muscleGroupData = try loadJSON(forResource: "muscle-groups", type: [MuscleGroupData].self) + for groupData in muscleGroupData { + let muscleGroup = MuscleGroup(name: groupData.name, descr: groupData.descr) + muscleGroups[groupData.name] = muscleGroup + modelContext.insert(muscleGroup) + } + + // 3. Load Muscles + let muscleData = try loadJSON(forResource: "muscles", type: [MuscleData].self) + for data in muscleData { + // Find the muscle group for this muscle + if let muscleGroup = muscleGroups[data.muscleGroup] { + let muscle = Muscle(name: data.name, descr: data.descr, muscleGroup: muscleGroup) + muscles[data.name] = muscle + modelContext.insert(muscle) + } else { + logger.warning("Muscle group not found for muscle: \(data.name)") + } + } + + // 4. Load Exercises + let exerciseData = try loadJSON(forResource: "exercises", type: [ExerciseData].self) + for data in exerciseData { + let exercise = Exercise(name: data.name, setup: data.setup, descr: data.descr, + sets: data.sets, reps: data.reps, weight: data.weight) + + // Set exercise type + if let type = exerciseTypes[data.type] { + exercise.type = type + } else { + logger.warning("Exercise type not found: \(data.type) for exercise: \(data.name)") + } + + // Set muscles + var exerciseMuscles: [Muscle] = [] + for muscleName in data.muscles { + if let muscle = muscles[muscleName] { + exerciseMuscles.append(muscle) + } else { + logger.warning("Muscle not found: \(muscleName) for exercise: \(data.name)") + } + } + exercise.muscles = exerciseMuscles + + exercises[data.name] = exercise + modelContext.insert(exercise) + } + + // 5. Load Splits and Exercise Assignments + let splitData = try loadJSON(forResource: "splits", type: [SplitData].self) + for data in splitData { + let split = Split(name: data.name, intro: data.intro) + modelContext.insert(split) + + // Create exercise assignments for this split + for (index, assignment) in data.splitExerciseAssignments.enumerated() { + if let exercise = exercises[assignment.exercise] { + let splitAssignment = SplitExerciseAssignment( + order: index + 1, // 1-based ordering + sets: assignment.sets, + reps: assignment.reps, + weight: assignment.weight, + split: split, + exercise: exercise + ) + modelContext.insert(splitAssignment) + } else { + logger.warning("Exercise not found: \(assignment.exercise) for split: \(data.name)") + } + } + } + + // Save all the inserted data + try modelContext.save() + logger.info("Initial data loaded successfully from JSON files") + } catch { + logger.error("Failed to load initial data from JSON files: \(error.localizedDescription)") + } + } + + // Helper method to load and decode JSON from a file + private static func loadJSON(forResource name: String, type: T.Type) throws -> T { + guard let url = Bundle.main.url(forResource: name, withExtension: "json") else { + logger.error("Could not find JSON file: \(name).json") + throw NSError(domain: "InitialData", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not find JSON file: \(name).json"]) + } + + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } catch { + logger.error("Failed to decode JSON file \(name).json: \(error.localizedDescription)") + throw error + } + } +} diff --git a/Workouts/Schema/SchemaV1.swift b/Workouts/Schema/SchemaV1.swift new file mode 100644 index 0000000..65595d2 --- /dev/null +++ b/Workouts/Schema/SchemaV1.swift @@ -0,0 +1,16 @@ +import SwiftData + +enum SchemaV1: VersionedSchema { + static var versionIdentifier: Schema.Version = .init(1, 0, 0) + + static var models: [any PersistentModel.Type] = [ + Exercise.self, + ExerciseType.self, + Muscle.self, + MuscleGroup.self, + Split.self, + SplitExerciseAssignment.self, + Workout.self, + WorkoutLog.self + ] +} diff --git a/Workouts/Schema/SchemaVersion.swift b/Workouts/Schema/SchemaVersion.swift new file mode 100644 index 0000000..147c305 --- /dev/null +++ b/Workouts/Schema/SchemaVersion.swift @@ -0,0 +1,13 @@ +import SwiftData + +enum SchemaVersion: Int, CaseIterable { + static var allCases: [SchemaVersion] = [ + .v1 + ] + + case v1 + + static var current: SchemaVersion { + .v1 + } +} diff --git a/Workouts/Schema/WorkoutsContainer.swift b/Workouts/Schema/WorkoutsContainer.swift new file mode 100644 index 0000000..b431d30 --- /dev/null +++ b/Workouts/Schema/WorkoutsContainer.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +final class WorkoutsContainer { + static let logger = AppLogger(subsystem: "Workouts", category: "WorkoutsContainer") + + static func create(shouldCreateDefaults: inout Bool) -> ModelContainer { + let schema = Schema(versionedSchema: SchemaV1.self) + let container = try! ModelContainer(for: schema, migrationPlan: WorkoutsMigrationPlan.self) + + let context = ModelContext(container) + let descriptor = FetchDescriptor() + let results = try! context.fetch(descriptor) + + if results.isEmpty { + shouldCreateDefaults = true + } + + return container + } + + @MainActor + static var preview: ModelContainer { + let schema = Schema(versionedSchema: SchemaV1.self) + + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + + do { + let container = try ModelContainer(for: schema, configurations: [configuration]) + let context = ModelContext(container) + + // Create default data for previews + InitialData.create(modelContext: context) + + return container + } catch { + fatalError("Failed to create preview ModelContainer: \(error.localizedDescription)") + } + } +} diff --git a/Workouts/Schema/WorkoutsMigrationPlan.swift b/Workouts/Schema/WorkoutsMigrationPlan.swift new file mode 100644 index 0000000..9df3aae --- /dev/null +++ b/Workouts/Schema/WorkoutsMigrationPlan.swift @@ -0,0 +1,11 @@ +import SwiftData + +struct WorkoutsMigrationPlan: SchemaMigrationPlan { + static var schemas: [VersionedSchema.Type] = [ + SchemaV1.self + ] + + static var stages: [MigrationStage] = [ + // Add migration stages here in the future + ] +} diff --git a/Workouts/Utils/AppLogger.swift b/Workouts/Utils/AppLogger.swift new file mode 100644 index 0000000..3ab1bea --- /dev/null +++ b/Workouts/Utils/AppLogger.swift @@ -0,0 +1,37 @@ +import OSLog + +struct AppLogger { + private let logger: Logger + private let subsystem: String + private let category: String + + init(subsystem: String, category: String) { + self.subsystem = subsystem + self.category = category + self.logger = Logger(subsystem: subsystem, category: category) + } + + func timestamp () -> String { + Date.now.formatDateET(format: "yyyy-MM-dd HH:mm:ss") + } + + func formattedMessage (_ message: String) -> String { + "\(timestamp()) [\(subsystem):\(category)] \(message)" + } + + func debug(_ message: String) { + logger.debug("\(formattedMessage(message))") + } + + func info(_ message: String) { + logger.info("\(formattedMessage(message))") + } + + func warning(_ message: String) { + logger.warning("\(formattedMessage(message))") + } + + func error(_ message: String) { + logger.error("\(formattedMessage(message))") + } +} diff --git a/Workouts/Utils/Badge.swift b/Workouts/Utils/Badge.swift new file mode 100644 index 0000000..f722e5e --- /dev/null +++ b/Workouts/Utils/Badge.swift @@ -0,0 +1,15 @@ +// +// Badge.swift +// Workouts +// +// Created by rzen on 7/13/25 at 5:42 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUICore + +struct Badge: Hashable { + var text: String + var color: Color +} diff --git a/Workouts/Utils/Date+Extensions.swift b/Workouts/Utils/Date+Extensions.swift new file mode 100644 index 0000000..c705a4d --- /dev/null +++ b/Workouts/Utils/Date+Extensions.swift @@ -0,0 +1,14 @@ +import Foundation + +extension Date { + func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(identifier: "America/New_York") + formatter.dateFormat = format + return formatter.string(from: self) + } + + static var ISO8601: String { + "yyyy-MM-dd'T'HH:mm:ssZ" + } +} diff --git a/Workouts/Utils/ListItem.swift b/Workouts/Utils/ListItem.swift new file mode 100644 index 0000000..def4589 --- /dev/null +++ b/Workouts/Utils/ListItem.swift @@ -0,0 +1,50 @@ +// +// ListItem.swift +// Workouts +// +// Created by rzen on 7/13/25 at 10:42 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI + +struct ListItem: View { + var title: String + var subtitle: String? + var count: Int? + var badges: [Badge]? = [] + + var body: some View { + HStack { + VStack (alignment: .leading) { + Text("\(title)") + HStack { + if let subtitle = subtitle { + Text("\(subtitle)") + .font(.footnote) + } + if let badges = badges { + ForEach (badges, id: \.self) { badge in + Text("\(badge.text)") + .bold() + .padding([.leading,.trailing], 5) + .cornerRadius(4) + .background(badge.color) + .foregroundColor(.white) + } + } + } + } + if let count = count { + Spacer() + Text("\(count)") + .font(.caption) + .foregroundColor(.gray) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } +} + diff --git a/Workouts/Views/Settings/ExerciseTypes/ExerciseTypeAddEditView.swift b/Workouts/Views/Settings/ExerciseTypes/ExerciseTypeAddEditView.swift new file mode 100644 index 0000000..ca9e4cd --- /dev/null +++ b/Workouts/Views/Settings/ExerciseTypes/ExerciseTypeAddEditView.swift @@ -0,0 +1,48 @@ +// +// ExerciseTypeAddEditView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 11:33 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI + +struct ExerciseTypeAddEditView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Bindable var model: ExerciseType + + var body: some View { + NavigationStack { + Form { + Section (header: Text("Nname")) { + TextField("Name", text: $model.name) + .bold() + } + + Section(header: Text("Description")) { + TextEditor(text: $model.descr) + .frame(minHeight: 100) + .padding(.vertical, 4) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + try? modelContext.save() + dismiss() + } + } + } + } + } +} diff --git a/Workouts/Views/Settings/ExerciseTypes/ExerciseTypeListView.swift b/Workouts/Views/Settings/ExerciseTypes/ExerciseTypeListView.swift new file mode 100644 index 0000000..e4d78d9 --- /dev/null +++ b/Workouts/Views/Settings/ExerciseTypes/ExerciseTypeListView.swift @@ -0,0 +1,75 @@ +// +// ExerciseTypeListView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 11:27 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct ExerciseTypeListView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Query(sort: [SortDescriptor(\ExerciseType.name)]) var items: [ExerciseType] + + @State var itemToEdit: ExerciseType? = nil + @State var itemToDelete: ExerciseType? = nil + + private func save () { + try? modelContext.save() + } + + var body: some View { + NavigationStack { + Form { + List { + ForEach (items) { item in + ListItem(title: item.name) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button (role: .destructive) { + itemToDelete = item + } label: { + Label("Delete", systemImage: "trash") + } + Button { + itemToEdit = item + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.indigo) + } + } + } + } + .navigationTitle("Exercise Types") + .sheet(item: $itemToEdit) {item in + ExerciseTypeAddEditView(model: item) + } + .confirmationDialog( + "Delete?", + isPresented: Binding( + get: { itemToDelete != nil }, + set: { if !$0 { itemToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let itemToDelete = itemToDelete { + modelContext.delete(itemToDelete) + try? modelContext.save() + self.itemToDelete = nil + } + } + Button("Cancel", role: .cancel) { + itemToDelete = nil + } + } message: { + Text("Are you sure you want to delete \(itemToDelete?.name ?? "this item")?") + } + } + } +} diff --git a/Workouts/Views/Settings/Exercises/ExerciseAddEditView.swift b/Workouts/Views/Settings/Exercises/ExerciseAddEditView.swift new file mode 100644 index 0000000..f9cff58 --- /dev/null +++ b/Workouts/Views/Settings/Exercises/ExerciseAddEditView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct ExerciseAddEditView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Bindable var model: Exercise + + var body: some View { + Text("Add/Edit Exercise") + .navigationTitle("Exercise") + } +} diff --git a/Workouts/Views/Settings/Exercises/ExercisesListView.swift b/Workouts/Views/Settings/Exercises/ExercisesListView.swift new file mode 100644 index 0000000..a0bdda8 --- /dev/null +++ b/Workouts/Views/Settings/Exercises/ExercisesListView.swift @@ -0,0 +1,82 @@ +// +// ExercisesListView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 4:30 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct ExercisesListView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Query(sort: [SortDescriptor(\ExerciseType.name)]) var groups: [ExerciseType] + + @State var itemToEdit: Exercise? = nil + @State var itemToDelete: Exercise? = nil + + private func save () { + try? modelContext.save() + } + + var body: some View { + NavigationStack { + Form { + ForEach (groups) { group in + let items = group.exercises ?? [] + let itemCount = items.count + if itemCount > 0 { + Section (header: Text("\(group.name) (\(itemCount))")) { + ForEach (items) { item in + ListItem(title: item.name) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button (role: .destructive) { + itemToDelete = item + } label: { + + Label("Delete", systemImage: "trash") + } + Button { + itemToEdit = item + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.indigo) + } + } + } + } + } + } + .navigationTitle("Exercises") + .sheet(item: $itemToEdit) { item in + ExerciseAddEditView(model: item) + } + .confirmationDialog( + "Delete?", + isPresented: Binding( + get: { itemToDelete != nil }, + set: { if !$0 { itemToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let itemToDelete = itemToDelete { + modelContext.delete(itemToDelete) + try? modelContext.save() + self.itemToDelete = nil + } + } + Button("Cancel", role: .cancel) { + itemToDelete = nil + } + } message: { + Text("Are you sure you want to delete \(itemToDelete?.name ?? "this item")?") + } + } + } +} diff --git a/Workouts/Views/Settings/MuscleGroups/MuscleGroupAddEditView.swift b/Workouts/Views/Settings/MuscleGroups/MuscleGroupAddEditView.swift new file mode 100644 index 0000000..f4bfdbe --- /dev/null +++ b/Workouts/Views/Settings/MuscleGroups/MuscleGroupAddEditView.swift @@ -0,0 +1,48 @@ +import SwiftUI + +// +// MuscleGroupAddEditView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 12:14 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +struct MuscleGroupAddEditView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Bindable var model: MuscleGroup + + var body: some View { + NavigationStack { + Form { + Section (header: Text("Name")) { + TextField("Name", text: $model.name) + .bold() + } + + Section(header: Text("Description")) { + TextEditor(text: $model.descr) + .frame(minHeight: 100) + .padding(.vertical, 4) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + try? modelContext.save() + dismiss() + } + } + } + } + } +} diff --git a/Workouts/Views/Settings/MuscleGroups/MuscleGroupsListView.swift b/Workouts/Views/Settings/MuscleGroups/MuscleGroupsListView.swift new file mode 100644 index 0000000..c0b9a25 --- /dev/null +++ b/Workouts/Views/Settings/MuscleGroups/MuscleGroupsListView.swift @@ -0,0 +1,70 @@ +// +// MuscleGroupsListView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 12:14 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct MuscleGroupsListView: View { + @Environment(\.modelContext) private var modelContext + + @Query(sort: [SortDescriptor(\MuscleGroup.name)]) var items: [MuscleGroup] + + @State var itemToEdit: MuscleGroup? = nil + @State var itemToDelete: MuscleGroup? = nil + + var body: some View { + NavigationStack { + Form { + List { + ForEach (items) { item in + ListItem(title: item.name, count: item.muscles?.count) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button (role: .destructive) { + itemToDelete = item + } label: { + Label("Delete", systemImage: "trash") + } + Button { + itemToEdit = item + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.indigo) + } + } + } + .navigationTitle("Muscle Groups") + } + .sheet(item: $itemToEdit) {item in + MuscleGroupAddEditView(model: item) + } + .confirmationDialog( + "Delete?", + isPresented: Binding( + get: { itemToDelete != nil }, + set: { if !$0 { itemToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let itemToDelete = itemToDelete { + modelContext.delete(itemToDelete) + try? modelContext.save() + self.itemToDelete = nil + } + } + Button("Cancel", role: .cancel) { + itemToDelete = nil + } + } message: { + Text("Are you sure you want to delete \(itemToDelete?.name ?? "this item")?") + } + } + } +} diff --git a/Workouts/Views/Settings/Muscles/MuscleAddEditView.swift b/Workouts/Views/Settings/Muscles/MuscleAddEditView.swift new file mode 100644 index 0000000..fa1010a --- /dev/null +++ b/Workouts/Views/Settings/Muscles/MuscleAddEditView.swift @@ -0,0 +1,59 @@ +// +// MuscleAddEditView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 11:55 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct MuscleAddEditView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Query(sort: [SortDescriptor(\MuscleGroup.name)]) var muscleGroups: [MuscleGroup] + @Bindable var model: Muscle + + var body: some View { + NavigationStack { + Form { + Section (header: Text("Name")) { + TextField("Name", text: $model.name) + .bold() + } + + Section(header: Text("Muscle Group")) { + Picker("Muscle Group", selection: $model.muscleGroup) { + Text("Select a muscle group").tag(nil as MuscleGroup?) + ForEach(muscleGroups) { group in + Text(group.name).tag(group as MuscleGroup?) + } + } + } + + Section(header: Text("Description")) { + TextEditor(text: $model.descr) + .frame(minHeight: 100) + .padding(.vertical, 4) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + try? modelContext.save() + dismiss() + } + } + } + } + } +} diff --git a/Workouts/Views/Settings/Muscles/MusclesListView.swift b/Workouts/Views/Settings/Muscles/MusclesListView.swift new file mode 100644 index 0000000..13a7b36 --- /dev/null +++ b/Workouts/Views/Settings/Muscles/MusclesListView.swift @@ -0,0 +1,79 @@ +// +// MuscleGroupsListView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 12:14 PM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct MusclesListView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Query(sort: [SortDescriptor(\MuscleGroup.name)]) var groups: [MuscleGroup] + + @State var itemToEdit: Muscle? = nil + @State var itemToDelete: Muscle? = nil + + private func save () { + try? modelContext.save() + } + + var body: some View { + NavigationStack { + Form { + ForEach (groups) { group in + Section (header: Text("\(group.name) (\(group.muscles?.count ?? 0))")) { + let items = group.muscles ?? [] + ForEach (items) { item in + ListItem(title: item.name) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button (role: .destructive) { + itemToDelete = item + } label: { + + Label("Delete", systemImage: "trash") + } + Button { + itemToEdit = item + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.indigo) + } + } + } + } + } + .navigationTitle("Muscles") + .sheet(item: $itemToEdit) { item in + MuscleAddEditView(model: item) + } + .confirmationDialog( + "Delete?", + isPresented: Binding( + get: { itemToDelete != nil }, + set: { if !$0 { itemToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let itemToDelete = itemToDelete { + modelContext.delete(itemToDelete) + try? modelContext.save() + self.itemToDelete = nil + } + } + Button("Cancel", role: .cancel) { + itemToDelete = nil + } + } message: { + Text("Are you sure you want to delete \(itemToDelete?.name ?? "this item")?") + } + } + } +} diff --git a/Workouts/Views/Settings/SettingsView.swift b/Workouts/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..29fe35e --- /dev/null +++ b/Workouts/Views/Settings/SettingsView.swift @@ -0,0 +1,77 @@ +// +// SettingsView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 10:24 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct SettingsView: View { + @Environment(\.modelContext) private var modelContext + + + var splitsCount: Int? { try? modelContext.fetchCount(FetchDescriptor()) } + var musclesCount: Int? { try? modelContext.fetchCount(FetchDescriptor()) } + var muscleGroupsCount: Int? { try? modelContext.fetchCount(FetchDescriptor()) } + var exerciseTypeCount: Int? { try? modelContext.fetchCount(FetchDescriptor()) } + var exercisesCount: Int? { try? modelContext.fetchCount(FetchDescriptor()) } + + var body: some View { + NavigationStack { + Form { + Section (header: Text("Lists")) { + NavigationLink(destination: SplitsListView()) { + HStack { + Text("Splits") + Spacer() + Text("\(splitsCount ?? 0)") + .font(.caption) + .foregroundColor(.gray) + } + } + NavigationLink(destination: MuscleGroupsListView()) { + HStack { + Text("Muscle Groups") + Spacer() + Text("\(muscleGroupsCount ?? 0)") + .font(.caption) + .foregroundColor(.gray) + } + } + NavigationLink(destination: MusclesListView()) { + HStack { + Text("Muscles") + Spacer() + Text("\(musclesCount ?? 0)") + .font(.caption) + .foregroundColor(.gray) + } + } + NavigationLink(destination: ExerciseTypeListView()) { + HStack { + Text("Exercise Types") + Spacer() + Text("\(exerciseTypeCount ?? 0)") + .font(.caption) + .foregroundColor(.gray) + } + } + NavigationLink(destination: ExercisesListView()) { + HStack { + Text("Exercises") + Spacer() + Text("\(exercisesCount ?? 0)") + .font(.caption) + .foregroundColor(.gray) + } + } + } + } + .navigationTitle("Settings") + } + } +} diff --git a/Workouts/Views/Settings/Splits/SplitAddEditView.swift b/Workouts/Views/Settings/Splits/SplitAddEditView.swift new file mode 100644 index 0000000..3fd3b3d --- /dev/null +++ b/Workouts/Views/Settings/Splits/SplitAddEditView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct SplitAddEditView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Bindable var model: Split + + @State var itemToEdit: SplitExerciseAssignment? = nil + @State var itemToDelete: SplitExerciseAssignment? = nil + + var body: some View { + NavigationStack { + Form { + Section (header: Text("Name")) { + TextField("Name", text: $model.name) + .bold() + } + + Section(header: Text("Description")) { + TextEditor(text: $model.intro) + .frame(minHeight: 100) + .padding(.vertical, 4) + } + + Section(header: Text("Exercises")) { + let item = model + if let assignments = item.exercises, !assignments.isEmpty { + ForEach(assignments, id: \.id) { item in + List { + ListItem( + title: item.exercise?.name ?? "Unnamed", + subtitle: "\(item.sets) × \(item.reps) @ \(item.weight) lbs" + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + 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") + .foregroundColor(.secondary) + } + + Button(action: { + + }) { + Label("Add Exercise", systemImage: "plus.circle") + } + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + try? modelContext.save() + dismiss() + } + } + } + } + } +} diff --git a/Workouts/Views/Settings/Splits/SplitsListView.swift b/Workouts/Views/Settings/Splits/SplitsListView.swift new file mode 100644 index 0000000..566635e --- /dev/null +++ b/Workouts/Views/Settings/Splits/SplitsListView.swift @@ -0,0 +1,78 @@ +// +// SplitsListView.swift +// Workouts +// +// Created by rzen on 7/13/25 at 10:27 AM. +// +// Copyright 2025 Rouslan Zenetl. All Rights Reserved. +// + +import SwiftUI +import SwiftData + +struct SplitsListView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Query(sort: [SortDescriptor(\Split.name)]) var items: [Split] + + @State var itemToEdit: Split? = nil + @State var itemToDelete: Split? = nil + + private func save () { + try? modelContext.save() + } + + var body: some View { + NavigationStack { + Form { + List { + ForEach (items) { item in + ListItem( + title: item.name, + count: item.exercises?.count + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button (role: .destructive) { + itemToDelete = item + } label: { + Label("Delete", systemImage: "trash") + } + Button { + itemToEdit = item + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.indigo) + } + } + } + .navigationTitle("Muscle Groups") + } + .sheet(item: $itemToEdit) {item in + SplitAddEditView(model: item) + } + .confirmationDialog( + "Delete?", + isPresented: Binding( + get: { itemToDelete != nil }, + set: { if !$0 { itemToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let itemToDelete = itemToDelete { + modelContext.delete(itemToDelete) + try? modelContext.save() + self.itemToDelete = nil + } + } + Button("Cancel", role: .cancel) { + itemToDelete = nil + } + } message: { + Text("Are you sure you want to delete \(itemToDelete?.name ?? "this item")?") + } + } + } +} diff --git a/Workouts/WorkoutsApp.swift b/Workouts/WorkoutsApp.swift index ac9eb75..5ffb2a1 100644 --- a/Workouts/WorkoutsApp.swift +++ b/Workouts/WorkoutsApp.swift @@ -13,23 +13,21 @@ import SwiftData @main struct WorkoutsApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") + let container: ModelContainer + + init() { + var shouldCreateDefaults = false + self.container = WorkoutsContainer.create(shouldCreateDefaults: &shouldCreateDefaults) + + if shouldCreateDefaults { + InitialData.create(modelContext: ModelContext(container)) } - }() + } var body: some Scene { WindowGroup { ContentView() } - .modelContainer(sharedModelContainer) + .modelContainer(container) } }