160 lines
6.0 KiB
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)
|
|
}
|
|
}
|