This commit is contained in:
2025-07-14 10:22:49 -04:00
parent bdaa406876
commit 39fd45e03f
12 changed files with 373 additions and 130 deletions

View File

@ -15,28 +15,30 @@ struct ContentView: View {
@Environment(\.modelContext) private var modelContext
var body: some View {
TabView {
WorkoutsView()
NavigationView {
TabView {
WorkoutsView()
.tabItem {
Label("Workout", systemImage: "figure.strengthtraining.traditional")
}
// Reports Tab
NavigationStack {
Text("Reports Placeholder")
.navigationTitle("Reports")
}
.tabItem {
Label("Workout", systemImage: "figure.strengthtraining.traditional")
Label("Reports", systemImage: "chart.bar")
}
// Reports Tab
NavigationStack {
Text("Reports Placeholder")
.navigationTitle("Reports")
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}
.tabItem {
Label("Reports", systemImage: "chart.bar")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}
.observeCloudKitChanges()
}
}

View File

@ -31,6 +31,8 @@ final class Exercise {
self.reps = reps
self.weight = weight
}
static let unnamed = "Unnamed Exercise"
}
extension Exercise: EditableEntity {

View File

@ -17,6 +17,8 @@ final class Split {
self.name = name
self.intro = intro
}
static let unnamed = "Unnamed Split"
}
// MARK: - EditableEntity Conformance
@ -63,7 +65,7 @@ fileprivate struct SplitFormView: View {
if let assignments = model.exercises, !assignments.isEmpty {
ForEach(assignments) { item in
ListItem(
title: item.exercise?.name ?? "Unnamed",
title: item.exercise?.name ?? Exercise.unnamed,
subtitle: "\(item.sets) × \(item.reps) @ \(item.weight) lbs"
)
.swipeActions {

View File

@ -0,0 +1,33 @@
import SwiftUI
import SwiftData
/// A view modifier that refreshes the view when CloudKit data changes
struct CloudKitSyncObserver: ViewModifier {
@Environment(\.modelContext) private var modelContext
@State private var refreshID = UUID()
func body(content: Content) -> some View {
content
.id(refreshID) // Force view refresh when this changes
.onReceive(NotificationCenter.default.publisher(for: .cloudKitDataDidChange)) { _ in
// When we receive a notification that CloudKit data changed:
// 1. Create a new UUID to force view refresh
refreshID = UUID()
// 2. Optionally, you can also manually refresh the model context
// This is sometimes needed for complex relationships
Task { @MainActor in
try? modelContext.fetch(FetchDescriptor<Exercise>())
// Add other model types as needed
}
}
}
}
// Extension to make it easier to use the modifier
extension View {
/// Adds observation for CloudKit sync changes and refreshes the view when changes occur
func observeCloudKitChanges() -> some View {
self.modifier(CloudKitSyncObserver())
}
}

View File

@ -1,7 +1,7 @@
import Foundation
import SwiftData
struct InitialData {
struct DataLoader {
static let logger = AppLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts",
category: "InitialData"

View File

@ -2,21 +2,15 @@ import Foundation
import SwiftData
final class WorkoutsContainer {
static let logger = AppLogger(subsystem: "Workouts", category: "WorkoutsContainer")
static let logger = AppLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Workouts",
category: "WorkoutsContainer"
)
static func create(shouldCreateDefaults: inout Bool) -> ModelContainer {
static func create() -> ModelContainer {
let schema = Schema(versionedSchema: SchemaV1.self)
let configuration = ModelConfiguration(cloudKitDatabase: .automatic)
let container = try! ModelContainer(for: schema, migrationPlan: WorkoutsMigrationPlan.self, configurations: [configuration])
let context = ModelContext(container)
let descriptor = FetchDescriptor<Exercise>()
let results = try! context.fetch(descriptor)
if results.isEmpty {
shouldCreateDefaults = true
}
return container
}
@ -30,7 +24,7 @@ final class WorkoutsContainer {
let context = ModelContext(container)
// Create default data for previews
InitialData.create(modelContext: context)
DataLoader.create(modelContext: context)
return container
} catch {

View File

@ -9,10 +9,17 @@
import SwiftUI
import SwiftData
import CloudKit
enum AppStorageKeys {
static let iCloudSyncEnabled = "iCloudSyncEnabled"
}
struct SettingsView: View {
@Environment(\.modelContext) private var modelContext
@State private var showingPopulateData = false
@State private var showingClearAllDataConfirmation = false
var splitsCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Split>()) }
var musclesCount: Int? { try? modelContext.fetchCount(FetchDescriptor<Muscle>()) }
@ -70,10 +77,81 @@ struct SettingsView: View {
}
}
}
Section(header: Text("Developer")) {
Button(action: {
showingPopulateData = true
}) {
HStack {
Label("Populate Data", systemImage: "plus")
Spacer()
}
}
.confirmationDialog(
"Populate Data?",
isPresented: $showingPopulateData,
titleVisibility: .hidden
) {
Button("Populate Data") {
DataLoader.create(modelContext: modelContext)
}
Button("Cancel", role: .cancel) {}
// } message: {
// Text("This action cannot be undone. All data will be permanently deleted.")
}
Button(action: {
showingClearAllDataConfirmation = true
}) {
HStack {
Label("Clear All Data", systemImage: "trash")
Spacer()
}
}
.foregroundColor(.red)
.confirmationDialog(
"Clear All Data?",
isPresented: $showingClearAllDataConfirmation,
titleVisibility: .visible
) {
Button("Clear All Data", role: .destructive) {
clearAllData()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This action cannot be undone. All data will be permanently deleted.")
}
}
}
.navigationTitle("Settings")
}
}
func deleteAllObjects<T: PersistentModel>(ofType type: T.Type, from context: ModelContext) throws {
let descriptor = FetchDescriptor<T>()
let allObjects = try context.fetch(descriptor)
for object in allObjects {
context.delete(object)
}
try context.save()
}
private func clearAllData () {
do {
try deleteAllObjects(ofType: ExerciseType.self, from: modelContext)
try deleteAllObjects(ofType: Exercise.self, from: modelContext)
try deleteAllObjects(ofType: Muscle.self, from: modelContext)
try deleteAllObjects(ofType: MuscleGroup.self, from: modelContext)
try deleteAllObjects(ofType: Split.self, from: modelContext)
try deleteAllObjects(ofType: SplitExerciseAssignment.self, from: modelContext)
try deleteAllObjects(ofType: Workout.self, from: modelContext)
try deleteAllObjects(ofType: WorkoutLog.self, from: modelContext)
try modelContext.save()
} catch {
print("Failed to clear all data: \(error)")
}
}
}
struct ExercisesListView: View {

View File

@ -0,0 +1,83 @@
//
// WorkoutEditView.swift
// Workouts
//
// Created by rzen on 7/14/25 at 7:35AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
struct WorkoutEditView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State var workout: Workout
@State var endDateHidden: Bool = false
var body: some View {
NavigationStack {
Form {
Section (header: Text("Split")) {
Text("\(workout.split?.name ?? Split.unnamed)")
}
Section (header: Text("Start/End")) {
DatePicker("Started", selection: $workout.start)
Toggle("Workout Ended", isOn: Binding(
get: { workout.end != nil },
set: { newValue in
withAnimation {
if newValue {
workout.end = Date()
endDateHidden = false
} else {
workout.end = nil
endDateHidden = true
}
}
}
))
if !endDateHidden {
DatePicker("Ended", selection: $workout.start)
}
}
.onAppear {
endDateHidden = workout.end == nil
}
Section (header: Text("Workout Log")) {
if let workoutLogs = workout.logs {
List {
ForEach (workoutLogs) { log in
ListItem(
title: log.exercise?.name ?? Exercise.unnamed,
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs"
)
}
}
} else {
Text("No workout logs yet")
}
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
try? modelContext.save()
dismiss()
}
}
}
}
}
}

View File

@ -18,64 +18,66 @@ struct WorkoutLogEditView: View {
@State private var showingSaveConfirmation = false
var body: some View {
Form {
Section (header: Text("Exercise")) {
Text("\(workoutLog.exercise?.name ?? "Unnamed Exercise")")
.font(.headline)
}
NavigationStack {
Form {
Section (header: Text("Exercise")) {
Text("\(workoutLog.exercise?.name ?? Exercise.unnamed)")
.font(.headline)
}
Section(header: Text("Sets/Reps")) {
Stepper("Sets: \(workoutLog.sets)", value: $workoutLog.sets, in: 1...10)
Stepper("Reps: \(workoutLog.reps)", value: $workoutLog.reps, in: 1...50)
}
Section(header: Text("Sets/Reps")) {
Stepper("Sets: \(workoutLog.sets)", value: $workoutLog.sets, in: 1...10)
Stepper("Reps: \(workoutLog.reps)", value: $workoutLog.reps, in: 1...50)
}
Section(header: Text("Weight")) {
HStack {
VStack(alignment: .center) {
Text("\(workoutLog.weight) lbs")
.font(.headline)
Section(header: Text("Weight")) {
HStack {
VStack(alignment: .center) {
Text("\(workoutLog.weight) lbs")
.font(.headline)
}
Spacer()
VStack(alignment: .trailing) {
Stepper("±1", value: $workoutLog.weight, in: 0...1000)
Stepper("±5", value: $workoutLog.weight, in: 0...1000, step: 5)
}
.frame(width: 130)
}
Spacer()
VStack(alignment: .trailing) {
Stepper("±1", value: $workoutLog.weight, in: 0...1000)
Stepper("±5", value: $workoutLog.weight, in: 0...1000, step: 5)
}
.frame(width: 130)
}
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
showingSaveConfirmation = true
}
}
}
.confirmationDialog("Save Options", isPresented: $showingSaveConfirmation) {
Button("Save Workout Log Only") {
try? modelContext.save()
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
showingSaveConfirmation = true
Button("Save Workout Log and Update Split") {
// Save the workout log
try? modelContext.save()
// Update the split with this workout log's data
// Note: Implementation depends on how splits are updated in your app
updateSplit(from: workoutLog)
dismiss()
}
}
}
.confirmationDialog("Save Options", isPresented: $showingSaveConfirmation) {
Button("Save Workout Log Only") {
try? modelContext.save()
dismiss()
}
Button("Save Workout Log and Update Split") {
// Save the workout log
try? modelContext.save()
// Update the split with this workout log's data
// Note: Implementation depends on how splits are updated in your app
updateSplit(from: workoutLog)
dismiss()
}
Button("Cancel", role: .cancel) {
// Do nothing, dialog will dismiss
Button("Cancel", role: .cancel) {
// Do nothing, dialog will dismiss
}
}
}
}

View File

@ -28,50 +28,55 @@ struct WorkoutLogView: View {
var body: some View {
Form {
List {
ForEach (sortedWorkoutLogs) { log in
let badges = log.completed ? [Badge(text: "Completed", color: .green)] : []
ListItem(
title: log.exercise?.name ?? "Untitled Exercise",
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs",
badges: badges
)
.swipeActions(edge: .leading, allowsFullSwipe: false) {
if (log.completed) {
Button {
log.completed = false
try? modelContext.save()
} label: {
Label("Complete", systemImage: "circle.fill")
Section {
Text("Started \(workout.label)")
}
Section {
List {
ForEach (sortedWorkoutLogs) { log in
let badges = log.completed ? [Badge(text: "Completed", color: .green)] : []
ListItem(
title: log.exercise?.name ?? "Untitled Exercise",
subtitle: "\(log.sets) sets × \(log.reps) reps × \(log.weight) lbs",
badges: badges
)
.swipeActions(edge: .leading, allowsFullSwipe: false) {
if (log.completed) {
Button {
log.completed = false
try? modelContext.save()
} label: {
Label("Complete", systemImage: "circle.fill")
}
.tint(.green)
} else {
Button {
log.completed = true
try? modelContext.save()
} label: {
Label("Reset", systemImage: "checkmark.circle.fill")
}
.tint(.green)
}
.tint(.green)
} else {
Button {
log.completed = true
try? modelContext.save()
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
itemToDelete = log
} label: {
Label("Reset", systemImage: "checkmark.circle.fill")
Label("Delete", systemImage: "trash")
}
.tint(.green)
Button {
itemToEdit = log
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
itemToDelete = log
} label: {
Label("Delete", systemImage: "trash")
}
Button {
itemToEdit = log
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
}
}
.navigationTitle("Workout")
.navigationTitle("\(workout.split?.name ?? Split.unnamed)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {

View File

@ -34,7 +34,10 @@ struct WorkoutsView: View {
List {
ForEach (workouts) { workout in
NavigationLink(destination: WorkoutLogView(workout: workout)) {
ListItem(title: workout.label)
ListItem(
title: workout.split?.name ?? Split.unnamed,
subtitle: workout.label
)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
@ -61,9 +64,9 @@ struct WorkoutsView: View {
}
}
}
// .sheet(item: $itemToEdit) { item in
// T.formView(for: item)
// }
.sheet(item: $itemToEdit) { item in
WorkoutEditView(workout: item)
}
.confirmationDialog(
"Delete?",
isPresented: Binding<Bool>(

View File

@ -10,17 +10,51 @@
import SwiftUI
import SwiftData
import CloudKit
import CoreData
@main
struct WorkoutsApp: App {
let container: ModelContainer
let container: ModelContainer
@State private var cloudKitObserver: NSObjectProtocol?
init() {
var shouldCreateDefaults = false
self.container = WorkoutsContainer.create(shouldCreateDefaults: &shouldCreateDefaults)
self.container = WorkoutsContainer.create()
if shouldCreateDefaults {
InitialData.create(modelContext: ModelContext(container))
// Set up CloudKit notification observation
setupCloudKitObservation()
}
private func setupCloudKitObservation() {
// Access the underlying NSPersistentCloudKitContainer
if let persistentContainer = Mirror(reflecting: container).descendant("persistentContainer") as? NSPersistentContainer {
// Register for remote change notifications
cloudKitObserver = NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: persistentContainer,
queue: .main
) { notification in
// Handle the notification
self.handleCloudKitNotification(notification)
}
}
}
private func handleCloudKitNotification(_ notification: Notification) {
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {
return
}
// Log the event for debugging
print("CloudKit event: \(event.type), \(event.succeeded ? "succeeded" : "failed")")
// If the event was a successful import, refresh the UI
if event.type == .import, event.succeeded {
// Create a new context to force UI refresh
let context = ModelContext(container)
// Trigger UI refresh by posting a notification that views can observe
NotificationCenter.default.post(name: .cloudKitDataDidChange, object: nil)
}
}
@ -31,3 +65,8 @@ struct WorkoutsApp: App {
.modelContainer(container)
}
}
// Extension to define the notification name
extension Notification.Name {
static let cloudKitDataDidChange = Notification.Name("cloudKitDataDidChange")
}