From 76366a0065fc949a76a94d8faabae52b346b3852 Mon Sep 17 00:00:00 2001 From: kutesir Date: Wed, 8 Jul 2026 13:23:56 +0300 Subject: [PATCH] workout: restrained button, finish confirmation, clear toggle (KC-52 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses feedback on the completion card: the primary button was a permanently-filled accent pill (violates 'nothing shouts'/'color earns its place') and Finish Anyway skipped confirming before overriding real progress. - QuietAccentButtonStyle: outlined at rest, fills solid accent only while pressed. - confirmationDialog on Finish Anyway showing the logged % (skipped at 100%). - Check all sets flips to Clear all sets once fully checked — undo is the button itself. Co-Authored-By: Claude Opus 4.8 --- KisaniCal/Components/SharedComponents.swift | 19 +++++++ KisaniCal/ISSUES.md | 17 ++++++ KisaniCal/Views/WorkoutView.swift | 59 +++++++++++++++------ 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/KisaniCal/Components/SharedComponents.swift b/KisaniCal/Components/SharedComponents.swift index 201d312..bd36666 100644 --- a/KisaniCal/Components/SharedComponents.swift +++ b/KisaniCal/Components/SharedComponents.swift @@ -10,6 +10,25 @@ struct PressButtonStyle: ButtonStyle { } } +/// A quiet accent button: outlined/tinted at rest, fills solid only while +/// pressed. Color is a response to touch, not a permanent decoration — +/// matches "nothing shouts, color earns its place." +struct QuietAccentButtonStyle: ButtonStyle { + @Environment(\.colorScheme) private var cs + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundColor(configuration.isPressed ? .white : AppColors.accent) + .background(configuration.isPressed ? AppColors.accent : AppColors.accentSoft) + .clipShape(RoundedRectangle(cornerRadius: 11)) + .overlay( + RoundedRectangle(cornerRadius: 11) + .stroke(AppColors.accent.opacity(configuration.isPressed ? 0 : 0.35), lineWidth: 1) + ) + .scaleEffect(configuration.isPressed ? 0.97 : 1.0) + .animation(KisaniSpring.micro, value: configuration.isPressed) + } +} + struct FabButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 8d54cbd..af010bb 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1705,3 +1705,20 @@ the main page) into a 3-state completion card: action); kept "Clear All Sets" there as a distinct reset utility. Files: `WorkoutView.swift`, `ExerciseModels.swift`. + +### KC-52 — follow-up: restraint, confirmation, clear (user feedback) +User pushed back on the first pass: the primary button was a permanently-filled +accent pill ("giving AI," violates PRODUCT.md "nothing shouts / color earns its +place"), "Finish Anyway" skipped confirming the % logged before overriding +progress, and there was no quick way to undo an accidental "Check all sets." + +- New `QuietAccentButtonStyle` (SharedComponents.swift): outlined/tinted at + rest, fills solid accent only while pressed — color as a response to touch, + not a static decoration. Applied to the Finish button. +- `confirmationDialog` before finishing with partial sets: "Finish with X of Y + sets logged?" + the logged % in the message. Skipped when already 100% (no + need to ask about something already true). +- "Check all sets" now flips to "Clear all sets" once every set is checked — + the undo is the button itself, always one tap away, no separate menu needed. + +Files: `WorkoutView.swift`, `Components/SharedComponents.swift`. diff --git a/KisaniCal/Views/WorkoutView.swift b/KisaniCal/Views/WorkoutView.swift index b196a4c..406dd8c 100644 --- a/KisaniCal/Views/WorkoutView.swift +++ b/KisaniCal/Views/WorkoutView.swift @@ -144,6 +144,7 @@ struct WorkoutView: View { isMissedToday: vm.isWorkoutMissed(on: Date()), onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } }, onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } }, + onClearAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } }, onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } }, onUndo: { withAnimation(KisaniSpring.snappy) { @@ -588,9 +589,12 @@ struct DotProgressCard: View { /// a workout happened (Health/Watch already knows that independently). let onFinish: () -> Void let onMarkAllSets: () -> Void + let onClearAllSets: () -> Void let onNotCompleted: () -> Void let onUndo: () -> Void + @State private var showFinishConfirm = false + var body: some View { VStack(spacing: 12) { HStack { @@ -620,6 +624,18 @@ struct DotProgressCard: View { } .padding(14) .cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil) + .confirmationDialog( + "Finish with \(done) of \(total) sets logged?", + isPresented: $showFinishConfirm, + titleVisibility: .visible + ) { + Button("Finish Anyway") { onFinish() } + Button("Cancel", role: .cancel) {} + } message: { + Text(total > 0 + ? "You've logged \(Int(progress * 100))% of sets. This still counts as today's workout." + : "No sets logged yet. This still counts as today's workout.") + } } private var statusTitle: String { @@ -651,32 +667,45 @@ struct DotProgressCard: View { } /// Primary: finish the workout as-is (honest capture, not gated on 100% sets). - /// Secondary: the mechanical "tick every box" action, and the explicit miss. + /// Secondary: the mechanical "tick every box" action (toggles to a clear once + /// all are checked, so a mis-tap is always one tap to undo), and the miss. private var actionRow: some View { VStack(spacing: 8) { - Button(action: onFinish) { + Button { + if done == total && total > 0 { onFinish() } else { showFinishConfirm = true } + } label: { 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)) + .buttonStyle(QuietAccentButtonStyle()) 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) + if done == total && total > 0 { + Button(action: onClearAllSets) { + Label("Clear all sets", systemImage: "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)) + } else { + 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)) } - .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")