This commit is contained in:
2025-07-13 17:51:52 -04:00
parent 6cd44579e2
commit d4514805e9
33 changed files with 1295 additions and 80 deletions

View File

@ -27,21 +27,21 @@
ExerciseType
- name (String)
- descr (String)
- exercises (Set<Exercise>?) // deleteRule: nullify, inverse: Exercise.types
- exercises (Set<Exercise>?) // deleteRule: nullify
MuscleGroup
- name (String)
- descr (String)
- muscles (Set<Muscle>?) // deleteRule: nullify, inverse: Muscle.groups
- muscles (Set<Muscle>?) // deleteRule: nullify
Muscle
- name (String)
- descr (String)
- groups (Set<MuscleGroup>) // deleteRule: nullify, inverse: MuscleGroup.muscles
- exercises (Set<Exercise>?) // deleteRule: nullify, inverse: Exercise.muscles
- muscleGroup (MuscleGroup?) // deleteRule: nullify, inverse: MuscleGroup.muscles
- exercises (Set<Exercise>?) // deleteRule: nullify
Exercise
- types (Set<ExerciseType>?) // 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<WorkoutLog>?) // 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<SplitExerciseAssignment>?) // 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<WorkoutLog>?) // deleteRule: .cascade, inverse: WorkoutLog.workout

View File

@ -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)
}

View File

@ -1,21 +0,0 @@
//
// Item.swift
// Workouts
//
// Created by rzen on 7/11/25 at 5:04PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,12 @@
//
// ListableItem.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:40AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
protocol ListableItem {
var name: String { get set }
}

View File

@ -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<T: Decodable>(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
}
}
}

View File

@ -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
]
}

View File

@ -0,0 +1,13 @@
import SwiftData
enum SchemaVersion: Int, CaseIterable {
static var allCases: [SchemaVersion] = [
.v1
]
case v1
static var current: SchemaVersion {
.v1
}
}

View File

@ -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<Workout>()
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)")
}
}
}

View File

@ -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
]
}

View File

@ -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))")
}
}

View File

@ -0,0 +1,15 @@
//
// Badge.swift
// Workouts
//
// Created by rzen on 7/13/25 at 5:42PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUICore
struct Badge: Hashable {
var text: String
var color: Color
}

View File

@ -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"
}
}

View File

@ -0,0 +1,50 @@
//
// ListItem.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:42AM.
//
// 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())
}
}

View File

@ -0,0 +1,48 @@
//
// ExerciseTypeAddEditView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 11:33AM.
//
// 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()
}
}
}
}
}
}

View File

@ -0,0 +1,75 @@
//
// ExerciseTypeListView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 11:27AM.
//
// 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<Bool>(
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")?")
}
}
}
}

View File

@ -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")
}
}

View File

@ -0,0 +1,82 @@
//
// ExercisesListView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 4:30PM.
//
// 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<Bool>(
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")?")
}
}
}
}

View File

@ -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()
}
}
}
}
}
}

View File

@ -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<Bool>(
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")?")
}
}
}
}

View File

@ -0,0 +1,59 @@
//
// MuscleAddEditView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 11:55AM.
//
// 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()
}
}
}
}
}
}

View File

@ -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<Bool>(
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")?")
}
}
}
}

View File

@ -0,0 +1,77 @@
//
// SettingsView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:24AM.
//
// 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<Split>()) }
var musclesCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Muscle>()) }
var muscleGroupsCount: Int? { try? modelContext.fetchCount(FetchDescriptor<MuscleGroup>()) }
var exerciseTypeCount: Int? { try? modelContext.fetchCount(FetchDescriptor<ExerciseType>()) }
var exercisesCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Exercise>()) }
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")
}
}
}

View File

@ -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()
}
}
}
}
}
}

View File

@ -0,0 +1,78 @@
//
// SplitsListView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 10:27AM.
//
// 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<Bool>(
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")?")
}
}
}
}

View File

@ -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)
}
}