workout: bring completion controls onto the main page (KC-52)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
DotProgressCard becomes a 3-state completion card (pending/done/missed) with a primary Finish Workout/Finish Anyway action that captures today's workout honestly — regardless of how many sets got ticked, since Health/Watch already confirms the workout independently. Secondary actions: check all sets, mark not completed. Done/missed states show an Undo. Adds WorkoutViewModel.unmarkWorkoutDone/unmarkWorkoutMissed. Removes 'Mark All Complete' from the header '...' menu (superseded by the card); keeps 'Clear All Sets' as a distinct reset utility. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1674,3 +1674,34 @@ SwiftUI/app-module pieces are self-reviewed but never compiled (no iOS runtime
|
||||
in this environment). User will build on device next. Awaiting: first-build
|
||||
error list (expected — SwiftUI Charts/generics are the likely spots), then
|
||||
XCTest run, then close out goal conditions #10/#11 and any remaining polish.
|
||||
|
||||
---
|
||||
|
||||
## KC-52 — Bring workout completion onto the main page (not buried in "…" menu)
|
||||
|
||||
**Status:** Implemented (not build-verified)
|
||||
**Reported by:** User
|
||||
**Area:** Workout
|
||||
|
||||
### Description
|
||||
"Mark All Complete" / "Clear All Sets" were hidden in the header's "…" menu.
|
||||
User wants completion controls front-and-center on the main Workout page, and
|
||||
a way to finish a workout even if not every set was ticked — since Health/Watch
|
||||
already independently confirms a workout happened, the app shouldn't gate
|
||||
"did you train today" on checking every box.
|
||||
|
||||
### Change
|
||||
Redesigned `DotProgressCard` (the "X of Y sets done" card, already at the top of
|
||||
the main page) into a 3-state completion card:
|
||||
- **Pending:** progress dots + a primary **Finish Workout / Finish Anyway**
|
||||
button (captures today via `markWorkoutDone`, regardless of set completion —
|
||||
honest capture, not gated on 100%), plus two secondary actions: **Check all
|
||||
sets** (mechanical, ticks every box) and **Didn't train** (marks missed).
|
||||
- **Done today:** green "Workout logged" state + **Undo**.
|
||||
- **Missed today:** muted "Marked as not completed" state + **Undo**.
|
||||
- Added `unmarkWorkoutDone(on:)` / `unmarkWorkoutMissed(on:)` to
|
||||
`WorkoutViewModel` — clean "back to pending" without conflating done/missed.
|
||||
- Removed "Mark All Complete" from the header's "…" menu (now the card's primary
|
||||
action); kept "Clear All Sets" there as a distinct reset utility.
|
||||
|
||||
Files: `WorkoutView.swift`, `ExerciseModels.swift`.
|
||||
|
||||
@@ -663,6 +663,21 @@ class WorkoutViewModel: ObservableObject {
|
||||
save()
|
||||
}
|
||||
|
||||
/// Undo a "done" mark for a specific day — back to pending. Does NOT mark it
|
||||
/// missed (that's a separate, explicit choice). Logged set data is kept.
|
||||
func unmarkWorkoutDone(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
workoutDates.removeAll { $0 == key }
|
||||
save()
|
||||
}
|
||||
|
||||
/// Undo a "missed" mark — back to pending, without claiming it was done.
|
||||
func unmarkWorkoutMissed(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
missedDates.removeAll { $0 == key }
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Compensation (recover a missed workout on a rest day)
|
||||
|
||||
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
|
||||
|
||||
@@ -108,9 +108,6 @@ struct WorkoutView: View {
|
||||
IButton(icon: "chart.bar.xaxis") { showStats = true }
|
||||
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
||||
Menu {
|
||||
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: {
|
||||
Label("Mark All Complete", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: {
|
||||
Label("Clear All Sets", systemImage: "circle")
|
||||
}
|
||||
@@ -140,8 +137,21 @@ struct WorkoutView: View {
|
||||
.moveDisabled(true)
|
||||
} else {
|
||||
|
||||
// ── Dot Grid Progress ──
|
||||
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets)
|
||||
// ── Dot Grid Progress + Completion ──
|
||||
DotProgressCard(
|
||||
progress: vm.progress, done: vm.doneSets, total: vm.totalSets,
|
||||
isDoneToday: vm.isWorkoutComplete(on: Date()),
|
||||
isMissedToday: vm.isWorkoutMissed(on: Date()),
|
||||
onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } },
|
||||
onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } },
|
||||
onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } },
|
||||
onUndo: {
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
if vm.isWorkoutMissed(on: Date()) { vm.unmarkWorkoutMissed(on: Date()) }
|
||||
else { vm.unmarkWorkoutDone(on: Date()) }
|
||||
}
|
||||
}
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
|
||||
@@ -571,34 +581,126 @@ struct AddSectionSheet: View {
|
||||
struct DotProgressCard: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let progress: Double; let done: Int; let total: Int
|
||||
let isDoneToday: Bool
|
||||
let isMissedToday: Bool
|
||||
/// Capture today as done regardless of how many sets are checked — the app's
|
||||
/// own set-ticking is a nice-to-have log, not the source of truth for whether
|
||||
/// a workout happened (Health/Watch already knows that independently).
|
||||
let onFinish: () -> Void
|
||||
let onMarkAllSets: () -> Void
|
||||
let onNotCompleted: () -> Void
|
||||
let onUndo: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(done) of \(total) sets done")
|
||||
Text(statusTitle)
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text(total == 0 ? "Add exercises to get started"
|
||||
: done == total && total > 0 ? "All sets done"
|
||||
: "\(total - done) sets remaining")
|
||||
Text(statusSubtitle)
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
|
||||
.font(AppFonts.mono(13, weight: .bold))
|
||||
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
|
||||
statusBadge
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("PROGRESS")
|
||||
.font(AppFonts.mono(8, weight: .bold))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs)
|
||||
|
||||
if isDoneToday || isMissedToday {
|
||||
undoRow
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("PROGRESS")
|
||||
.font(AppFonts.mono(8, weight: .bold))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs)
|
||||
}
|
||||
actionRow
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.cardStyle()
|
||||
.cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil)
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
if isDoneToday { return "Workout logged" }
|
||||
if isMissedToday { return "Marked as not completed" }
|
||||
return "\(done) of \(total) sets done"
|
||||
}
|
||||
|
||||
private var statusSubtitle: String {
|
||||
if isDoneToday { return "Counted toward today · \(done)/\(total) sets logged" }
|
||||
if isMissedToday { return "Doesn't count toward today" }
|
||||
if total == 0 { return "Add exercises to get started" }
|
||||
if done == total { return "All sets done" }
|
||||
return "\(total - done) sets remaining"
|
||||
}
|
||||
|
||||
@ViewBuilder private var statusBadge: some View {
|
||||
if isDoneToday {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 18)).foregroundColor(AppColors.green)
|
||||
} else if isMissedToday {
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: 16)).foregroundColor(AppColors.text3(cs))
|
||||
} else {
|
||||
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
|
||||
.font(AppFonts.mono(13, weight: .bold))
|
||||
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary: finish the workout as-is (honest capture, not gated on 100% sets).
|
||||
/// Secondary: the mechanical "tick every box" action, and the explicit miss.
|
||||
private var actionRow: some View {
|
||||
VStack(spacing: 8) {
|
||||
Button(action: onFinish) {
|
||||
Label(done == total && total > 0 ? "Finish Workout" : "Finish Anyway",
|
||||
systemImage: "checkmark.seal.fill")
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.background(AppColors.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11))
|
||||
.buttonStyle(PressButtonStyle(scale: 0.97))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: onMarkAllSets) {
|
||||
Label("Check all sets", systemImage: "checkmark.circle")
|
||||
.font(AppFonts.sans(11, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.buttonStyle(PressButtonStyle(scale: 0.97))
|
||||
|
||||
Button(action: onNotCompleted) {
|
||||
Label("Didn't train", systemImage: "moon.zzz")
|
||||
.font(AppFonts.sans(11, weight: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||
.buttonStyle(PressButtonStyle(scale: 0.97))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var undoRow: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: onUndo) {
|
||||
Label("Undo", systemImage: "arrow.uturn.backward")
|
||||
.font(AppFonts.sans(11, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user