fix: resolve specificStadium achievement ID mismatch
The Green Monster (Fenway) and Ivy League (Wrigley) achievements weren't working because: 1. Symbolic IDs use lowercase sport (stadium_mlb_bos) 2. Sport enum uses uppercase raw values (MLB) 3. Visits store stadium UUIDs, not symbolic IDs Added resolveSymbolicStadiumId() helper that: - Uppercases the sport string before Sport(rawValue:) - Looks up team by abbreviation and sport - Returns the team's stadiumId as UUID string Also fixed: - getStadiumIdsForLeague returns UUID strings (not symbolic IDs) - AchievementProgress.isEarned computed from progress OR stored record - getStadiumIdsForDivision queries CanonicalTeam properly Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ struct AchievementsListView: View {
|
||||
|
||||
@State private var achievements: [AchievementProgress] = []
|
||||
@State private var isLoading = true
|
||||
@State private var selectedCategory: AchievementCategory?
|
||||
@State private var selectedSport: Sport? // nil = All sports
|
||||
@State private var selectedAchievement: AchievementProgress?
|
||||
|
||||
var body: some View {
|
||||
@@ -24,8 +24,8 @@ struct AchievementsListView: View {
|
||||
achievementSummary
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Category filter
|
||||
categoryFilter
|
||||
// Sport filter
|
||||
sportFilter
|
||||
.staggeredAnimation(index: 1)
|
||||
|
||||
// Achievements grid
|
||||
@@ -48,27 +48,58 @@ struct AchievementsListView: View {
|
||||
// MARK: - Achievement Summary
|
||||
|
||||
private var achievementSummary: some View {
|
||||
let earned = achievements.filter { $0.isEarned }.count
|
||||
let total = achievements.count
|
||||
// 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
|
||||
// Trophy icon with progress ring
|
||||
ZStack {
|
||||
// Background circle
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 70, height: 70)
|
||||
.stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 6)
|
||||
.frame(width: 76, height: 76)
|
||||
|
||||
Image(systemName: "trophy.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
// 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) {
|
||||
Text("\(earned) / \(total)")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
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("Achievements Earned")
|
||||
Text("\(filterTitle) Achievements")
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
@@ -78,7 +109,14 @@ struct AchievementsListView: View {
|
||||
Text("All achievements unlocked!")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
.foregroundStyle(completedGold)
|
||||
} else if earned > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill")
|
||||
Text("\(total - earned) more to go!")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,34 +127,38 @@ struct AchievementsListView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
.stroke(earned > 0 ? completedGold.opacity(0.3) : Theme.surfaceGlow(colorScheme), lineWidth: earned > 0 ? 2 : 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
|
||||
.shadow(color: earned > 0 ? completedGold.opacity(0.2) : Theme.cardShadow(colorScheme), radius: 10, y: 5)
|
||||
}
|
||||
|
||||
// MARK: - Category Filter
|
||||
// MARK: - Sport Filter
|
||||
|
||||
private var categoryFilter: some View {
|
||||
private var sportFilter: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
CategoryFilterButton(
|
||||
// All sports
|
||||
SportFilterButton(
|
||||
title: "All",
|
||||
icon: "square.grid.2x2",
|
||||
isSelected: selectedCategory == nil
|
||||
icon: "star.fill",
|
||||
color: Theme.warmOrange,
|
||||
isSelected: selectedSport == nil
|
||||
) {
|
||||
withAnimation(Theme.Animation.spring) {
|
||||
selectedCategory = nil
|
||||
selectedSport = nil
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(AchievementCategory.allCases, id: \.self) { category in
|
||||
CategoryFilterButton(
|
||||
title: category.displayName,
|
||||
icon: category.iconName,
|
||||
isSelected: selectedCategory == category
|
||||
// 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) {
|
||||
selectedCategory = category
|
||||
selectedSport = sport
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,22 +185,26 @@ struct AchievementsListView: View {
|
||||
}
|
||||
|
||||
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
|
||||
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 achievements.filter { $0.definition.category == category }
|
||||
.sorted { first, second in
|
||||
if first.isEarned != second.isEarned {
|
||||
return first.isEarned
|
||||
}
|
||||
return first.progressPercentage > second.progressPercentage
|
||||
|
||||
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
|
||||
@@ -176,11 +222,12 @@ struct AchievementsListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter Button
|
||||
// MARK: - Sport Filter Button
|
||||
|
||||
struct CategoryFilterButton: View {
|
||||
struct SportFilterButton: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@@ -193,15 +240,16 @@ struct CategoryFilterButton: View {
|
||||
.font(.subheadline)
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(isSelected ? Theme.warmOrange : Theme.cardBackground(colorScheme))
|
||||
.background(isSelected ? color : Theme.cardBackground(colorScheme))
|
||||
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(isSelected ? Color.clear : Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
.stroke(isSelected ? Color.clear : color.opacity(0.3), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -215,6 +263,9 @@ struct AchievementCard: View {
|
||||
|
||||
@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
|
||||
@@ -223,6 +274,13 @@ struct AchievementCard: View {
|
||||
.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)
|
||||
@@ -241,6 +299,7 @@ struct AchievementCard: View {
|
||||
// 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)
|
||||
@@ -248,16 +307,22 @@ struct AchievementCard: View {
|
||||
|
||||
// Progress or earned date
|
||||
if achievement.isEarned {
|
||||
if let earnedAt = achievement.earnedAt {
|
||||
Text(earnedAt.formatted(date: .abbreviated, time: .omitted))
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
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())
|
||||
.progressViewStyle(AchievementProgressStyle(category: achievement.definition.category))
|
||||
|
||||
Text(achievement.progressText)
|
||||
.font(.caption)
|
||||
@@ -267,26 +332,26 @@ struct AchievementCard: View {
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.frame(maxWidth: .infinity, minHeight: 170)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.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 ? Theme.warmOrange.opacity(0.5) : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1)
|
||||
.stroke(achievement.isEarned ? completedGold : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 5, y: 2)
|
||||
.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 categoryColor.opacity(0.2)
|
||||
return completedGold.opacity(0.2)
|
||||
}
|
||||
return Theme.cardBackgroundElevated(colorScheme)
|
||||
}
|
||||
|
||||
private var badgeIconColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor
|
||||
return completedGold
|
||||
}
|
||||
return Theme.textMuted(colorScheme)
|
||||
}
|
||||
@@ -312,21 +377,44 @@ struct AchievementCard: View {
|
||||
// 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: 2)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Theme.cardBackgroundElevated(colorScheme))
|
||||
.frame(height: 4)
|
||||
.frame(height: 6)
|
||||
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 4)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(progressGradient)
|
||||
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 6)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +426,9 @@ struct AchievementDetailSheet: View {
|
||||
@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) {
|
||||
@@ -348,9 +439,18 @@ struct AchievementDetailSheet: View {
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
if achievement.isEarned {
|
||||
// Gold ring with glow effect
|
||||
Circle()
|
||||
.stroke(Theme.warmOrange, lineWidth: 4)
|
||||
.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)
|
||||
@@ -372,7 +472,8 @@ struct AchievementDetailSheet: View {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text(achievement.definition.name)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.fontWeight(achievement.isEarned ? .bold : .regular)
|
||||
.foregroundStyle(achievement.isEarned ? completedGold : Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(achievement.definition.description)
|
||||
.font(.body)
|
||||
@@ -385,26 +486,33 @@ struct AchievementDetailSheet: View {
|
||||
Text(achievement.definition.category.displayName)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(categoryColor)
|
||||
.foregroundStyle(achievement.isEarned ? completedGold : categoryColor)
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.background(categoryColor.opacity(0.15))
|
||||
.background((achievement.isEarned ? completedGold : 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)
|
||||
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) {
|
||||
@@ -450,14 +558,14 @@ struct AchievementDetailSheet: View {
|
||||
|
||||
private var badgeBackgroundColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor.opacity(0.2)
|
||||
return completedGold.opacity(0.2)
|
||||
}
|
||||
return Theme.cardBackgroundElevated(colorScheme)
|
||||
}
|
||||
|
||||
private var badgeIconColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor
|
||||
return completedGold
|
||||
}
|
||||
return Theme.textMuted(colorScheme)
|
||||
}
|
||||
@@ -485,19 +593,27 @@ struct AchievementDetailSheet: View {
|
||||
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: 8)
|
||||
.frame(height: 10)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 8)
|
||||
.fill(progressGradient)
|
||||
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 10)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
.frame(height: 10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ struct GameMatchConfirmationView: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(match.matchupDescription)
|
||||
Text(match.fullMatchupDescription)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
|
||||
@@ -433,7 +433,7 @@ struct PhotoImportCandidateCard: View {
|
||||
private func matchRow(_ match: GameMatchCandidate) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(match.matchupDescription)
|
||||
Text(match.fullMatchupDescription)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
|
||||
@@ -477,11 +477,13 @@ struct RecentVisitRow: View {
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
// Date, Away @ Home on one line, left aligned
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(visit.shortDateDescription)
|
||||
if let matchup = visit.matchup {
|
||||
Text("•")
|
||||
Text(matchup)
|
||||
if let away = visit.awayTeamName, let home = visit.homeTeamName {
|
||||
Text(away)
|
||||
Text("@")
|
||||
Text(home)
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
@@ -490,15 +492,6 @@ struct RecentVisitRow: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if visit.photoCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "photo")
|
||||
Text("\(visit.photoCount)")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
@@ -33,6 +33,8 @@ struct StadiumVisitSheet: View {
|
||||
// UI state
|
||||
@State private var showStadiumPicker = false
|
||||
@State private var isSaving = false
|
||||
@State private var isLookingUpGame = false
|
||||
@State private var scoreFromScraper = false // Track if score was auto-filled
|
||||
@State private var errorMessage: String?
|
||||
@State private var showAwayTeamSuggestions = false
|
||||
@State private var showHomeTeamSuggestions = false
|
||||
@@ -220,10 +222,36 @@ struct StadiumVisitSheet: View {
|
||||
.frame(width: 50)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// Look Up Game Button
|
||||
if selectedStadium != nil {
|
||||
Button {
|
||||
Task {
|
||||
await lookUpGame()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if isLookingUpGame {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
Text("Look Up Game")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
.disabled(isLookingUpGame)
|
||||
}
|
||||
} header: {
|
||||
Text("Game Info")
|
||||
} footer: {
|
||||
Text("Leave blank if you don't remember the score")
|
||||
if selectedStadium != nil {
|
||||
Text("Tap 'Look Up Game' to auto-fill teams and score from historical data")
|
||||
} else {
|
||||
Text("Select a stadium to enable game lookup")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
@@ -320,6 +348,36 @@ struct StadiumVisitSheet: View {
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func lookUpGame() async {
|
||||
guard let stadium = selectedStadium else { return }
|
||||
|
||||
isLookingUpGame = true
|
||||
errorMessage = nil
|
||||
|
||||
// Use the historical game scraper
|
||||
if let scrapedGame = await HistoricalGameScraper.shared.scrapeGame(
|
||||
stadium: stadium,
|
||||
date: visitDate
|
||||
) {
|
||||
// Fill in the form with scraped data
|
||||
awayTeamName = scrapedGame.awayTeam
|
||||
homeTeamName = scrapedGame.homeTeam
|
||||
|
||||
if let away = scrapedGame.awayScore {
|
||||
awayScore = String(away)
|
||||
scoreFromScraper = true
|
||||
}
|
||||
if let home = scrapedGame.homeScore {
|
||||
homeScore = String(home)
|
||||
scoreFromScraper = true
|
||||
}
|
||||
} else {
|
||||
errorMessage = "No game found for \(stadium.name) on this date"
|
||||
}
|
||||
|
||||
isLookingUpGame = false
|
||||
}
|
||||
|
||||
private func saveVisit() {
|
||||
guard let stadium = selectedStadium else {
|
||||
errorMessage = "Please select a stadium"
|
||||
@@ -340,8 +398,8 @@ struct StadiumVisitSheet: View {
|
||||
homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName,
|
||||
awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName,
|
||||
finalScore: finalScoreString,
|
||||
scoreSource: finalScoreString != nil ? .user : nil,
|
||||
dataSource: .fullyManual,
|
||||
scoreSource: finalScoreString != nil ? (scoreFromScraper ? .scraped : .user) : nil,
|
||||
dataSource: scoreFromScraper ? .automatic : .fullyManual,
|
||||
seatLocation: seatLocation.isEmpty ? nil : seatLocation,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
source: .manual
|
||||
|
||||
@@ -194,25 +194,25 @@ struct VisitDetailView: View {
|
||||
}
|
||||
|
||||
if let matchup = visit.matchupDescription {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Matchup")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(matchup)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let score = visit.finalScore {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Final Score")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(score)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.font(.body)
|
||||
@@ -238,42 +238,42 @@ struct VisitDetailView: View {
|
||||
}
|
||||
|
||||
// Date
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Date")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(formattedDate)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Seat location
|
||||
if let seat = visit.seatLocation, !seat.isEmpty {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Seat")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(seat)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// Source
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Source")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(visit.source.displayName)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Created date
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Logged")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(formattedCreatedDate)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.font(.body)
|
||||
.padding(Theme.Spacing.lg)
|
||||
|
||||
Reference in New Issue
Block a user