Files
Sportstime/SportsTime/Features/Progress/Views/AchievementsListView.swift
Trey t 2d48f1411a feat: implement Dynamic Type with Apple text styles
Replace all custom Theme.FontSize values and hardcoded font sizes with
Apple's built-in text styles (.largeTitle, .title2, .headline, .body,
.subheadline, .caption, .caption2) to support accessibility scaling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 10:23:16 -06:00

527 lines
18 KiB
Swift

//
// AchievementsListView.swift
// SportsTime
//
// Displays achievements gallery with earned, in-progress, and locked badges.
//
import SwiftUI
import SwiftData
struct AchievementsListView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@State private var achievements: [AchievementProgress] = []
@State private var isLoading = true
@State private var selectedCategory: AchievementCategory?
@State private var selectedAchievement: AchievementProgress?
var body: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Summary header
achievementSummary
.staggeredAnimation(index: 0)
// Category filter
categoryFilter
.staggeredAnimation(index: 1)
// Achievements grid
achievementsGrid
.staggeredAnimation(index: 2)
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle("Achievements")
.task {
await loadAchievements()
}
.sheet(item: $selectedAchievement) { achievement in
AchievementDetailSheet(achievement: achievement)
.presentationDetents([.medium])
}
}
// MARK: - Achievement Summary
private var achievementSummary: some View {
let earned = achievements.filter { $0.isEarned }.count
let total = achievements.count
return HStack(spacing: Theme.Spacing.lg) {
// Trophy icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 70, height: 70)
Image(systemName: "trophy.fill")
.font(.system(size: 32))
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("\(earned) / \(total)")
.font(.largeTitle)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Achievements Earned")
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
if earned == total && total > 0 {
HStack(spacing: 4) {
Image(systemName: "star.fill")
Text("All achievements unlocked!")
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
}
Spacer()
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
}
// MARK: - Category Filter
private var categoryFilter: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
CategoryFilterButton(
title: "All",
icon: "square.grid.2x2",
isSelected: selectedCategory == nil
) {
withAnimation(Theme.Animation.spring) {
selectedCategory = nil
}
}
ForEach(AchievementCategory.allCases, id: \.self) { category in
CategoryFilterButton(
title: category.displayName,
icon: category.iconName,
isSelected: selectedCategory == category
) {
withAnimation(Theme.Animation.spring) {
selectedCategory = category
}
}
}
}
}
}
// MARK: - Achievements Grid
private var achievementsGrid: some View {
let filtered = filteredAchievements
return LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: Theme.Spacing.md
) {
ForEach(filtered) { achievement in
AchievementCard(achievement: achievement)
.onTapGesture {
selectedAchievement = achievement
}
}
}
}
private var filteredAchievements: [AchievementProgress] {
guard let category = selectedCategory else {
return achievements.sorted { first, second in
// Earned first, then by progress
if first.isEarned != second.isEarned {
return first.isEarned
}
return first.progressPercentage > second.progressPercentage
}
}
return achievements.filter { $0.definition.category == category }
.sorted { first, second in
if first.isEarned != second.isEarned {
return first.isEarned
}
return first.progressPercentage > second.progressPercentage
}
}
// MARK: - Data Loading
private func loadAchievements() async {
isLoading = true
do {
let engine = AchievementEngine(modelContext: modelContext)
achievements = try await engine.getProgress()
} catch {
// Handle error silently, show empty state
achievements = []
}
isLoading = false
}
}
// MARK: - Category Filter Button
struct CategoryFilterButton: View {
let title: String
let icon: String
let isSelected: Bool
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: action) {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: icon)
.font(.subheadline)
Text(title)
.font(.subheadline)
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background(isSelected ? Theme.warmOrange : Theme.cardBackground(colorScheme))
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
.clipShape(Capsule())
.overlay {
Capsule()
.stroke(isSelected ? Color.clear : Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Achievement Card
struct AchievementCard: View {
let achievement: AchievementProgress
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: Theme.Spacing.sm) {
// Badge icon
ZStack {
Circle()
.fill(badgeBackgroundColor)
.frame(width: 60, height: 60)
Image(systemName: achievement.definition.iconName)
.font(.system(size: 28))
.foregroundStyle(badgeIconColor)
if !achievement.isEarned {
Circle()
.fill(.black.opacity(0.3))
.frame(width: 60, height: 60)
Image(systemName: "lock.fill")
.font(.subheadline)
.foregroundStyle(.white)
}
}
// Title
Text(achievement.definition.name)
.font(.subheadline)
.foregroundStyle(achievement.isEarned ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
.multilineTextAlignment(.center)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
// Progress or earned date
if achievement.isEarned {
if let earnedAt = achievement.earnedAt {
Text(earnedAt.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(Theme.warmOrange)
}
} else {
// Progress bar
VStack(spacing: 4) {
ProgressView(value: achievement.progressPercentage)
.progressViewStyle(AchievementProgressStyle())
Text(achievement.progressText)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
}
.padding(Theme.Spacing.md)
.frame(maxWidth: .infinity, minHeight: 170)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(achievement.isEarned ? Theme.warmOrange.opacity(0.5) : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 5, y: 2)
.opacity(achievement.isEarned ? 1.0 : 0.7)
}
private var badgeBackgroundColor: Color {
if achievement.isEarned {
return categoryColor.opacity(0.2)
}
return Theme.cardBackgroundElevated(colorScheme)
}
private var badgeIconColor: Color {
if achievement.isEarned {
return categoryColor
}
return Theme.textMuted(colorScheme)
}
private var categoryColor: Color {
switch achievement.definition.category {
case .count:
return Theme.warmOrange
case .division:
return Theme.routeGold
case .conference:
return Theme.routeAmber
case .league:
return Color(hex: "FFD700") // Gold
case .journey:
return Color(hex: "9B59B6") // Purple
case .special:
return Color(hex: "E74C3C") // Red
}
}
}
// MARK: - Achievement Progress Style
struct AchievementProgressStyle: ProgressViewStyle {
@Environment(\.colorScheme) private var colorScheme
func makeBody(configuration: Configuration) -> some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Theme.cardBackgroundElevated(colorScheme))
.frame(height: 4)
RoundedRectangle(cornerRadius: 2)
.fill(Theme.warmOrange)
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 4)
}
}
.frame(height: 4)
}
}
// MARK: - Achievement Detail Sheet
struct AchievementDetailSheet: View {
let achievement: AchievementProgress
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(spacing: Theme.Spacing.xl) {
// Large badge
ZStack {
Circle()
.fill(badgeBackgroundColor)
.frame(width: 120, height: 120)
if achievement.isEarned {
Circle()
.stroke(Theme.warmOrange, lineWidth: 4)
.frame(width: 130, height: 130)
}
Image(systemName: achievement.definition.iconName)
.font(.system(size: 56))
.foregroundStyle(badgeIconColor)
if !achievement.isEarned {
Circle()
.fill(.black.opacity(0.3))
.frame(width: 120, height: 120)
Image(systemName: "lock.fill")
.font(.system(size: 24))
.foregroundStyle(.white)
}
}
// Title and description
VStack(spacing: Theme.Spacing.sm) {
Text(achievement.definition.name)
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(achievement.definition.description)
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
// Category badge
HStack(spacing: 4) {
Image(systemName: achievement.definition.category.iconName)
Text(achievement.definition.category.displayName)
}
.font(.subheadline)
.foregroundStyle(categoryColor)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(categoryColor.opacity(0.15))
.clipShape(Capsule())
}
// Status section
if achievement.isEarned {
if let earnedAt = achievement.earnedAt {
VStack(spacing: 4) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 24))
.foregroundStyle(.green)
Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
} else {
// Progress section
VStack(spacing: Theme.Spacing.sm) {
Text("Progress")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
ProgressView(value: achievement.progressPercentage)
.progressViewStyle(LargeProgressStyle())
.frame(width: 200)
Text("\(achievement.currentProgress) / \(achievement.totalRequired)")
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
// Sport badge if applicable
if let sport = achievement.definition.sport {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: sport.iconName)
Text(sport.displayName)
}
.font(.subheadline)
.foregroundStyle(sport.themeColor)
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background(sport.themeColor.opacity(0.15))
.clipShape(Capsule())
}
Spacer()
}
.padding(Theme.Spacing.lg)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
private var badgeBackgroundColor: Color {
if achievement.isEarned {
return categoryColor.opacity(0.2)
}
return Theme.cardBackgroundElevated(colorScheme)
}
private var badgeIconColor: Color {
if achievement.isEarned {
return categoryColor
}
return Theme.textMuted(colorScheme)
}
private var categoryColor: Color {
switch achievement.definition.category {
case .count:
return Theme.warmOrange
case .division:
return Theme.routeGold
case .conference:
return Theme.routeAmber
case .league:
return Color(hex: "FFD700")
case .journey:
return Color(hex: "9B59B6")
case .special:
return Color(hex: "E74C3C")
}
}
}
// MARK: - Large Progress Style
struct LargeProgressStyle: ProgressViewStyle {
@Environment(\.colorScheme) private var colorScheme
func makeBody(configuration: Configuration) -> some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Theme.cardBackgroundElevated(colorScheme))
.frame(height: 8)
RoundedRectangle(cornerRadius: 4)
.fill(Theme.warmOrange)
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 8)
}
}
.frame(height: 8)
}
}
// MARK: - Category Extensions
extension AchievementCategory {
var iconName: String {
switch self {
case .count: return "number.circle"
case .division: return "map"
case .conference: return "building.2"
case .league: return "crown"
case .journey: return "car.fill"
case .special: return "star.circle"
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
AchievementsListView()
}
.modelContainer(for: StadiumVisit.self, inMemory: true)
}