diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 793f1a4..8d54cbd 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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`. diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 0031ad8..7ff0b15 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/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. diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index 989e231..b196a4c 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -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) + } } }