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
102 lines
3.5 KiB
Swift
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)")
|
|
}
|
|
}
|