// // 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 selectedSport: Sport? // nil = All sports @State private var selectedAchievement: AchievementProgress? var body: some View { ScrollView { VStack(spacing: Theme.Spacing.lg) { // Summary header achievementSummary .staggeredAnimation(index: 0) // Sport filter sportFilter .staggeredAnimation(index: 1) // Achievements grid achievementsGrid .staggeredAnimation(index: 2) } .padding(Theme.Spacing.md) } .themedBackground() .navigationTitle("Achievements") .toolbar { ToolbarItem(placement: .topBarTrailing) { // Share only achievements matching current filter let achievementsToShare = selectedSport != nil ? filteredAchievements.filter { $0.isEarned } : earnedAchievements // Collect sports for background: single sport if filtered, all sports from achievements if "All" let sportsForBackground: Set = { if let sport = selectedSport { return [sport] } // Extract all unique sports from earned achievements return Set(achievementsToShare.compactMap { $0.definition.sport }) }() if !achievementsToShare.isEmpty { ShareButton( content: AchievementCollectionContent( achievements: achievementsToShare, year: Calendar.current.component(.year, from: Date()), sports: sportsForBackground, filterSport: selectedSport ), style: .icon ) .foregroundStyle(Theme.warmOrange) } } } .task { await loadAchievements() } .sheet(item: $selectedAchievement) { achievement in AchievementDetailSheet(achievement: achievement) .presentationDetents([.medium]) } } // MARK: - Achievement Summary private var achievementSummary: some View { // Use filtered achievements to show relevant counts let displayAchievements = filteredAchievements let earned = displayAchievements.filter { $0.isEarned }.count let total = displayAchievements.count let progress = total > 0 ? Double(earned) / Double(total) : 0 let completedGold = colorScheme == .dark ? Color(hex: "FFD700") : Color(hex: "B8860B") let filterTitle = selectedSport?.displayName ?? "All Sports" let accentColor = selectedSport?.themeColor ?? Theme.warmOrange return HStack(spacing: Theme.Spacing.lg) { // Trophy icon with progress ring ZStack { // Background circle Circle() .stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 6) .frame(width: 76, height: 76) // Progress ring Circle() .trim(from: 0, to: progress) .stroke( LinearGradient( colors: earned > 0 ? [completedGold, Color(hex: "FFA500")] : [accentColor, accentColor.opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing ), style: StrokeStyle(lineWidth: 6, lineCap: .round) ) .frame(width: 76, height: 76) .rotationEffect(.degrees(-90)) // Inner circle Circle() .fill(earned > 0 ? completedGold.opacity(0.15) : accentColor.opacity(0.15)) .frame(width: 64, height: 64) Image(systemName: selectedSport?.iconName ?? "trophy.fill") .font(.title2) .foregroundStyle(earned > 0 ? completedGold : accentColor) .accessibilityHidden(true) } VStack(alignment: .leading, spacing: Theme.Spacing.xs) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text("\(earned)") .font(.system(.largeTitle, design: .rounded).weight(.bold)) .monospacedDigit() .foregroundStyle(earned > 0 ? completedGold : Theme.textPrimary(colorScheme)) Text("/ \(total)") .font(.title2) .foregroundStyle(Theme.textSecondary(colorScheme)) } Text("\(filterTitle) Achievements") .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(completedGold) } else if earned > 0 { HStack(spacing: 4) { Image(systemName: "flame.fill") Text("\(total - earned) more to go!") } .font(.subheadline) .foregroundStyle(accentColor) } } Spacer() } .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .overlay { RoundedRectangle(cornerRadius: Theme.CornerRadius.large) .stroke(earned > 0 ? completedGold.opacity(0.3) : Theme.surfaceGlow(colorScheme), lineWidth: earned > 0 ? 2 : 1) } .shadow(color: earned > 0 ? completedGold.opacity(0.2) : Theme.cardShadow(colorScheme), radius: 10, y: 5) } // MARK: - Sport Filter private var sportFilter: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Theme.Spacing.sm) { // All sports SportFilterButton( title: "All", icon: "star.fill", color: Theme.warmOrange, isSelected: selectedSport == nil ) { Theme.Animation.withMotion(Theme.Animation.spring) { selectedSport = nil } } // Sport-specific filters ForEach(Sport.supported) { sport in SportFilterButton( title: sport.displayName, icon: sport.iconName, color: sport.themeColor, isSelected: selectedSport == sport ) { Theme.Animation.withMotion(Theme.Animation.spring) { selectedSport = sport } } } } } } // 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) .contentShape(Rectangle()) .onTapGesture { selectedAchievement = achievement } } } } private var earnedAchievements: [AchievementProgress] { achievements.filter { $0.isEarned } } private var filteredAchievements: [AchievementProgress] { let filtered: [AchievementProgress] if let sport = selectedSport { // Filter to achievements for this sport only filtered = achievements.filter { achievement in // Include if achievement is sport-specific and matches, OR if it's cross-sport (nil) achievement.definition.sport == sport } } else { // "All" - show all achievements filtered = achievements } return filtered.sorted { first, second in // Earned first, then by progress percentage 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: - Sport Filter Button struct SportFilterButton: View { let title: String let icon: String let color: Color 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) .fontWeight(isSelected ? .semibold : .regular) } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) .background(isSelected ? color : Theme.cardBackground(colorScheme)) .foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme)) .clipShape(Capsule()) .contentShape(Capsule()) .overlay { Capsule() .stroke(isSelected ? Color.clear : color.opacity(0.3), lineWidth: 1) } } .buttonStyle(.plain) .accessibilityValue(isSelected ? "Selected" : "Not selected") .accessibilityAddTraits(isSelected ? .isSelected : []) } } // MARK: - Achievement Card struct AchievementCard: View { let achievement: AchievementProgress @Environment(\.colorScheme) private var colorScheme // Gold that's readable in both light and dark mode private var completedGold: Color { colorScheme == .dark ? Color(hex: "FFD700") : Color(hex: "B8860B") } var body: some View { VStack(spacing: Theme.Spacing.sm) { // Badge icon ZStack { Circle() .fill(badgeBackgroundColor) .frame(width: 60, height: 60) if achievement.isEarned { // Gold ring for completed Circle() .stroke(completedGold, lineWidth: 3) .frame(width: 64, height: 64) } Image(systemName: achievement.definition.iconName) .font(.title2) .foregroundStyle(badgeIconColor) .accessibilityHidden(true) if !achievement.isEarned { Circle() .fill(.black.opacity(0.3)) .frame(width: 60, height: 60) Image(systemName: "lock.fill") .font(.subheadline) .foregroundStyle(.white) .accessibilityHidden(true) } } // Title Text(achievement.definition.name) .font(.subheadline) .fontWeight(achievement.isEarned ? .semibold : .regular) .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 { HStack(spacing: 4) { Image(systemName: "checkmark.seal.fill") .font(.caption) .accessibilityHidden(true) if let earnedAt = achievement.earnedAt { Text(earnedAt.formatted(date: .abbreviated, time: .omitted)) } else { Text("Completed") } } .font(.caption) .foregroundStyle(completedGold) } else { // Progress bar VStack(spacing: 4) { ProgressView(value: achievement.progressPercentage) .progressViewStyle(AchievementProgressStyle(category: achievement.definition.category)) .accessibilityValue("\(Int(achievement.progressPercentage * 100)) percent, \(achievement.progressText)") Text(achievement.progressText) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) } } } .padding(Theme.Spacing.md) .frame(maxWidth: .infinity, minHeight: 170) .background(achievement.isEarned ? completedGold.opacity(0.08) : Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(achievement.isEarned ? completedGold : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1) } .shadow(color: achievement.isEarned ? completedGold.opacity(0.3) : Theme.cardShadow(colorScheme), radius: achievement.isEarned ? 8 : 5, y: 2) .opacity(achievement.isEarned ? 1.0 : 0.7) .accessibilityElement(children: .combine) } private var badgeBackgroundColor: Color { if achievement.isEarned { return completedGold.opacity(0.2) } return Theme.cardBackgroundElevated(colorScheme) } private var badgeIconColor: Color { if achievement.isEarned { return completedGold } 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 { let category: AchievementCategory @Environment(\.colorScheme) private var colorScheme init(category: AchievementCategory = .count) { self.category = category } private var progressGradient: LinearGradient { let colors: [Color] = switch category { case .count: [Theme.warmOrange, Color(hex: "FF8C42")] case .division: [Theme.routeGold, Color(hex: "FFA500")] case .conference: [Theme.routeAmber, Color(hex: "FF6B35")] case .league: [Color(hex: "FFD700"), Color(hex: "FFC107")] case .journey: [Color(hex: "9B59B6"), Color(hex: "8E44AD")] case .special: [Color(hex: "E74C3C"), Color(hex: "C0392B")] } return LinearGradient(colors: colors, startPoint: .leading, endPoint: .trailing) } func makeBody(configuration: Configuration) -> some View { GeometryReader { geometry in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3) .fill(Theme.cardBackgroundElevated(colorScheme)) .frame(height: 6) RoundedRectangle(cornerRadius: 3) .fill(progressGradient) .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 6) } } .frame(height: 6) } } // MARK: - Achievement Detail Sheet struct AchievementDetailSheet: View { let achievement: AchievementProgress @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss // Gold that's readable in both light and dark mode private var completedGold: Color { colorScheme == .dark ? Color(hex: "FFD700") : Color(hex: "B8860B") } var body: some View { NavigationStack { VStack(spacing: Theme.Spacing.xl) { // Large badge ZStack { Circle() .fill(badgeBackgroundColor) .frame(width: 120, height: 120) if achievement.isEarned { // Gold ring with glow effect Circle() .stroke( LinearGradient( colors: [completedGold, Color(hex: "FFA500")], startPoint: .topLeading, endPoint: .bottomTrailing ), lineWidth: 5 ) .frame(width: 130, height: 130) .shadow(color: completedGold.opacity(0.5), radius: 10) } Image(systemName: achievement.definition.iconName) .font(.largeTitle) .foregroundStyle(badgeIconColor) .accessibilityHidden(true) if !achievement.isEarned { Circle() .fill(.black.opacity(0.3)) .frame(width: 120, height: 120) Image(systemName: "lock.fill") .font(.title3) .foregroundStyle(.white) .accessibilityHidden(true) } } // Title and description VStack(spacing: Theme.Spacing.sm) { Text(achievement.definition.name) .font(.title2) .fontWeight(achievement.isEarned ? .bold : .regular) .foregroundStyle(achievement.isEarned ? completedGold : Theme.textPrimary(colorScheme)) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) Text(achievement.definition.description) .font(.body) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) // Category badge HStack(spacing: 4) { Image(systemName: achievement.definition.category.iconName) Text(achievement.definition.category.displayName) } .font(.subheadline) .foregroundStyle(achievement.isEarned ? completedGold : categoryColor) .padding(.horizontal, Theme.Spacing.sm) .padding(.vertical, Theme.Spacing.xs) .background((achievement.isEarned ? completedGold : categoryColor).opacity(0.15)) .clipShape(Capsule()) } // Status section if achievement.isEarned { VStack(spacing: 8) { Image(systemName: "checkmark.seal.fill") .font(.title) .foregroundStyle(completedGold) .accessibilityHidden(true) if let earnedAt = achievement.earnedAt { Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) } else { Text("Achievement Unlocked!") .font(.headline) .foregroundStyle(completedGold) } } .padding() .background(completedGold.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } 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) .accessibilityValue("\(Int(achievement.progressPercentage * 100)) percent, \(achievement.currentProgress) of \(achievement.totalRequired)") 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) .accessibilityLabel(sport.displayName) 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() } } if achievement.isEarned { ToolbarItem(placement: .primaryAction) { ShareButton( content: AchievementSpotlightContent(achievement: achievement), style: .icon ) .foregroundStyle(completedGold) } } } } } private var badgeBackgroundColor: Color { if achievement.isEarned { return completedGold.opacity(0.2) } return Theme.cardBackgroundElevated(colorScheme) } private var badgeIconColor: Color { if achievement.isEarned { return completedGold } 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 private var progressGradient: LinearGradient { LinearGradient( colors: [Theme.warmOrange, Color(hex: "FF6B35")], startPoint: .leading, endPoint: .trailing ) } func makeBody(configuration: Configuration) -> some View { GeometryReader { geometry in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 4) .fill(Theme.cardBackgroundElevated(colorScheme)) .frame(height: 10) RoundedRectangle(cornerRadius: 4) .fill(progressGradient) .frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 10) } } .frame(height: 10) } } // 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) }