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**
|
**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.
|
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.
|
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
|
// MARK: - Achievements
|
||||||
|
|
||||||
/// A derived badge. `progress` is 0…1 toward earning (1 when earned) so locked badges
|
/// 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 {
|
struct Achievement: Sendable, Equatable, Identifiable {
|
||||||
var id: String
|
var id: String
|
||||||
var title: String
|
var title: String
|
||||||
var detail: String
|
var detail: String
|
||||||
|
var story: String
|
||||||
var systemImage: String
|
var systemImage: String
|
||||||
var colorName: String
|
var colorName: String
|
||||||
var earned: Bool
|
var earned: Bool
|
||||||
@@ -355,17 +358,38 @@ enum ProgressPlanner {
|
|||||||
var badges: [Achievement] = []
|
var badges: [Achievement] = []
|
||||||
|
|
||||||
// Session-count milestones.
|
// Session-count milestones.
|
||||||
let countMilestones: [(Int, String, String)] = [
|
let countMilestones: [(Int, String, String, String)] = [
|
||||||
(1, "First Rep", "figure.walk"),
|
(1, "First Rep", "figure.walk",
|
||||||
(10, "Ten Strong", "10.circle.fill"),
|
"Every training journey starts with a single session, and the first one is "
|
||||||
(50, "Fifty Club", "50.circle.fill"),
|
+ "the hardest rep of any program — it's the moment planning turned into "
|
||||||
(100, "Century", "medal.fill"),
|
+ "doing. This badge testifies to the step most people never take: you "
|
||||||
(250, "Relentless", "flame.circle.fill"),
|
+ "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(
|
badges.append(Achievement(
|
||||||
id: "count-\(target)", title: title,
|
id: "count-\(target)", title: title,
|
||||||
detail: "Complete \(target) workout\(target == 1 ? "" : "s")",
|
detail: "Complete \(target) workout\(target == 1 ? "" : "s")",
|
||||||
|
story: story,
|
||||||
systemImage: symbol, colorName: "blue",
|
systemImage: symbol, colorName: "blue",
|
||||||
earned: count >= target,
|
earned: count >= target,
|
||||||
progress: min(1, Double(count) / Double(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.
|
// Weekly-streak milestones — best consecutive-hit run across any goal track.
|
||||||
let bestRun = tracks.map { bestHitRun(weeks: $0.weeks) }.max() ?? 0
|
let bestRun = tracks.map { bestHitRun(weeks: $0.weeks) }.max() ?? 0
|
||||||
let streakMilestones: [(Int, String)] = [
|
let streakMilestones: [(Int, String, String)] = [
|
||||||
(4, "4-Week Rhythm"), (8, "8-Week Habit"), (12, "12-Week Lifestyle"),
|
(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(
|
badges.append(Achievement(
|
||||||
id: "streak-\(target)", title: title,
|
id: "streak-\(target)", title: title,
|
||||||
detail: "Hit a goal every week for \(target) weeks straight",
|
detail: "Hit a goal every week for \(target) weeks straight",
|
||||||
|
story: story,
|
||||||
systemImage: "flame.fill", colorName: "orange",
|
systemImage: "flame.fill", colorName: "orange",
|
||||||
earned: bestRun >= target,
|
earned: bestRun >= target,
|
||||||
progress: min(1, Double(bestRun) / Double(target))))
|
progress: min(1, Double(bestRun) / Double(target))))
|
||||||
@@ -390,19 +430,33 @@ enum ProgressPlanner {
|
|||||||
badges.append(Achievement(
|
badges.append(Achievement(
|
||||||
id: "days-7", title: "Seven Straight",
|
id: "days-7", title: "Seven Straight",
|
||||||
detail: "Work out 7 days in a row",
|
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",
|
systemImage: "calendar", colorName: "green",
|
||||||
earned: dayRun >= 7,
|
earned: dayRun >= 7,
|
||||||
progress: min(1, Double(dayRun) / 7)))
|
progress: min(1, Double(dayRun) / 7)))
|
||||||
|
|
||||||
// Lifetime volume.
|
// Lifetime volume.
|
||||||
let volumeMilestones: [(Double, String, String)] = [
|
let volumeMilestones: [(Double, String, String, String)] = [
|
||||||
(100_000, "Heavy Lifter", "scalemass.fill"),
|
(100_000, "Heavy Lifter", "scalemass.fill",
|
||||||
(1_000_000, "Million Mover", "trophy.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(
|
badges.append(Achievement(
|
||||||
id: "volume-\(Int(target))", title: title,
|
id: "volume-\(Int(target))", title: title,
|
||||||
detail: "Lift \(Int(target).formatted()) total volume",
|
detail: "Lift \(Int(target).formatted()) total volume",
|
||||||
|
story: story,
|
||||||
systemImage: symbol, colorName: "purple",
|
systemImage: symbol, colorName: "purple",
|
||||||
earned: totalVolume >= target,
|
earned: totalVolume >= target,
|
||||||
progress: min(1, totalVolume / target)))
|
progress: min(1, totalVolume / target)))
|
||||||
@@ -413,12 +467,20 @@ enum ProgressPlanner {
|
|||||||
badges.append(Achievement(
|
badges.append(Achievement(
|
||||||
id: "early-bird", title: "Early Bird",
|
id: "early-bird", title: "Early Bird",
|
||||||
detail: "Start a workout before 7 AM",
|
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",
|
systemImage: "sunrise.fill", colorName: "yellow",
|
||||||
earned: hours.contains { $0 < 7 },
|
earned: hours.contains { $0 < 7 },
|
||||||
progress: hours.contains { $0 < 7 } ? 1 : 0))
|
progress: hours.contains { $0 < 7 } ? 1 : 0))
|
||||||
badges.append(Achievement(
|
badges.append(Achievement(
|
||||||
id: "night-owl", title: "Night Owl",
|
id: "night-owl", title: "Night Owl",
|
||||||
detail: "Start a workout after 9 PM",
|
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",
|
systemImage: "moon.stars.fill", colorName: "indigo",
|
||||||
earned: hours.contains { $0 >= 21 },
|
earned: hours.contains { $0 >= 21 },
|
||||||
progress: hours.contains { $0 >= 21 } ? 1 : 0))
|
progress: hours.contains { $0 >= 21 } ? 1 : 0))
|
||||||
@@ -428,6 +490,11 @@ enum ProgressPlanner {
|
|||||||
badges.append(Achievement(
|
badges.append(Achievement(
|
||||||
id: "clean-sweep", title: "Clean Sweep",
|
id: "clean-sweep", title: "Clean Sweep",
|
||||||
detail: "Hit every goal in a single week",
|
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",
|
systemImage: "checkmark.seal.fill", colorName: "teal",
|
||||||
earned: sweep, progress: sweep ? 1 : 0))
|
earned: sweep, progress: sweep ? 1 : 0))
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ import SwiftUI
|
|||||||
|
|
||||||
/// The achievements badge grid. Every badge is a pure derivation of history
|
/// The achievements badge grid. Every badge is a pure derivation of history
|
||||||
/// (`ProgressPlanner.achievements`) — earned ones glow in their color, locked ones
|
/// (`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
|
/// wear a progress ring showing how close they are. Tapping pushes the badge's
|
||||||
/// detail.
|
/// detail screen with the how-to-earn rule and its story.
|
||||||
struct AchievementsSection: View {
|
struct AchievementsSection: View {
|
||||||
let achievements: [Achievement]
|
let achievements: [Achievement]
|
||||||
|
|
||||||
@State private var selected: Achievement?
|
|
||||||
|
|
||||||
private var earnedCount: Int { achievements.filter(\.earned).count }
|
private var earnedCount: Int { achievements.filter(\.earned).count }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -33,8 +31,8 @@ struct AchievementsSection: View {
|
|||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4),
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4),
|
||||||
spacing: 16) {
|
spacing: 16) {
|
||||||
ForEach(achievements) { achievement in
|
ForEach(achievements) { achievement in
|
||||||
Button {
|
NavigationLink {
|
||||||
selected = achievement
|
AchievementDetailView(achievement: achievement)
|
||||||
} label: {
|
} label: {
|
||||||
AchievementBadge(achievement: achievement)
|
AchievementBadge(achievement: achievement)
|
||||||
}
|
}
|
||||||
@@ -43,20 +41,6 @@ struct AchievementsSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.progressCard()
|
.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)")
|
"\(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