Implement real-time sync between iOS and Apple Watch apps using WatchConnectivity framework. This replaces reliance on CloudKit which doesn't work reliably in simulators. - Add WatchConnectivityManager to both iOS and Watch targets - Sync workouts, splits, exercises, and logs between devices - Update iOS views to trigger sync on data changes - Add onChange observer to ExerciseView for live progress updates - Configure App Groups for shared container storage - Add Watch app views: WorkoutLogsView, WorkoutLogListView, ExerciseProgressView
190 lines
6.2 KiB
Swift
190 lines
6.2 KiB
Swift
//
|
|
// WorkoutLogsView.swift
|
|
// Workouts
|
|
//
|
|
// Created by rzen on 7/13/25 at 6:52 PM.
|
|
//
|
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import CoreData
|
|
|
|
struct WorkoutLogsView: View {
|
|
@Environment(\.managedObjectContext) private var viewContext
|
|
|
|
@FetchRequest(
|
|
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
|
|
animation: .default
|
|
)
|
|
private var workouts: FetchedResults<Workout>
|
|
|
|
@State private var showingSplitPicker = false
|
|
@State private var showingSettings = false
|
|
@State private var itemToDelete: Workout? = nil
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(workouts, id: \.objectID) { workout in
|
|
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
|
|
CalendarListItem(
|
|
date: workout.start,
|
|
title: workout.split?.name ?? Split.unnamed,
|
|
subtitle: getSubtitle(for: workout),
|
|
subtitle2: workout.statusName
|
|
)
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button {
|
|
itemToDelete = workout
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
.tint(.red)
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if workouts.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Workouts Yet",
|
|
systemImage: "list.bullet.clipboard",
|
|
description: Text("Start a new workout from one of your splits.")
|
|
)
|
|
}
|
|
}
|
|
.navigationTitle("Workout Logs")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button {
|
|
showingSettings = true
|
|
} label: {
|
|
Image(systemName: "gearshape.2")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Start New") {
|
|
showingSplitPicker.toggle()
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingSettings) {
|
|
SettingsView()
|
|
}
|
|
.sheet(isPresented: $showingSplitPicker) {
|
|
SplitPickerSheet()
|
|
}
|
|
.confirmationDialog(
|
|
"Delete Workout?",
|
|
isPresented: Binding<Bool>(
|
|
get: { itemToDelete != nil },
|
|
set: { if !$0 { itemToDelete = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Delete", role: .destructive) {
|
|
if let item = itemToDelete {
|
|
withAnimation {
|
|
viewContext.delete(item)
|
|
try? viewContext.save()
|
|
WatchConnectivityManager.shared.syncAllData()
|
|
itemToDelete = nil
|
|
}
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
itemToDelete = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func getSubtitle(for workout: Workout) -> String {
|
|
if workout.status == .completed, let endDate = workout.end {
|
|
return workout.start.humanTimeInterval(to: endDate)
|
|
} else {
|
|
return workout.start.formattedDate()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Split Picker Sheet
|
|
|
|
struct SplitPickerSheet: View {
|
|
@Environment(\.managedObjectContext) private var viewContext
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@FetchRequest(
|
|
sortDescriptors: [
|
|
NSSortDescriptor(keyPath: \Split.order, ascending: true),
|
|
NSSortDescriptor(keyPath: \Split.name, ascending: true)
|
|
],
|
|
animation: .default
|
|
)
|
|
private var splits: FetchedResults<Split>
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(splits, id: \.objectID) { split in
|
|
Button {
|
|
startWorkout(with: split)
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: split.systemImage)
|
|
.foregroundColor(Color.color(from: split.color))
|
|
Text(split.name)
|
|
Spacer()
|
|
Text("\(split.exercisesArray.count) exercises")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Select a Split")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startWorkout(with split: Split) {
|
|
let workout = Workout(context: viewContext)
|
|
workout.start = Date()
|
|
workout.status = .notStarted
|
|
workout.split = split
|
|
|
|
for exercise in split.exercisesArray {
|
|
let workoutLog = WorkoutLog(context: viewContext)
|
|
workoutLog.exerciseName = exercise.name
|
|
workoutLog.date = Date()
|
|
workoutLog.order = exercise.order
|
|
workoutLog.sets = exercise.sets
|
|
workoutLog.reps = exercise.reps
|
|
workoutLog.weight = exercise.weight
|
|
workoutLog.loadType = exercise.loadType
|
|
workoutLog.duration = exercise.duration
|
|
workoutLog.status = .notStarted
|
|
workoutLog.workout = workout
|
|
}
|
|
|
|
try? viewContext.save()
|
|
|
|
// Sync to Watch
|
|
WatchConnectivityManager.shared.syncAllData()
|
|
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
WorkoutLogsView()
|
|
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
|
}
|