Rework the Apple Watch progress flow
Watch root lists every in-progress workout; picking an exercise runs a paged Ready -> work/rest -> Finish flow (One More + auto-firing Done), with a phase-dot row and brand-tinted count-up/down timers. Includes the configurable rest and auto-finish settings synced over WatchConnectivity and the wrist-down timer fix.
This commit is contained in:
@@ -10,17 +10,19 @@ enum WCPayload {
|
|||||||
static let workoutsKey = "workouts"
|
static let workoutsKey = "workouts"
|
||||||
static let workoutKey = "workout"
|
static let workoutKey = "workout"
|
||||||
static let restSecondsKey = "restSeconds"
|
static let restSecondsKey = "restSeconds"
|
||||||
|
static let doneCountdownSecondsKey = "doneCountdownSeconds"
|
||||||
|
|
||||||
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
||||||
static let requestSyncType = "requestSync" // watch → phone (please push state)
|
static let requestSyncType = "requestSync" // watch → phone (please push state)
|
||||||
|
|
||||||
// MARK: - Phone → Watch (application context: latest-state-wins)
|
// MARK: - Phone → Watch (application context: latest-state-wins)
|
||||||
|
|
||||||
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int) -> [String: Any] {
|
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int) -> [String: Any] {
|
||||||
var dict: [String: Any] = [:]
|
var dict: [String: Any] = [:]
|
||||||
if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s }
|
if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s }
|
||||||
if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w }
|
if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w }
|
||||||
dict[restSecondsKey] = restSeconds
|
dict[restSecondsKey] = restSeconds
|
||||||
|
dict[doneCountdownSecondsKey] = doneCountdownSeconds
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ enum WCPayload {
|
|||||||
|
|
||||||
static func decodeRestSeconds(_ dict: [String: Any]) -> Int? { dict[restSecondsKey] as? Int }
|
static func decodeRestSeconds(_ dict: [String: Any]) -> Int? { dict[restSecondsKey] as? Int }
|
||||||
|
|
||||||
|
static func decodeDoneCountdownSeconds(_ dict: [String: Any]) -> Int? { dict[doneCountdownSecondsKey] as? Int }
|
||||||
|
|
||||||
// MARK: - Watch → Phone (a single updated workout)
|
// MARK: - Watch → Phone (a single updated workout)
|
||||||
|
|
||||||
static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] {
|
static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] {
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ extension Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Brand purple from the app logo (a touch brighter than the icon's `#7023BC`
|
||||||
|
/// so it stays legible on the watch's black background). Used for the work phase —
|
||||||
|
/// its count-up timer and progress dots.
|
||||||
|
static let workTint = Color(red: 0.51, green: 0.22, blue: 0.84)
|
||||||
|
|
||||||
|
/// Light teal counterpart for the rest phase — its count-down timer and dots.
|
||||||
|
static let restTint = Color(red: 0.44, green: 0.85, blue: 0.84)
|
||||||
|
|
||||||
/// Returns a darker shade by reducing HSB brightness (not opacity).
|
/// Returns a darker shade by reducing HSB brightness (not opacity).
|
||||||
func darker(by percentage: CGFloat = 0.2) -> Color {
|
func darker(by percentage: CGFloat = 0.2) -> Color {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
// Apply whatever the phone last pushed, then ask for a fresh push.
|
// Apply whatever the phone last pushed, then ask for a fresh push.
|
||||||
applyState(WCPayload.decodeSplits(session.receivedApplicationContext),
|
applyState(WCPayload.decodeSplits(session.receivedApplicationContext),
|
||||||
workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext))
|
workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext))
|
||||||
applyRestSeconds(session.receivedApplicationContext)
|
applySettings(session.receivedApplicationContext)
|
||||||
requestSync()
|
requestSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +63,13 @@ final class WatchConnectivityBridge: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyRestSeconds(_ dict: [String: Any]) {
|
private func applySettings(_ dict: [String: Any]) {
|
||||||
if let rest = WCPayload.decodeRestSeconds(dict) {
|
if let rest = WCPayload.decodeRestSeconds(dict) {
|
||||||
UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey)
|
UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey)
|
||||||
}
|
}
|
||||||
|
if let done = WCPayload.decodeDoneCountdownSeconds(dict) {
|
||||||
|
UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
|
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
|
||||||
@@ -101,9 +104,11 @@ extension WatchConnectivityBridge: WCSessionDelegate {
|
|||||||
let splits = WCPayload.decodeSplits(applicationContext)
|
let splits = WCPayload.decodeSplits(applicationContext)
|
||||||
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
let workouts = WCPayload.decodeWorkouts(applicationContext)
|
||||||
let rest = WCPayload.decodeRestSeconds(applicationContext)
|
let rest = WCPayload.decodeRestSeconds(applicationContext)
|
||||||
|
let done = WCPayload.decodeDoneCountdownSeconds(applicationContext)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.applyState(splits, workouts: workouts)
|
self.applyState(splits, workouts: workouts)
|
||||||
if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
|
if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
|
||||||
|
if let done { UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,37 +8,57 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// Root of the watch app. The watch only runs the workout that's currently active
|
/// Root of the watch app. The watch only runs the workouts that are currently
|
||||||
/// on the phone — there's no history browsing here. The "active" workout is the most
|
/// active on the phone — there's no history browsing here. An "active" workout is a
|
||||||
/// recent cached one that isn't finished (`notStarted` or `inProgress`); a workout is
|
/// cached one that isn't finished (`notStarted` or `inProgress`); a workout is
|
||||||
/// created on the phone as `notStarted` the moment a split is picked, and flips to
|
/// created on the phone as `notStarted` the moment a split is picked, and flips to
|
||||||
/// `completed` once every exercise is done, at which point we fall back to the gate.
|
/// `completed` once every exercise is done, at which point it drops off this list.
|
||||||
|
///
|
||||||
|
/// The root shows every active workout (most-recent first); picking one opens its
|
||||||
|
/// exercise list.
|
||||||
struct ActiveWorkoutGateView: View {
|
struct ActiveWorkoutGateView: View {
|
||||||
@Environment(WatchConnectivityBridge.self) private var bridge
|
@Environment(WatchConnectivityBridge.self) private var bridge
|
||||||
@Environment(WorkoutSessionManager.self) private var sessionManager
|
@Environment(WorkoutSessionManager.self) private var sessionManager
|
||||||
|
|
||||||
@Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout]
|
@Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout]
|
||||||
|
@Query private var splits: [Split]
|
||||||
|
|
||||||
private var activeWorkout: Workout? {
|
private var activeWorkouts: [Workout] {
|
||||||
workouts.first { $0.status == .inProgress || $0.status == .notStarted }
|
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func split(for workout: Workout) -> Split? {
|
||||||
|
guard let id = workout.splitID else { return nil }
|
||||||
|
return splits.first { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if let workout = activeWorkout {
|
Group {
|
||||||
WorkoutLogListView(workout: workout)
|
if activeWorkouts.isEmpty {
|
||||||
} else {
|
|
||||||
emptyState
|
emptyState
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(activeWorkouts) { workout in
|
||||||
|
NavigationLink {
|
||||||
|
WorkoutLogListView(workout: workout)
|
||||||
|
} label: {
|
||||||
|
ActiveWorkoutRow(workout: workout, split: split(for: workout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("In Progress")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
// Nothing to run yet — pull fresh state in case the phone just started one.
|
// Nothing to run yet — pull fresh state in case the phone just started one.
|
||||||
if activeWorkout == nil { bridge.requestSync() }
|
if activeWorkouts.isEmpty { bridge.requestSync() }
|
||||||
}
|
}
|
||||||
.onChange(of: activeWorkout == nil) { _, noActiveWorkout in
|
.onChange(of: activeWorkouts.isEmpty) { _, noActiveWorkouts in
|
||||||
// The workout finished (or was cleared) — release the HealthKit session that
|
// Everything finished (or was cleared) — release the HealthKit session that
|
||||||
// was keeping the launched app alive.
|
// was keeping the launched app alive.
|
||||||
if noActiveWorkout { sessionManager.end() }
|
if noActiveWorkouts { sessionManager.end() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,3 +76,38 @@ struct ActiveWorkoutGateView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Active Workout Row
|
||||||
|
|
||||||
|
private struct ActiveWorkoutRow: View {
|
||||||
|
let workout: Workout
|
||||||
|
let split: Split?
|
||||||
|
|
||||||
|
private var logs: [WorkoutLog] { workout.logsArray }
|
||||||
|
private var doneCount: Int { logs.filter { $0.status == .completed }.count }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: split?.systemImage ?? "figure.strengthtraining.traditional")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(split.map { Color.color(from: $0.color) } ?? .workTint)
|
||||||
|
.frame(width: 26)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(workout.splitName ?? Split.unnamed)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var subtitle: String {
|
||||||
|
guard !logs.isEmpty else { return workout.start.formattedDate() }
|
||||||
|
if doneCount == 0 { return "Not started · \(logs.count) exercises" }
|
||||||
|
return "\(doneCount) of \(logs.count) done"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,16 +8,22 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WatchKit
|
import WatchKit
|
||||||
|
|
||||||
/// Runs a single exercise as a horizontally-paged HIIT cycle:
|
/// Runs a single exercise as a horizontally-paged flow:
|
||||||
///
|
///
|
||||||
/// Work₁ → Rest₁ → Work₂ → Rest₂ → … → Rest₍ₙ₋₁₎ → Workₙ
|
/// [Ready] → Work₁ → Rest₁ → Work₂ → … → Workₙ → Finish
|
||||||
///
|
///
|
||||||
/// The **work** phase counts *up* (a stopwatch for the current set); the user swipes
|
/// A **Ready?** page leads in *only* when the exercise hasn't been started yet (a
|
||||||
/// left when they're done. The **rest** phase counts *down* from the configurable rest
|
/// resumed exercise jumps straight to its first unfinished set). The **work** phase
|
||||||
/// time, beeps once per second in the final three seconds, and then auto-advances to
|
/// counts *up* (a stopwatch for the current set, tinted with the brand purple); the
|
||||||
/// the next work phase. The final work phase has no rest after it — instead it offers
|
/// user swipes left when they're done. The **rest** phase counts *down* from the
|
||||||
/// **One More** (append a bonus set and keep going) and **Done** (mark the exercise
|
/// configurable rest time (light teal), beeps once per second in the final three
|
||||||
/// complete and return to the list).
|
/// seconds, then auto-advances to the next work phase. Every work phase — including
|
||||||
|
/// the last — looks the same; sliding past the final set reaches a **Finish** page
|
||||||
|
/// offering **One More** (append a bonus set and keep going) and **Done** (which also
|
||||||
|
/// auto-fires after a configurable countdown, completing the exercise).
|
||||||
|
///
|
||||||
|
/// A row of phase dots tracks progress: purple for work, teal for rest, with the
|
||||||
|
/// current phase drawn as a wider dash.
|
||||||
struct ExerciseProgressView: View {
|
struct ExerciseProgressView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@@ -35,8 +41,13 @@ struct ExerciseProgressView: View {
|
|||||||
@State private var showingCancelConfirm = false
|
@State private var showingCancelConfirm = false
|
||||||
@State private var didRestorePage = false
|
@State private var didRestorePage = false
|
||||||
|
|
||||||
/// Forces the starting page (used only by the DEBUG screenshot host to land on a
|
/// True when this run opens on the lead-in **Ready?** page (the exercise hadn't
|
||||||
/// rest page). Always nil in normal use.
|
/// been started). A resumed exercise — or the screenshot host — skips it. Constant
|
||||||
|
/// for the lifetime of the view, so all the page-index math below is stable.
|
||||||
|
private let showsReady: Bool
|
||||||
|
|
||||||
|
/// Forces the starting page (used only by the DEBUG screenshot host). When set it
|
||||||
|
/// also suppresses the Ready page so the index is a plain work/rest cycle offset.
|
||||||
private let debugInitialPage: Int?
|
private let debugInitialPage: Int?
|
||||||
|
|
||||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, debugInitialPage: Int? = nil) {
|
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, debugInitialPage: Int? = nil) {
|
||||||
@@ -48,23 +59,45 @@ struct ExerciseProgressView: View {
|
|||||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||||
let sets = max(1, log?.sets ?? 1)
|
let sets = max(1, log?.sets ?? 1)
|
||||||
_setCount = State(initialValue: sets)
|
_setCount = State(initialValue: sets)
|
||||||
|
|
||||||
|
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
||||||
|
let ready = debugInitialPage == nil && notStarted
|
||||||
|
self.showsReady = ready
|
||||||
|
|
||||||
|
let base = ready ? 1 : 0
|
||||||
// Resume on the first unfinished set's work page (clamped to the last set).
|
// Resume on the first unfinished set's work page (clamped to the last set).
|
||||||
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
|
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
|
||||||
_currentPage = State(initialValue: debugInitialPage ?? (completed * 2))
|
let resume = ready ? 0 : base + completed * 2
|
||||||
|
_currentPage = State(initialValue: debugInitialPage ?? resume)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var log: WorkoutLogDocument? {
|
private var log: WorkoutLogDocument? {
|
||||||
doc.logs.first { $0.id == logID }
|
doc.logs.first { $0.id == logID }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Work₁, Rest₁, …, Workₙ ⇒ N sets + (N−1) rests = 2N − 1 pages.
|
/// Offset of the work/rest cycle: `1` when a Ready page leads, else `0`.
|
||||||
private var totalPages: Int { setCount * 2 - 1 }
|
private var base: Int { showsReady ? 1 : 0 }
|
||||||
|
|
||||||
/// The first unfinished set's work page (clamped to the last set). Resuming an
|
/// Work/rest pages: Work₁, Rest₁, …, Workₙ ⇒ N sets + (N−1) rests = 2N − 1.
|
||||||
/// exercise opens here, skipping any completed work/rest pairs.
|
private var cycleCount: Int { setCount * 2 - 1 }
|
||||||
|
|
||||||
|
/// Ready (`base`) + cycle (`2N − 1`) + Finish (`1`).
|
||||||
|
private var totalPages: Int { base + cycleCount + 1 }
|
||||||
|
|
||||||
|
/// The single Finish page sits just past the last work page.
|
||||||
|
private var finishIndex: Int { base + cycleCount }
|
||||||
|
|
||||||
|
/// The first unfinished set's work page (only used when resuming, so `base == 0`).
|
||||||
private var resumePage: Int {
|
private var resumePage: Int {
|
||||||
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
let completed = min(max(0, log?.currentStateIndex ?? 0), setCount - 1)
|
||||||
return completed * 2
|
return base + completed * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position within the work/rest cycle for the current page — `nil` on the Ready
|
||||||
|
/// and Finish pages (which show no dots).
|
||||||
|
private var currentCycleIndex: Int? {
|
||||||
|
let c = currentPage - base
|
||||||
|
return (0..<cycleCount).contains(c) ? c : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var detail: String {
|
private var detail: String {
|
||||||
@@ -75,6 +108,12 @@ struct ExerciseProgressView: View {
|
|||||||
return "\(log.reps) reps"
|
return "\(log.reps) reps"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-line plan summary for the Ready page, e.g. "4 sets × 8 reps".
|
||||||
|
private var readySummary: String {
|
||||||
|
let setsText = "\(setCount) set\(setCount == 1 ? "" : "s")"
|
||||||
|
return detail.isEmpty ? setsText : "\(setsText) × \(detail)"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $currentPage) {
|
TabView(selection: $currentPage) {
|
||||||
ForEach(0..<totalPages, id: \.self) { index in
|
ForEach(0..<totalPages, id: \.self) { index in
|
||||||
@@ -82,7 +121,13 @@ struct ExerciseProgressView: View {
|
|||||||
.tag(index)
|
.tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page)
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if let cycleIndex = currentCycleIndex {
|
||||||
|
PhaseProgressDots(count: cycleCount, currentIndex: cycleIndex)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button {
|
Button {
|
||||||
@@ -101,11 +146,11 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Jump to the first unfinished set. A paged TabView can settle on page 0 on
|
// Jump to the first unfinished set. A paged TabView can settle on page 0 on
|
||||||
// first layout, so re-assert once more after this run loop. (The screenshot
|
// first layout, so re-assert once more after this run loop. (The Ready page
|
||||||
// host pins an explicit page, so skip the resume jump there.)
|
// and the screenshot host both pin an explicit page, so skip the jump there.)
|
||||||
guard !didRestorePage else { return }
|
guard !didRestorePage else { return }
|
||||||
didRestorePage = true
|
didRestorePage = true
|
||||||
if debugInitialPage == nil {
|
if !showsReady && debugInitialPage == nil {
|
||||||
jumpToResumePage()
|
jumpToResumePage()
|
||||||
Task { @MainActor in jumpToResumePage() }
|
Task { @MainActor in jumpToResumePage() }
|
||||||
}
|
}
|
||||||
@@ -125,16 +170,24 @@ struct ExerciseProgressView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func page(for index: Int) -> some View {
|
private func page(for index: Int) -> some View {
|
||||||
let isActive = index == currentPage
|
let isActive = index == currentPage
|
||||||
if index.isMultiple(of: 2) {
|
if showsReady && index == 0 {
|
||||||
// Work phase. The last page (set N) finishes the exercise.
|
ReadyPhaseView(summary: readySummary, onStart: start)
|
||||||
|
} else {
|
||||||
|
let cycleIndex = index - base
|
||||||
|
if cycleIndex == cycleCount {
|
||||||
|
// Finish page — confirm Done (auto-fires) or add One More.
|
||||||
|
FinishPhaseView(
|
||||||
|
isActive: isActive,
|
||||||
|
onDone: { completeExercise(); dismiss() },
|
||||||
|
onOneMore: addSet
|
||||||
|
)
|
||||||
|
} else if cycleIndex.isMultiple(of: 2) {
|
||||||
|
// Work phase.
|
||||||
WorkPhaseView(
|
WorkPhaseView(
|
||||||
setNumber: index / 2 + 1,
|
setNumber: cycleIndex / 2 + 1,
|
||||||
totalSets: setCount,
|
totalSets: setCount,
|
||||||
detail: detail,
|
detail: detail,
|
||||||
isLast: index == totalPages - 1,
|
isActive: isActive
|
||||||
isActive: isActive,
|
|
||||||
onOneMore: addSet,
|
|
||||||
onDone: { completeExercise(); dismiss() }
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Rest phase. Auto-advances to the next work page when the timer hits zero.
|
// Rest phase. Auto-advances to the next work page when the timer hits zero.
|
||||||
@@ -143,9 +196,16 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Mutations
|
// MARK: - Mutations
|
||||||
|
|
||||||
|
/// Leave the Ready page for the first work phase, marking the exercise started.
|
||||||
|
private func start() {
|
||||||
|
beginExercise()
|
||||||
|
withAnimation { currentPage = base }
|
||||||
|
}
|
||||||
|
|
||||||
/// Programmatically move one page right (used by the rest auto-advance), guarding
|
/// Programmatically move one page right (used by the rest auto-advance), guarding
|
||||||
/// against overrun if the user swiped away in the meantime.
|
/// against overrun if the user swiped away in the meantime.
|
||||||
private func advance(from index: Int) {
|
private func advance(from index: Int) {
|
||||||
@@ -153,15 +213,30 @@ struct ExerciseProgressView: View {
|
|||||||
currentPage = index + 1
|
currentPage = index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a bonus set: grow the plan, record the just-finished set as done, and
|
/// Flip a not-yet-started exercise to in-progress the moment the user taps Start.
|
||||||
/// slide into the rest period that now follows the previously-final work page.
|
private func beginExercise() {
|
||||||
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
|
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return }
|
||||||
|
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||||
|
recomputeWorkoutStatus()
|
||||||
|
doc.updatedAt = Date()
|
||||||
|
onChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a bonus set from the Finish page: mark every prior set done, grow the
|
||||||
|
/// plan, and slide forward into the bonus set's work phase.
|
||||||
|
///
|
||||||
|
/// Growing `setCount` and advancing `currentPage` in one animated transaction makes
|
||||||
|
/// the new work phase slide in from the right. (We land on work, not a rest, because
|
||||||
|
/// the Finish page's own auto-finish countdown already served as the between-set
|
||||||
|
/// breather — and the rest page would otherwise sit at the very index the Finish
|
||||||
|
/// page occupied, which a paged TabView can't animate to.)
|
||||||
private func addSet() {
|
private func addSet() {
|
||||||
let restPage = currentPage + 1 // the rest page that becomes valid after the bump
|
let newCount = setCount + 1
|
||||||
setCount += 1
|
|
||||||
|
|
||||||
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
||||||
doc.logs[i].sets = setCount
|
doc.logs[i].sets = newCount
|
||||||
doc.logs[i].currentStateIndex = setCount - 1 // the old final set is complete
|
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
|
||||||
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||||
doc.logs[i].completed = false
|
doc.logs[i].completed = false
|
||||||
recomputeWorkoutStatus()
|
recomputeWorkoutStatus()
|
||||||
@@ -169,29 +244,29 @@ struct ExerciseProgressView: View {
|
|||||||
onChange()
|
onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
withAnimation { currentPage = restPage }
|
withAnimation {
|
||||||
|
setCount = newCount
|
||||||
|
currentPage += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Page index → completed-set count: Work₁(0)→0, Rest₁(1)→1, Work₂(2)→1, … —
|
/// Map a page to completed-set count and record forward progress.
|
||||||
/// i.e. `(pageIndex + 1) / 2`. Reaching set N as *completed* only happens via Done.
|
|
||||||
///
|
///
|
||||||
/// Progress is **monotonic**: completing a work phase advances the count (and
|
/// Paging tops out at `setCount − 1` completed sets — the final set is marked done
|
||||||
/// forwards it to the phone), but swiping back — or a transient TabView snap to
|
/// only by an explicit **Done** (`completeExercise`). Progress is **monotonic**:
|
||||||
/// page 0 — never un-counts a finished set.
|
/// completing a work phase advances the count (and forwards it to the phone), but
|
||||||
|
/// swiping back — or a transient TabView snap to page 0 — never un-counts a set.
|
||||||
private func recordProgress(for pageIndex: Int) {
|
private func recordProgress(for pageIndex: Int) {
|
||||||
let reached = min((pageIndex + 1) / 2, setCount)
|
if showsReady && pageIndex == 0 { return } // Ready page records nothing
|
||||||
|
let cycleIndex = pageIndex - base
|
||||||
|
let reached = min(max(0, (cycleIndex + 1) / 2), setCount - 1)
|
||||||
|
|
||||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||||
guard reached > doc.logs[i].currentStateIndex else { return }
|
guard reached > doc.logs[i].currentStateIndex else { return }
|
||||||
|
|
||||||
doc.logs[i].currentStateIndex = reached
|
doc.logs[i].currentStateIndex = reached
|
||||||
if reached >= setCount {
|
|
||||||
doc.logs[i].status = WorkoutStatus.completed.rawValue
|
|
||||||
doc.logs[i].completed = true
|
|
||||||
} else {
|
|
||||||
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||||
doc.logs[i].completed = false
|
doc.logs[i].completed = false
|
||||||
}
|
|
||||||
|
|
||||||
recomputeWorkoutStatus()
|
recomputeWorkoutStatus()
|
||||||
doc.updatedAt = Date()
|
doc.updatedAt = Date()
|
||||||
@@ -238,16 +313,66 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Phase Progress Dots
|
||||||
|
|
||||||
|
/// Compact progress row for the work/rest cycle: purple dots for work, teal for rest,
|
||||||
|
/// with the active phase drawn as a wider dash. Dots are deliberately a touch thick so
|
||||||
|
/// the count of remaining sets reads at a glance.
|
||||||
|
private struct PhaseProgressDots: View {
|
||||||
|
let count: Int
|
||||||
|
let currentIndex: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(0..<count, id: \.self) { i in
|
||||||
|
let isWork = i.isMultiple(of: 2)
|
||||||
|
let isCurrent = i == currentIndex
|
||||||
|
Capsule()
|
||||||
|
.fill(isWork ? Color.workTint : Color.restTint)
|
||||||
|
.frame(width: isCurrent ? 12 : 6, height: 6)
|
||||||
|
.opacity(isCurrent ? 1 : 0.45)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ready Phase
|
||||||
|
|
||||||
|
private struct ReadyPhaseView: View {
|
||||||
|
let summary: String
|
||||||
|
let onStart: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Text("Ready?")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
|
||||||
|
if !summary.isEmpty {
|
||||||
|
Text(summary)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onStart) {
|
||||||
|
Label("Start", systemImage: "play.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.workTint)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Work Phase
|
// MARK: - Work Phase
|
||||||
|
|
||||||
private struct WorkPhaseView: View {
|
private struct WorkPhaseView: View {
|
||||||
let setNumber: Int
|
let setNumber: Int
|
||||||
let totalSets: Int
|
let totalSets: Int
|
||||||
let detail: String
|
let detail: String
|
||||||
let isLast: Bool
|
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let onOneMore: () -> Void
|
|
||||||
let onDone: () -> Void
|
|
||||||
|
|
||||||
/// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed
|
/// Wall-clock anchor for the count-up stopwatch. Driving the display from a fixed
|
||||||
/// start date (rendered by SwiftUI's timer text) instead of incrementing a counter
|
/// start date (rendered by SwiftUI's timer text) instead of incrementing a counter
|
||||||
@@ -257,39 +382,19 @@ private struct WorkPhaseView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Text("Set \(setNumber) of \(totalSets)")
|
Text("\(setNumber) of \(totalSets)")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
Text(startDate, style: .timer)
|
Text(startDate, style: .timer)
|
||||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(Color.workTint)
|
||||||
|
|
||||||
|
if !detail.isEmpty {
|
||||||
Text(detail)
|
Text(detail)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
if isLast {
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Button(action: onOneMore) {
|
|
||||||
Label("One More", systemImage: "plus")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.tint(.blue)
|
|
||||||
|
|
||||||
Button(action: onDone) {
|
|
||||||
Label("Done", systemImage: "checkmark")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.tint(.green)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.padding(.top, 4)
|
|
||||||
} else {
|
|
||||||
Label("Swipe to rest", systemImage: "chevron.right")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -335,11 +440,7 @@ private struct RestPhaseView: View {
|
|||||||
Text(timerInterval: startDate...endDate, countsDown: true)
|
Text(timerInterval: startDate...endDate, countsDown: true)
|
||||||
.font(.system(size: 54, weight: .bold, design: .rounded))
|
.font(.system(size: 54, weight: .bold, design: .rounded))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(Color.restTint)
|
||||||
|
|
||||||
Label("Swipe to skip", systemImage: "chevron.right")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.onAppear { if isActive { start() } }
|
.onAppear { if isActive { start() } }
|
||||||
@@ -374,3 +475,71 @@ private struct RestPhaseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Finish Phase
|
||||||
|
|
||||||
|
/// Terminal page after the last set. **Done** completes the exercise and dismisses —
|
||||||
|
/// and fires automatically after a configurable countdown so the user doesn't have to
|
||||||
|
/// tap with sweaty hands. **One More** appends a bonus set instead.
|
||||||
|
private struct FinishPhaseView: View {
|
||||||
|
let isActive: Bool
|
||||||
|
let onDone: () -> Void
|
||||||
|
let onOneMore: () -> Void
|
||||||
|
|
||||||
|
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||||
|
|
||||||
|
/// Wall-clock deadline for the auto-Done (same Always-On rationale as the rest
|
||||||
|
/// timer). `remaining` is what the Done button shows.
|
||||||
|
@State private var endDate = Date()
|
||||||
|
@State private var remaining = 0
|
||||||
|
/// Fires the auto-Done exactly once, and latches off while the page isn't active.
|
||||||
|
@State private var didFire = false
|
||||||
|
private let ticker = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Finished!")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Button(action: fire) {
|
||||||
|
Label(remaining > 0 ? "Done · \(remaining)" : "Done", systemImage: "checkmark")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Color.workTint)
|
||||||
|
|
||||||
|
Button(action: onOneMore) {
|
||||||
|
Label("One More", systemImage: "plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.onAppear { if isActive { start() } }
|
||||||
|
.onChange(of: isActive) { _, active in active ? start() : (didFire = true) }
|
||||||
|
.onReceive(ticker) { _ in tick() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start() {
|
||||||
|
let total = max(1, doneCountdownSeconds)
|
||||||
|
endDate = Date().addingTimeInterval(Double(total))
|
||||||
|
remaining = total
|
||||||
|
didFire = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tick() {
|
||||||
|
guard isActive, !didFire else { return }
|
||||||
|
let r = Int(ceil(endDate.timeIntervalSinceNow))
|
||||||
|
remaining = max(0, r)
|
||||||
|
if r <= 0 { fire() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fire() {
|
||||||
|
guard !didFire else { return }
|
||||||
|
didFire = true
|
||||||
|
WKInterfaceDevice.current().play(.success)
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ final class WatchAppDelegate: NSObject, WKApplicationDelegate {
|
|||||||
let sessionManager = WorkoutSessionManager()
|
let sessionManager = WorkoutSessionManager()
|
||||||
|
|
||||||
func applicationDidFinishLaunching() {
|
func applicationDidFinishLaunching() {
|
||||||
|
#if DEBUG
|
||||||
|
// The screenshot harness renders fixed screens — don't pop the Health auth
|
||||||
|
// dialog over them (it would also leak into App Store captures).
|
||||||
|
if ScreenshotSeed.isActive { return }
|
||||||
|
#endif
|
||||||
sessionManager.requestAuthorization()
|
sessionManager.requestAuthorization()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ struct WatchScreenshotRoot: View {
|
|||||||
if let workout = activeWorkout {
|
if let workout = activeWorkout {
|
||||||
switch ScreenshotSeed.screen(default: "list") {
|
switch ScreenshotSeed.screen(default: "list") {
|
||||||
case "work":
|
case "work":
|
||||||
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: nil)
|
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 0)
|
||||||
case "rest":
|
case "rest":
|
||||||
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 1)
|
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 1)
|
||||||
default:
|
default:
|
||||||
@@ -39,8 +39,9 @@ struct WatchScreenshotRoot: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hosts the progress view with a working-copy binding (and an optional pinned page so
|
/// Hosts the progress view with a working-copy binding and a pinned page (which also
|
||||||
/// we can capture a rest phase, which the normal resume logic never lands on).
|
/// suppresses the Ready lead-in), so we can capture a specific work or rest phase that
|
||||||
|
/// the normal resume logic wouldn't land on.
|
||||||
private struct ProgressHost: View {
|
private struct ProgressHost: View {
|
||||||
@State private var doc: WorkoutDocument
|
@State private var doc: WorkoutDocument
|
||||||
private let logID: String
|
private let logID: String
|
||||||
|
|||||||
@@ -43,10 +43,12 @@ final class PhoneConnectivityBridge: NSObject {
|
|||||||
let workouts = (try? context.fetch(wDesc)) ?? []
|
let workouts = (try? context.fetch(wDesc)) ?? []
|
||||||
|
|
||||||
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
|
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
|
||||||
|
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
|
||||||
let payload = WCPayload.encodeState(
|
let payload = WCPayload.encodeState(
|
||||||
splits: splits.map(SplitDocument.init(from:)),
|
splits: splits.map(SplitDocument.init(from:)),
|
||||||
workouts: workouts.map(WorkoutDocument.init(from:)),
|
workouts: workouts.map(WorkoutDocument.init(from:)),
|
||||||
restSeconds: restSeconds
|
restSeconds: restSeconds,
|
||||||
|
doneCountdownSeconds: doneCountdownSeconds
|
||||||
)
|
)
|
||||||
try? session.updateApplicationContext(payload)
|
try? session.updateApplicationContext(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ struct SettingsView: View {
|
|||||||
@Query(sort: \Split.order) private var splits: [Split]
|
@Query(sort: \Split.order) private var splits: [Split]
|
||||||
|
|
||||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||||
|
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||||
@State private var showingAddSplitSheet = false
|
@State private var showingAddSplitSheet = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
// MARK: - Workout Section
|
// MARK: - Workout Section
|
||||||
Section(header: Text("Workout")) {
|
Section {
|
||||||
Stepper(value: $restSeconds, in: 10...180, step: 5) {
|
Stepper(value: $restSeconds, in: 10...180, step: 5) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Rest Between Sets")
|
Text("Rest Between Sets")
|
||||||
@@ -31,6 +32,18 @@ struct SettingsView: View {
|
|||||||
Text("\(restSeconds)s").foregroundColor(.secondary)
|
Text("\(restSeconds)s").foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stepper(value: $doneCountdownSeconds, in: 3...20, step: 1) {
|
||||||
|
HStack {
|
||||||
|
Text("Auto-Finish Countdown")
|
||||||
|
Spacer()
|
||||||
|
Text("\(doneCountdownSeconds)s").foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Workout")
|
||||||
|
} footer: {
|
||||||
|
Text("How long the watch waits on the finish screen before completing an exercise automatically.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Splits Section
|
// MARK: - Splits Section
|
||||||
@@ -97,8 +110,7 @@ struct SettingsView: View {
|
|||||||
IndieAbout(configuration: AppInfoConfiguration(
|
IndieAbout(configuration: AppInfoConfiguration(
|
||||||
documents: [
|
documents: [
|
||||||
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md"),
|
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md"),
|
||||||
.license(),
|
.license(extension: "md")
|
||||||
.acknowledgements()
|
|
||||||
]
|
]
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -108,6 +120,7 @@ struct SettingsView: View {
|
|||||||
SplitAddEditView(split: nil)
|
SplitAddEditView(split: nil)
|
||||||
}
|
}
|
||||||
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
|
.onChange(of: restSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||||
|
.onChange(of: doneCountdownSeconds) { _, _ in services.watchBridge.pushAll() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user