workout: restrained button, finish confirmation, clear toggle (KC-52 follow-up)
Some checks failed
CI / build-and-test (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-08 13:23:56 +03:00
parent c521c43f9a
commit 5123a38ea9
3 changed files with 80 additions and 15 deletions

View File

@@ -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 { struct FabButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label

View File

@@ -1705,3 +1705,20 @@ the main page) into a 3-state completion card:
action); kept "Clear All Sets" there as a distinct reset utility. action); kept "Clear All Sets" there as a distinct reset utility.
Files: `WorkoutView.swift`, `ExerciseModels.swift`. 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`.

View File

@@ -144,6 +144,7 @@ struct WorkoutView: View {
isMissedToday: vm.isWorkoutMissed(on: Date()), isMissedToday: vm.isWorkoutMissed(on: Date()),
onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } }, onFinish: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutDone(on: Date()) } },
onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } }, onMarkAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } },
onClearAllSets: { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } },
onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } }, onNotCompleted: { withAnimation(KisaniSpring.snappy) { vm.markWorkoutMissed(on: Date()) } },
onUndo: { onUndo: {
withAnimation(KisaniSpring.snappy) { withAnimation(KisaniSpring.snappy) {
@@ -588,9 +589,12 @@ struct DotProgressCard: View {
/// a workout happened (Health/Watch already knows that independently). /// a workout happened (Health/Watch already knows that independently).
let onFinish: () -> Void let onFinish: () -> Void
let onMarkAllSets: () -> Void let onMarkAllSets: () -> Void
let onClearAllSets: () -> Void
let onNotCompleted: () -> Void let onNotCompleted: () -> Void
let onUndo: () -> Void let onUndo: () -> Void
@State private var showFinishConfirm = false
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
HStack { HStack {
@@ -620,6 +624,18 @@ struct DotProgressCard: View {
} }
.padding(14) .padding(14)
.cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil) .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 { private var statusTitle: String {
@@ -651,32 +667,45 @@ struct DotProgressCard: View {
} }
/// Primary: finish the workout as-is (honest capture, not gated on 100% sets). /// 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 { private var actionRow: some View {
VStack(spacing: 8) { 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", Label(done == total && total > 0 ? "Finish Workout" : "Finish Anyway",
systemImage: "checkmark.seal.fill") systemImage: "checkmark.seal.fill")
.font(AppFonts.sans(13, weight: .semibold)) .font(AppFonts.sans(13, weight: .semibold))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 11) .padding(.vertical, 11)
} }
.foregroundColor(.white) .buttonStyle(QuietAccentButtonStyle())
.background(AppColors.accent)
.clipShape(RoundedRectangle(cornerRadius: 11))
.buttonStyle(PressButtonStyle(scale: 0.97))
HStack(spacing: 8) { HStack(spacing: 8) {
Button(action: onMarkAllSets) { if done == total && total > 0 {
Label("Check all sets", systemImage: "checkmark.circle") Button(action: onClearAllSets) {
.font(AppFonts.sans(11, weight: .medium)) Label("Clear all sets", systemImage: "circle")
.frame(maxWidth: .infinity) .font(AppFonts.sans(11, weight: .medium))
.padding(.vertical, 8) .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) { Button(action: onNotCompleted) {
Label("Didn't train", systemImage: "moon.zzz") Label("Didn't train", systemImage: "moon.zzz")