Add WatchConnectivity for bidirectional iOS-Watch sync
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
This commit is contained in:
229
Workouts Watch App/Views/WorkoutLogListView.swift
Normal file
229
Workouts Watch App/Views/WorkoutLogListView.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
//
|
||||
// WorkoutLogListView.swift
|
||||
// Workouts Watch App
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct WorkoutLogListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@ObservedObject var workout: Workout
|
||||
|
||||
@State private var showingExercisePicker = false
|
||||
@State private var selectedLog: WorkoutLog?
|
||||
|
||||
var sortedWorkoutLogs: [WorkoutLog] {
|
||||
workout.logsArray
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header: Text(workout.label)) {
|
||||
ForEach(sortedWorkoutLogs, id: \.objectID) { log in
|
||||
Button {
|
||||
selectedLog = log
|
||||
} label: {
|
||||
WorkoutLogRowLabel(log: log)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showingExercisePicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if sortedWorkoutLogs.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Exercises",
|
||||
systemImage: "figure.strengthtraining.traditional",
|
||||
description: Text("Tap + to add exercises.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(workout.split?.name ?? Split.unnamed)
|
||||
.navigationDestination(item: $selectedLog) { log in
|
||||
ExerciseProgressView(workoutLog: log)
|
||||
}
|
||||
.sheet(isPresented: $showingExercisePicker) {
|
||||
ExercisePickerView(workout: workout)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Workout Log Row Label
|
||||
|
||||
struct WorkoutLogRowLabel: View {
|
||||
@ObservedObject var log: WorkoutLog
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
statusIcon
|
||||
.foregroundColor(statusColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(log.exerciseName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var statusIcon: Image {
|
||||
switch log.status {
|
||||
case .completed:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
case .inProgress:
|
||||
Image(systemName: "circle.dotted")
|
||||
case .notStarted:
|
||||
Image(systemName: "circle")
|
||||
case .skipped:
|
||||
Image(systemName: "xmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch log.status {
|
||||
case .completed:
|
||||
.green
|
||||
case .inProgress:
|
||||
.orange
|
||||
case .notStarted:
|
||||
.secondary
|
||||
case .skipped:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if log.loadTypeEnum == .duration {
|
||||
let mins = log.durationMinutes
|
||||
let secs = log.durationSeconds
|
||||
if mins > 0 && secs > 0 {
|
||||
return "\(log.sets) × \(mins)m \(secs)s"
|
||||
} else if mins > 0 {
|
||||
return "\(log.sets) × \(mins) min"
|
||||
} else {
|
||||
return "\(log.sets) × \(secs) sec"
|
||||
}
|
||||
} else {
|
||||
return "\(log.sets) × \(log.reps) × \(log.weight) lbs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exercise Picker View
|
||||
|
||||
struct ExercisePickerView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ObservedObject var workout: Workout
|
||||
|
||||
private var availableExercises: [Exercise] {
|
||||
guard let split = workout.split else { return [] }
|
||||
let existingNames = Set(workout.logsArray.map { $0.exerciseName })
|
||||
return split.exercisesArray.filter { !existingNames.contains($0.name) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if availableExercises.isEmpty {
|
||||
Text("All exercises added")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(availableExercises, id: \.objectID) { exercise in
|
||||
Button {
|
||||
addExercise(exercise)
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
.font(.headline)
|
||||
Text(exerciseSubtitle(exercise))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Exercise")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addExercise(_ exercise: Exercise) {
|
||||
let log = WorkoutLog(context: viewContext)
|
||||
log.exerciseName = exercise.name
|
||||
log.date = Date()
|
||||
log.order = Int32(workout.logsArray.count)
|
||||
log.sets = exercise.sets
|
||||
log.reps = exercise.reps
|
||||
log.weight = exercise.weight
|
||||
log.loadType = exercise.loadType
|
||||
log.duration = exercise.duration
|
||||
log.status = .notStarted
|
||||
log.workout = workout
|
||||
|
||||
// Update workout start if first exercise
|
||||
if workout.logsArray.count == 1 {
|
||||
workout.start = Date()
|
||||
}
|
||||
|
||||
try? viewContext.save()
|
||||
|
||||
// Sync to iOS
|
||||
WatchConnectivityManager.shared.syncToiOS()
|
||||
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func exerciseSubtitle(_ exercise: Exercise) -> String {
|
||||
let loadType = LoadType(rawValue: Int(exercise.loadType)) ?? .weight
|
||||
if loadType == .duration {
|
||||
let mins = exercise.durationMinutes
|
||||
let secs = exercise.durationSeconds
|
||||
if mins > 0 && secs > 0 {
|
||||
return "\(exercise.sets) × \(mins)m \(secs)s"
|
||||
} else if mins > 0 {
|
||||
return "\(exercise.sets) × \(mins) min"
|
||||
} else {
|
||||
return "\(exercise.sets) × \(secs) sec"
|
||||
}
|
||||
} else {
|
||||
return "\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WorkoutLogListView(workout: Workout())
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
|
||||
}
|
||||
Reference in New Issue
Block a user