workout: restrained button, finish confirmation, clear toggle (KC-52 follow-up)
Some checks failed
CI / build-and-test (push) Has been cancelled
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:
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,22 +667,34 @@ 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) {
|
||||||
|
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) {
|
Button(action: onMarkAllSets) {
|
||||||
Label("Check all sets", systemImage: "checkmark.circle")
|
Label("Check all sets", systemImage: "checkmark.circle")
|
||||||
.font(AppFonts.sans(11, weight: .medium))
|
.font(AppFonts.sans(11, weight: .medium))
|
||||||
@@ -677,6 +705,7 @@ struct DotProgressCard: View {
|
|||||||
.background(AppColors.surface2(cs))
|
.background(AppColors.surface2(cs))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||||
.buttonStyle(PressButtonStyle(scale: 0.97))
|
.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")
|
||||||
|
|||||||
Reference in New Issue
Block a user