Give achievements tappable detail pages with how-to-earn and why-it-matters copy

This commit is contained in:
2026-07-11 13:40:36 -04:00
parent e509d0e7c5
commit b1a5d25b61
3 changed files with 162 additions and 35 deletions
+2
View File
@@ -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.
+82 -15
View File
@@ -137,11 +137,14 @@ struct TrendPoint: Sendable, Equatable, Identifiable {
// MARK: - Achievements
/// A derived badge. `progress` is 01 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))
@@ -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)
}
}