Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements: - Add StadiumVisit and Achievement SwiftData models - Create Progress tab with interactive map view - Implement photo-based visit import with GPS/date matching - Add achievement badges (count-based, regional, journey) - Create shareable progress cards for social media - Add canonical data infrastructure (stadium identities, team aliases) - Implement score resolution from free APIs (MLB, NBA, NHL stats) UI Improvements: - Add ThemedSpinner and ThemedSpinnerCompact components - Replace all ProgressView() with themed spinners throughout app - Fix sport selection state not persisting when navigating away Bug Fixes: - Fix Coast to Coast trips showing only 1 city (validation issue) - Fix stadium progress showing 0/0 (filtering issue) - Remove "Stadium Quest" title from progress view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
526
SportsTime/Features/Progress/Views/AchievementsListView.swift
Normal file
526
SportsTime/Features/Progress/Views/AchievementsListView.swift
Normal file
@@ -0,0 +1,526 @@
|
||||
//
|
||||
// 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(.system(size: Theme.FontSize.heroTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Achievements Earned")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
if earned == total && total > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "star.fill")
|
||||
Text("All achievements unlocked!")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.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(.system(size: 14))
|
||||
Text(title)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
}
|
||||
.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(.system(size: 14))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(achievement.definition.name)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.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(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
} else {
|
||||
// Progress bar
|
||||
VStack(spacing: 4) {
|
||||
ProgressView(value: achievement.progressPercentage)
|
||||
.progressViewStyle(AchievementProgressStyle())
|
||||
|
||||
Text(achievement.progressText)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.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(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(achievement.definition.description)
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Category badge
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: achievement.definition.category.iconName)
|
||||
Text(achievement.definition.category.displayName)
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.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(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Progress section
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text("Progress")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
ProgressView(value: achievement.progressPercentage)
|
||||
.progressViewStyle(LargeProgressStyle())
|
||||
.frame(width: 200)
|
||||
|
||||
Text("\(achievement.currentProgress) / \(achievement.totalRequired)")
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold))
|
||||
.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(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.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)
|
||||
}
|
||||
Reference in New Issue
Block a user