- Add a11y label to ProgressMapView reset button and progress bar values - Fix CADisplayLink retain cycle in ItineraryTableViewController via deinit - Add [weak self] to PhotoGalleryViewModel Task closure - Add @MainActor to TripWizardViewModel, remove manual MainActor.run hop - Fix O(n²) rank lookup in PollDetailView/DebugPollPreviewView with enumerated() - Cache itinerarySections via ItinerarySectionBuilder static extraction + @State - Convert CanonicalSyncService/BootstrapService from actor to @MainActor final class - Add .accessibilityHidden(true) to RegionMapSelector Map to prevent duplicate VoiceOver Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
708 lines
26 KiB
Swift
708 lines
26 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 = 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)
|
|
.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)
|
|
.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)
|
|
}
|