Files
workouts/Workouts/Views/Progress/AchievementsSection.swift

160 lines
6.0 KiB
Swift

//
// AchievementsSection.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
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 pushes the badge's
/// detail screen with the how-to-earn rule and its story.
struct AchievementsSection: View {
let achievements: [Achievement]
private var earnedCount: Int { achievements.filter(\.earned).count }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Achievements")
.font(.headline)
Spacer()
Text("\(earnedCount) of \(achievements.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4),
spacing: 16) {
ForEach(achievements) { achievement in
NavigationLink {
AchievementDetailView(achievement: achievement)
} label: {
AchievementBadge(achievement: achievement)
}
.buttonStyle(.plain)
}
}
}
.progressCard()
}
}
/// One badge: a tinted circle with the symbol, the title beneath. Locked badges are
/// desaturated with a thin progress arc around the circle.
struct AchievementBadge: View {
let achievement: Achievement
private var color: Color { Color.color(from: achievement.colorName) }
var body: some View {
VStack(spacing: 6) {
ZStack {
Circle()
.fill(achievement.earned ? color.opacity(0.18) : Color(.systemFill))
Image(systemName: achievement.systemImage)
.font(.title3)
.foregroundStyle(achievement.earned ? color : Color(.tertiaryLabel))
}
.frame(width: 52, height: 52)
.overlay {
if !achievement.earned, achievement.progress > 0 {
Circle()
.trim(from: 0, to: achievement.progress)
.stroke(color.opacity(0.6),
style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
}
}
Text(achievement.title)
.font(.caption2)
.multilineTextAlignment(.center)
.lineLimit(2, reservesSpace: true)
.foregroundStyle(achievement.earned ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityLabel(
"\(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)
}
}