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
@@ -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)
}
}