Files
workouts/Workouts Watch App/Views/ExerciseProgressView.swift
T
rzen 85d0eaddbb Workouts 2.0: re-base persistence on iCloud Drive documents
Replace Core Data + NSPersistentCloudKitContainer + App-Group store +
WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents
architecture:

- iCloud Drive JSON documents are the sole source of truth (one file per
  aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a
  rebuildable SwiftData cache populated only by an NSMetadataQuery observer
  and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate.
- Shared model layer (ULID, Codable *Documents + stateless mappers, @Model
  cache entities, SwiftData container) compiled into both targets.
- New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone
  is the sole writer of iCloud Drive, the watch round-trips documents.
- AppServices DI + iCloud-required root gate; Swift 6 strict concurrency.
- Starter splits generated on demand from the bundled YAML catalogs.
- Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments
  entitlement (drop CloudKit/App Group/aps-environment).
- Duration stored as Int seconds (was a Date epoch hack); fix workout
  end-on-create, undismissable delete dialog, toolbar-hiding nav stacks,
  and the Settings placeholder.
- Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the
  Scripts/ TestFlight pipeline (release.sh + ASC API scripts).

MARKETING_VERSION 2.0.
2026-06-19 14:25:27 -04:00

308 lines
9.4 KiB
Swift

//
// ExerciseProgressView.swift
// Workouts Watch App
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import WatchKit
struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
/// The shared working workout document owned by the parent. We mutate the
/// matching log in place and ask the parent to forward each change through the
/// bridge driving the UI from this doc (not the cache) avoids losing rapid
/// taps to the read-after-write race.
@Binding var doc: WorkoutDocument
let logID: String
let onChange: () -> Void
@State private var currentPage: Int = 0
@State private var showingCancelConfirm = false
private var log: WorkoutLogDocument? {
doc.logs.first(where: { $0.id == logID })
}
private var totalSets: Int {
max(1, log?.sets ?? 1)
}
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 = log?.currentStateIndex ?? 0
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: log?.reps ?? 0,
isTimeBased: LoadType(rawValue: log?.loadType ?? LoadType.weight.rawValue) == .duration,
durationMinutes: (log?.durationSeconds ?? 0) / 60,
durationSeconds: (log?.durationSeconds ?? 0) % 60
)
} 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)
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
guard clampedProgress != doc.logs[i].currentStateIndex else { return }
doc.logs[i].currentStateIndex = clampedProgress
if clampedProgress >= totalSets {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if clampedProgress > 0 {
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
}
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func completeExercise() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = totalSets
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
recomputeWorkoutStatus()
doc.updatedAt = Date()
onChange()
}
private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
}
}
// 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
private let ticker = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
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 {
elapsedSeconds = 0
WKInterfaceDevice.current().play(.start)
}
.onReceive(ticker) { _ in
elapsedSeconds += 1
checkHapticPing()
}
}
private var formattedTime: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
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)
}
}
}