Give achievements tappable detail pages with how-to-earn and why-it-matters copy
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user