Merge develop -> main: full session (KC-51 through KC-71) #28

Merged
kutesir merged 64 commits from develop into main 2026-07-12 22:57:14 +00:00
3 changed files with 167 additions and 19 deletions
Showing only changes of commit c521c43f9a - Show all commits

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
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`.

View File

@@ -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.

View File

@@ -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
}
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)
}
}
}