Files
workouts/Workouts/Views/Progress/AchievementsSection.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

102 lines
3.5 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 shows the how-to-earn
/// detail.
struct AchievementsSection: View {
let achievements: [Achievement]
@State private var selected: 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
Button {
selected = achievement
} label: {
AchievementBadge(achievement: achievement)
}
.buttonStyle(.plain)
}
}
}
.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.")
}
}
}
/// 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)")
}
}