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:
309
Workouts Watch App/Views/ExerciseProgressView.swift
Normal file
309
Workouts Watch App/Views/ExerciseProgressView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user