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:
Trey t
2026-01-11 22:22:29 -06:00
parent dcd5edb229
commit 5c13650742
20 changed files with 1619 additions and 141 deletions

View File

@@ -47,6 +47,9 @@ final class PhotoImportViewModel {
func processSelectedPhotos(_ items: [PhotosPickerItem]) async {
guard !items.isEmpty else { return }
print("📷 [PhotoImport] ════════════════════════════════════════════════")
print("📷 [PhotoImport] Starting photo import with \(items.count) items")
isProcessing = true
totalCount = items.count
processedCount = 0
@@ -57,33 +60,69 @@ final class PhotoImportViewModel {
// Load PHAssets from PhotosPickerItems
var assets: [PHAsset] = []
for item in items {
for (index, item) in items.enumerated() {
print("📷 [PhotoImport] ────────────────────────────────────────────────")
print("📷 [PhotoImport] Processing item \(index + 1)/\(items.count)")
print("📷 [PhotoImport] Item identifier: \(item.itemIdentifier ?? "nil")")
print("📷 [PhotoImport] Item supportedContentTypes: \(item.supportedContentTypes)")
if let assetId = item.itemIdentifier {
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
print("📷 [PhotoImport] PHAsset fetch result count: \(fetchResult.count)")
if let asset = fetchResult.firstObject {
print("📷 [PhotoImport] ✅ Found PHAsset")
print("📷 [PhotoImport] - localIdentifier: \(asset.localIdentifier)")
print("📷 [PhotoImport] - mediaType: \(asset.mediaType.rawValue)")
print("📷 [PhotoImport] - creationDate: \(asset.creationDate?.description ?? "nil")")
print("📷 [PhotoImport] - location: \(asset.location?.description ?? "nil")")
print("📷 [PhotoImport] - sourceType: \(asset.sourceType.rawValue)")
print("📷 [PhotoImport] - pixelWidth: \(asset.pixelWidth)")
print("📷 [PhotoImport] - pixelHeight: \(asset.pixelHeight)")
assets.append(asset)
} else {
print("📷 [PhotoImport] ⚠️ No PHAsset found for identifier")
}
} else {
print("📷 [PhotoImport] ⚠️ No itemIdentifier on PhotosPickerItem")
}
processedCount += 1
}
print("📷 [PhotoImport] ────────────────────────────────────────────────")
print("📷 [PhotoImport] Loaded \(assets.count) PHAssets, extracting metadata...")
// Extract metadata from all assets
let metadataList = await metadataExtractor.extractMetadata(from: assets)
print("📷 [PhotoImport] ────────────────────────────────────────────────")
print("📷 [PhotoImport] Extracted \(metadataList.count) metadata records")
// Summarize metadata extraction results
let withLocation = metadataList.filter { $0.hasValidLocation }.count
let withDate = metadataList.filter { $0.hasValidDate }.count
print("📷 [PhotoImport] Photos with location: \(withLocation)/\(metadataList.count)")
print("📷 [PhotoImport] Photos with date: \(withDate)/\(metadataList.count)")
// Process each photo through game matcher
processedCount = 0
for metadata in metadataList {
for (index, metadata) in metadataList.enumerated() {
print("📷 [PhotoImport] Matching photo \(index + 1): date=\(metadata.captureDate?.description ?? "nil"), location=\(metadata.hasValidLocation)")
let candidate = await gameMatcher.processPhotoForImport(metadata: metadata)
processedPhotos.append(candidate)
// Auto-confirm high-confidence matches
if candidate.canAutoProcess {
confirmedImports.insert(candidate.id)
print("📷 [PhotoImport] ✅ Auto-confirmed match")
}
processedCount += 1
}
print("📷 [PhotoImport] ════════════════════════════════════════════════")
print("📷 [PhotoImport] Import complete: \(processedPhotos.count) photos, \(confirmedImports.count) auto-confirmed")
isProcessing = false
}
@@ -143,8 +182,8 @@ final class PhotoImportViewModel {
visitType: .game,
homeTeamName: match.homeTeam.fullName,
awayTeamName: match.awayTeam.fullName,
finalScore: nil,
scoreSource: nil,
finalScore: match.formattedFinalScore,
scoreSource: match.formattedFinalScore != nil ? .scraped : nil,
dataSource: .automatic,
seatLocation: nil,
notes: nil,

View File

@@ -77,7 +77,8 @@ final class ProgressViewModel {
visitDate: visit.visitDate,
visitType: visit.visitType,
sport: selectedSport,
matchup: visit.matchupDescription,
homeTeamName: visit.homeTeamName,
awayTeamName: visit.awayTeamName,
score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes
@@ -123,7 +124,8 @@ final class ProgressViewModel {
visitDate: visit.visitDate,
visitType: visit.visitType,
sport: sport,
matchup: visit.matchupDescription,
homeTeamName: visit.homeTeamName,
awayTeamName: visit.awayTeamName,
score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes

View File

@@ -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)
}
}

View File

@@ -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))

View File

@@ -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))

View File

@@ -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))

View File

@@ -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

View File

@@ -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)