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:
2026-01-19 19:15:38 -05:00
parent 8b6250e4d6
commit 9a881e841b
21 changed files with 1581 additions and 67 deletions

View File

@@ -0,0 +1,309 @@
//
// ExerciseProgressView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import WatchKit
struct ExerciseProgressView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dismiss) private var dismiss
@ObservedObject var workoutLog: WorkoutLog
@State private var currentPage: Int = 0
@State private var showingCancelConfirm = false
private var totalSets: Int {
max(1, Int(workoutLog.sets))
}
private var totalPages: Int {
// Set 1, Rest 1, Set 2, Rest 2, ..., Set N, Done
// = N sets + (N-1) rests + 1 done = 2N
totalSets * 2
}
private var firstUnfinishedSetPage: Int {
// currentStateIndex is the number of completed sets
let completedSets = Int(workoutLog.currentStateIndex)
if completedSets >= totalSets {
// All done, go to done page
return totalPages - 1
}
// Each set is at page index * 2 (Set1=0, Set2=2, Set3=4...)
return completedSets * 2
}
var body: some View {
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
pageView(for: index)
.tag(index)
}
}
.tabViewStyle(.page)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
showingCancelConfirm = true
} label: {
Image(systemName: "xmark")
}
}
}
.confirmationDialog("Cancel Exercise?", isPresented: $showingCancelConfirm, titleVisibility: .visible) {
Button("Cancel Exercise", role: .destructive) {
dismiss()
}
Button("Continue", role: .cancel) { }
}
.onAppear {
// Skip to first unfinished set
currentPage = firstUnfinishedSetPage
}
.onChange(of: currentPage) { _, newPage in
updateProgress(for: newPage)
}
}
@ViewBuilder
private func pageView(for index: Int) -> some View {
let lastPageIndex = totalPages - 1
if index == lastPageIndex {
// Done page
DonePageView {
completeExercise()
dismiss()
}
} else if index % 2 == 0 {
// Set page (0, 2, 4, ...)
let setNumber = (index / 2) + 1
SetPageView(
setNumber: setNumber,
totalSets: totalSets,
reps: Int(workoutLog.reps),
isTimeBased: workoutLog.loadTypeEnum == .duration,
durationMinutes: workoutLog.durationMinutes,
durationSeconds: workoutLog.durationSeconds
)
} else {
// Rest page (1, 3, 5, ...)
let restNumber = (index / 2) + 1
RestPageView(restNumber: restNumber)
}
}
private func updateProgress(for pageIndex: Int) {
// Calculate which set we're on based on page index
// Pages: Set1(0), Rest1(1), Set2(2), Rest2(3), Set3(4), Done(5)
// After completing Set 1 and moving to Rest 1, progress should be 1
let setIndex = (pageIndex + 1) / 2
let clampedProgress = min(setIndex, totalSets)
if clampedProgress != Int(workoutLog.currentStateIndex) {
workoutLog.currentStateIndex = Int32(clampedProgress)
if clampedProgress >= totalSets {
workoutLog.status = .completed
workoutLog.completed = true
} else if clampedProgress > 0 {
workoutLog.status = .inProgress
workoutLog.completed = false
}
updateWorkoutStatus()
try? viewContext.save()
// Sync to iOS
WatchConnectivityManager.shared.syncToiOS()
}
}
private func completeExercise() {
workoutLog.currentStateIndex = Int32(totalSets)
workoutLog.status = .completed
workoutLog.completed = true
updateWorkoutStatus()
try? viewContext.save()
// Sync to iOS
WatchConnectivityManager.shared.syncToiOS()
}
private func updateWorkoutStatus() {
guard let workout = workoutLog.workout else { return }
let logs = workout.logsArray
let allCompleted = logs.allSatisfy { $0.status == .completed }
let anyInProgress = logs.contains { $0.status == .inProgress }
let allNotStarted = logs.allSatisfy { $0.status == .notStarted }
if allCompleted {
workout.status = .completed
workout.end = Date()
} else if anyInProgress || !allNotStarted {
workout.status = .inProgress
} else {
workout.status = .notStarted
}
}
}
// MARK: - Set Page View
struct SetPageView: View {
let setNumber: Int
let totalSets: Int
let reps: Int
let isTimeBased: Bool
let durationMinutes: Int
let durationSeconds: Int
var body: some View {
VStack(spacing: 8) {
Text("Set \(setNumber) of \(totalSets)")
.font(.headline)
.foregroundColor(.secondary)
Text("\(setNumber)")
.font(.system(size: 72, weight: .bold, design: .rounded))
.foregroundColor(.green)
if isTimeBased {
Text(formattedDuration)
.font(.title3)
.foregroundColor(.secondary)
} else {
Text("\(reps) reps")
.font(.title3)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
WKInterfaceDevice.current().play(.start)
}
}
private var formattedDuration: String {
if durationMinutes > 0 && durationSeconds > 0 {
return "\(durationMinutes)m \(durationSeconds)s"
} else if durationMinutes > 0 {
return "\(durationMinutes) min"
} else {
return "\(durationSeconds) sec"
}
}
}
// MARK: - Rest Page View
struct RestPageView: View {
let restNumber: Int
@State private var elapsedSeconds: Int = 0
@State private var timer: Timer?
var body: some View {
VStack(spacing: 8) {
Text("Rest")
.font(.headline)
.foregroundColor(.secondary)
Text(formattedTime)
.font(.system(size: 56, weight: .bold, design: .monospaced))
.foregroundColor(.orange)
Text("Swipe to continue")
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
startTimer()
WKInterfaceDevice.current().play(.start)
}
.onDisappear {
stopTimer()
}
}
private var formattedTime: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
private func startTimer() {
elapsedSeconds = 0
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
elapsedSeconds += 1
checkHapticPing()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func checkHapticPing() {
// Haptic ping every 10 seconds with pattern:
// 10s: single, 20s: double, 30s: triple, 40s: single, 50s: double, etc.
guard elapsedSeconds > 0 && elapsedSeconds % 10 == 0 else { return }
let cyclePosition = (elapsedSeconds / 10) % 3
let pingCount: Int
switch cyclePosition {
case 1: pingCount = 1 // 10s, 40s, 70s...
case 2: pingCount = 2 // 20s, 50s, 80s...
case 0: pingCount = 3 // 30s, 60s, 90s...
default: pingCount = 1
}
playHapticPings(count: pingCount)
}
private func playHapticPings(count: Int) {
for i in 0..<count {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.2) {
WKInterfaceDevice.current().play(.click)
}
}
}
}
// MARK: - Done Page View
struct DonePageView: View {
let onDone: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.green)
Text("Done!")
.font(.title2)
.fontWeight(.bold)
Text("Tap to finish")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture {
WKInterfaceDevice.current().play(.success)
onDone()
}
.onAppear {
WKInterfaceDevice.current().play(.success)
}
}
}

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

