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>
527 lines
18 KiB
Swift
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(.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)
|
|
}
|