Files
Sportstime/SportsTime/Features/Progress/Views/AchievementsListView.swift
Trey t 3d4952e5ff feat(ui): add sport backgrounds to share cards, achievement filtering, and wizard validation
- Add ShareCardSportBackground with floating sport icons for share cards
- Share cards now show sport-specific backgrounds (single or multiple sports)
- Achievement collection share respects sport filter selection
- Add ability to share individual achievements from detail sheet
- Trip wizard ReviewStep highlights missing required fields in red
- Add FieldValidation model to TripWizardViewModel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:02:57 -06:00

690 lines
25 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 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<Sport> = {
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 = Color(hex: "FFD700")
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(.system(size: 28))
.foregroundStyle(earned > 0 ? completedGold : accentColor)
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(earned)")
.font(.system(size: 36, weight: .bold, design: .rounded))
.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
) {
withAnimation(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
) {
withAnimation(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)
.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())
.overlay {
Capsule()
.stroke(isSelected ? Color.clear : color.opacity(0.3), lineWidth: 1)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Achievement Card
struct AchievementCard: View {
let achievement: AchievementProgress
@Environment(\.colorScheme) private var colorScheme
// Gold color for completed achievements
private let completedGold = Color(hex: "FFD700")
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(.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)
.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)
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))
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)
}
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 color for completed achievements
private let completedGold = Color(hex: "FFD700")
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(.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)
.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(.system(size: 32))
.foregroundStyle(completedGold)
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)
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() }
}
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)
}