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