View File

@@ -0,0 +1,99 @@
//
// WorkoutLogsView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import CoreData
struct WorkoutLogsView: View {
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var connectivityManager: WatchConnectivityManager
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Workout.start, ascending: false)],
animation: .default
)
private var workouts: FetchedResults<Workout>
var body: some View {
NavigationStack {
List {
ForEach(workouts, id: \.objectID) { workout in
NavigationLink(destination: WorkoutLogListView(workout: workout)) {
WorkoutRow(workout: workout)
}
}
}
.overlay {
if workouts.isEmpty {
ContentUnavailableView(
"No Workouts",
systemImage: "list.bullet.clipboard",
description: Text("Tap sync or start a workout from iPhone.")
)
}
}
.navigationTitle("Workouts")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
connectivityManager.requestSync()
} label: {
Image(systemName: "arrow.triangle.2.circlepath")
}
}
}
}
}
}
// MARK: - Workout Row
struct WorkoutRow: View {
@ObservedObject var workout: Workout
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(workout.split?.name ?? Split.unnamed)
.font(.headline)
.lineLimit(1)
HStack {
Text(workout.start.formatDate())
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
statusIndicator
}
}
.padding(.vertical, 4)
}
@ViewBuilder
private var statusIndicator: some View {
switch workout.status {
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .inProgress:
Image(systemName: "circle.dotted")
.foregroundColor(.orange)
case .notStarted:
Image(systemName: "circle")
.foregroundColor(.secondary)
case .skipped:
Image(systemName: "xmark.circle")
.foregroundColor(.secondary)
}
}
}
#Preview {
WorkoutLogsView()
.environment(\.managedObjectContext, PersistenceController.preview.viewContext)
}