From b1a5d25b61aa56286be1af0ec5e67c5ce84dded9 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 11 Jul 2026 13:40:36 -0400 Subject: [PATCH] Give achievements tappable detail pages with how-to-earn and why-it-matters copy --- CHANGELOG.md | 2 + Workouts/Progress/ProgressPlanner.swift | 97 +++++++++++++++--- .../Views/Progress/AchievementsSection.swift | 98 +++++++++++++++---- 3 files changed, 162 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38490f6..3b6623e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +Tap any achievement to open a detail page explaining how to earn it and why it matters. + Scheduled workouts can now remind you with a notification at a time you choose. The Today board now shows just the day's docket — only schedules actually due on the viewed day appear. diff --git a/Workouts/Progress/ProgressPlanner.swift b/Workouts/Progress/ProgressPlanner.swift index dc026d6..d32e23a 100644 --- a/Workouts/Progress/ProgressPlanner.swift +++ b/Workouts/Progress/ProgressPlanner.swift @@ -137,11 +137,14 @@ struct TrendPoint: Sendable, Equatable, Identifiable { // MARK: - Achievements /// A derived badge. `progress` is 0…1 toward earning (1 when earned) so locked badges -/// can show how close they are. +/// can show how close they are. `detail` is the one-line how-to-earn rule; `story` is +/// the longer read shown on the badge's detail screen — why the milestone matters and +/// what earning it testifies to. struct Achievement: Sendable, Equatable, Identifiable { var id: String var title: String var detail: String + var story: String var systemImage: String var colorName: String var earned: Bool @@ -355,17 +358,38 @@ enum ProgressPlanner { var badges: [Achievement] = [] // Session-count milestones. - let countMilestones: [(Int, String, String)] = [ - (1, "First Rep", "figure.walk"), - (10, "Ten Strong", "10.circle.fill"), - (50, "Fifty Club", "50.circle.fill"), - (100, "Century", "medal.fill"), - (250, "Relentless", "flame.circle.fill"), + let countMilestones: [(Int, String, String, String)] = [ + (1, "First Rep", "figure.walk", + "Every training journey starts with a single session, and the first one is " + + "the hardest rep of any program — it's the moment planning turned into " + + "doing. This badge testifies to the step most people never take: you " + + "showed up."), + (10, "Ten Strong", "10.circle.fill", + "Ten workouts is where a decision becomes a practice. Anyone can try " + + "something once; coming back ten times means you pushed through the " + + "early sessions where motivation does all the work and habit hasn't " + + "formed yet. This badge testifies that you're building something real."), + (50, "Fifty Club", "50.circle.fill", + "Fifty sessions is months of consistent training — long enough for real " + + "physiological change: heavier lifts, faster recovery, movements that " + + "feel natural instead of awkward. This badge testifies to a body of " + + "work, not a burst of enthusiasm."), + (100, "Century", "medal.fill", + "One hundred workouts represents hundreds of hours of deliberate effort, " + + "and the kind of adaptation that only accumulates with time under load. " + + "At this point training isn't something you do — it's part of who you " + + "are. This badge testifies to identity, not intention."), + (250, "Relentless", "flame.circle.fill", + "Two hundred and fifty sessions is years of showing up — through busy " + + "weeks, low motivation, and every good reason to skip. Very few people " + + "ever train this much. This badge testifies to the rarest quality in " + + "fitness: relentlessness."), ] - for (target, title, symbol) in countMilestones { + for (target, title, symbol, story) in countMilestones { badges.append(Achievement( id: "count-\(target)", title: title, detail: "Complete \(target) workout\(target == 1 ? "" : "s")", + story: story, systemImage: symbol, colorName: "blue", earned: count >= target, progress: min(1, Double(count) / Double(target)))) @@ -373,13 +397,29 @@ enum ProgressPlanner { // Weekly-streak milestones — best consecutive-hit run across any goal track. let bestRun = tracks.map { bestHitRun(weeks: $0.weeks) }.max() ?? 0 - let streakMilestones: [(Int, String)] = [ - (4, "4-Week Rhythm"), (8, "8-Week Habit"), (12, "12-Week Lifestyle"), + let streakMilestones: [(Int, String, String)] = [ + (4, "4-Week Rhythm", + "Four straight weeks of hitting a goal is your first real training rhythm. " + + "Progress comes from consistency week over week, not from one heroic " + + "week — and a month is where a plan starts to become a pattern. This " + + "badge testifies that you can sustain intent, not just spark it."), + (8, "8-Week Habit", + "Eight consecutive weeks is the length of a classic training block — the " + + "horizon where measurable strength and conditioning gains actually show " + + "up. Holding a streak this long means training survived vacations, " + + "deadlines, and dips in motivation. This badge testifies that the habit " + + "now carries you, not the other way around."), + (12, "12-Week Lifestyle", + "Twelve weeks — a full quarter — of hitting your goal every single week. " + + "Programs end; lifestyles don't. A streak this long means training is no " + + "longer an experiment you're running, it's simply how you live. This " + + "badge testifies to exactly that."), ] - for (target, title) in streakMilestones { + for (target, title, story) in streakMilestones { badges.append(Achievement( id: "streak-\(target)", title: title, detail: "Hit a goal every week for \(target) weeks straight", + story: story, systemImage: "flame.fill", colorName: "orange", earned: bestRun >= target, progress: min(1, Double(bestRun) / Double(target)))) @@ -390,19 +430,33 @@ enum ProgressPlanner { badges.append(Achievement( id: "days-7", title: "Seven Straight", detail: "Work out 7 days in a row", + story: "A full week without a single day off takes planning, recovery " + + "management, and follow-through every single morning. It testifies to " + + "discipline and momentum — the ability to keep a promise to yourself " + + "seven days running. Wear it proudly, and remember rest is training " + + "too.", systemImage: "calendar", colorName: "green", earned: dayRun >= 7, progress: min(1, Double(dayRun) / 7))) // Lifetime volume. - let volumeMilestones: [(Double, String, String)] = [ - (100_000, "Heavy Lifter", "scalemass.fill"), - (1_000_000, "Million Mover", "trophy.fill"), + let volumeMilestones: [(Double, String, String, String)] = [ + (100_000, "Heavy Lifter", "scalemass.fill", + "A hundred thousand in lifted volume — every rep of every set, added up. " + + "Volume is the primary driver of muscle growth, and a number this size " + + "only comes from thousands of purposeful reps. This badge is the tonnage " + + "receipt for your work: proof the effort was real and measured."), + (1_000_000, "Million Mover", "trophy.fill", + "One million in total volume. Nobody reaches seven figures by accident — " + + "it's the compound interest of years of training, session after session, " + + "plate after plate. This badge testifies to a scale of sustained work " + + "that puts you in genuinely rare company."), ] - for (target, title, symbol) in volumeMilestones { + for (target, title, symbol, story) in volumeMilestones { badges.append(Achievement( id: "volume-\(Int(target))", title: title, detail: "Lift \(Int(target).formatted()) total volume", + story: story, systemImage: symbol, colorName: "purple", earned: totalVolume >= target, progress: min(1, totalVolume / target))) @@ -413,12 +467,20 @@ enum ProgressPlanner { badges.append(Achievement( id: "early-bird", title: "Early Bird", detail: "Start a workout before 7 AM", + story: "Training before 7 AM means putting the workout ahead of everything " + + "the day is about to throw at you. Morning sessions are the ones life " + + "can't cancel — no meeting runs into them, no fatigue talks you out of " + + "them. This badge testifies that you make time rather than find it.", systemImage: "sunrise.fill", colorName: "yellow", earned: hours.contains { $0 < 7 }, progress: hours.contains { $0 < 7 } ? 1 : 0)) badges.append(Achievement( id: "night-owl", title: "Night Owl", detail: "Start a workout after 9 PM", + story: "Starting a workout after 9 PM takes a different kind of resolve — " + + "willpower runs lowest at the end of the day, when the couch is " + + "closest and every excuse is at hand. This badge testifies that you " + + "train even when it would be easy not to.", systemImage: "moon.stars.fill", colorName: "indigo", earned: hours.contains { $0 >= 21 }, progress: hours.contains { $0 >= 21 } ? 1 : 0)) @@ -428,6 +490,11 @@ enum ProgressPlanner { badges.append(Achievement( id: "clean-sweep", title: "Clean Sweep", detail: "Hit every goal in a single week", + story: "In one week you hit every goal you set — not one focus at the " + + "expense of the others, but the whole plan, executed. Balance is the " + + "hardest thing to train because nothing about it is urgent. This " + + "badge testifies to well-rounded programming and the discipline to " + + "honor all of it at once.", systemImage: "checkmark.seal.fill", colorName: "teal", earned: sweep, progress: sweep ? 1 : 0)) diff --git a/Workouts/Views/Progress/AchievementsSection.swift b/Workouts/Views/Progress/AchievementsSection.swift index 123bae7..c8fca91 100644 --- a/Workouts/Views/Progress/AchievementsSection.swift +++ b/Workouts/Views/Progress/AchievementsSection.swift @@ -9,13 +9,11 @@ import SwiftUI /// The achievements badge grid. Every badge is a pure derivation of history /// (`ProgressPlanner.achievements`) — earned ones glow in their color, locked ones -/// wear a progress ring showing how close they are. Tapping shows the how-to-earn -/// detail. +/// wear a progress ring showing how close they are. Tapping pushes the badge's +/// detail screen with the how-to-earn rule and its story. struct AchievementsSection: View { let achievements: [Achievement] - @State private var selected: Achievement? - private var earnedCount: Int { achievements.filter(\.earned).count } var body: some View { @@ -33,8 +31,8 @@ struct AchievementsSection: View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 16) { ForEach(achievements) { achievement in - Button { - selected = achievement + NavigationLink { + AchievementDetailView(achievement: achievement) } label: { AchievementBadge(achievement: achievement) } @@ -43,20 +41,6 @@ struct AchievementsSection: View { } } .progressCard() - .alert( - selected?.title ?? "", - isPresented: Binding( - get: { selected != nil }, - set: { if !$0 { selected = nil } } - ), - presenting: selected - ) { _ in - Button("OK") { selected = nil } - } message: { achievement in - Text(achievement.earned - ? achievement.detail - : "\(achievement.detail) — \(Int((achievement.progress * 100).rounded()))% there.") - } } } @@ -99,3 +83,77 @@ struct AchievementBadge: View { "\(achievement.title), \(achievement.earned ? "earned" : "locked"): \(achievement.detail)") } } + +/// One achievement's full-screen read: the badge writ large, its earned/locked state, +/// the how-to-earn rule, and the story of why the milestone matters. +struct AchievementDetailView: View { + let achievement: Achievement + + private var color: Color { Color.color(from: achievement.colorName) } + + var body: some View { + ScrollView { + VStack(spacing: 24) { + ZStack { + Circle() + .fill(achievement.earned ? color.opacity(0.18) : Color(.systemFill)) + Image(systemName: achievement.systemImage) + .font(.system(size: 44)) + .foregroundStyle(achievement.earned ? color : Color(.tertiaryLabel)) + } + .frame(width: 120, height: 120) + .overlay { + if !achievement.earned, achievement.progress > 0 { + Circle() + .trim(from: 0, to: achievement.progress) + .stroke(color.opacity(0.6), + style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + } + .padding(.top, 16) + + VStack(spacing: 8) { + Text(achievement.title) + .font(.title2.bold()) + .multilineTextAlignment(.center) + if achievement.earned { + Label("Earned", systemImage: "checkmark.circle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(color) + } else { + Text("\(Int((achievement.progress * 100).rounded()))% there") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("How to Earn") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Text(achievement.detail) + .font(.body) + } + .progressCard() + + VStack(alignment: .leading, spacing: 6) { + Text("Why It Matters") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Text(achievement.story) + .font(.body) + } + .progressCard() + } + .padding(.horizontal) + .padding(.bottom, 24) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle(achievement.title) + .navigationBarTitleDisplayMode(.inline) + } +}