workout: bring completion controls onto the main page (KC-52)
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:
kutesir
2026-07-08 10:57:05 +03:00
parent 708bf95e2a
commit c521c43f9a
3 changed files with 167 additions and 19 deletions

View File

@@ -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 in this environment). User will build on device next. Awaiting: first-build
error list (expected — SwiftUI Charts/generics are the likely spots), then error list (expected — SwiftUI Charts/generics are the likely spots), then
XCTest run, then close out goal conditions #10/#11 and any remaining polish. 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`.

View File

@@ -663,6 +663,21 @@ class WorkoutViewModel: ObservableObject {
save() 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) // MARK: - Compensation (recover a missed workout on a rest day)
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow. /// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.

View File

@@ -108,9 +108,6 @@ struct WorkoutView: View {
IButton(icon: "chart.bar.xaxis") { showStats = true } IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = true } IButton(icon: "clock.arrow.circlepath") { showHistory = true }
Menu { Menu {
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: {
Label("Mark All Complete", systemImage: "checkmark.circle")
}
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: { Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: {
Label("Clear All Sets", systemImage: "circle") Label("Clear All Sets", systemImage: "circle")
} }
@@ -140,8 +137,21 @@ struct WorkoutView: View {
.moveDisabled(true) .moveDisabled(true)
} else { } else {
// Dot Grid Progress // Dot Grid Progress + Completion
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets) 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) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14)) .listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
@@ -571,34 +581,126 @@ struct AddSectionSheet: View {
struct DotProgressCard: View { struct DotProgressCard: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs
let progress: Double; let done: Int; let total: Int 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 { var body: some View {
VStack(spacing: 10) { VStack(spacing: 12) {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("\(done) of \(total) sets done") Text(statusTitle)
.font(AppFonts.sans(13, weight: .semibold)) .font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.text(cs)) .foregroundColor(AppColors.text(cs))
Text(total == 0 ? "Add exercises to get started" Text(statusSubtitle)
: done == total && total > 0 ? "All sets done"
: "\(total - done) sets remaining")
.font(AppFonts.mono(9)) .font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs)) .foregroundColor(AppColors.text3(cs))
} }
Spacer() Spacer()
Text(total > 0 ? "\(Int(progress * 100))%" : "0%") statusBadge
.font(AppFonts.mono(13, weight: .bold))
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
} }
VStack(alignment: .leading, spacing: 6) {
Text("PROGRESS") if isDoneToday || isMissedToday {
.font(AppFonts.mono(8, weight: .bold)) undoRow
.foregroundColor(AppColors.text3(cs)) } else {
DotGridProgress(progress: total > 0 ? progress : 0, cs: cs) 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) .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)
}
} }
} }