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