Adds 3 new UI tests covering stadium visit manual entry, required field validation, and games history navigation. Includes accessibility IDs on StadiumVisitSheet/ProgressTabView and new page objects (StadiumVisitSheetScreen, GamesHistoryScreen) in the test framework. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
521 lines
21 KiB
Swift
521 lines
21 KiB
Swift
//
|
|
// StadiumVisitSheet.swift
|
|
// SportsTime
|
|
//
|
|
// Sheet for manually logging a stadium visit.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct StadiumVisitSheet: View {
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
// Optional pre-selected values
|
|
var initialStadium: Stadium?
|
|
var initialSport: Sport?
|
|
var onSave: ((StadiumVisit) -> Void)?
|
|
|
|
// Form state
|
|
@State private var selectedSport: Sport
|
|
@State private var selectedStadium: Stadium?
|
|
@State private var visitDate: Date = Date()
|
|
@State private var visitType: VisitType = .game
|
|
@State private var homeTeamName: String = ""
|
|
@State private var awayTeamName: String = ""
|
|
@State private var homeScore: String = ""
|
|
@State private var awayScore: String = ""
|
|
@State private var seatLocation: String = ""
|
|
@State private var notes: String = ""
|
|
|
|
// 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
|
|
@FocusState private var awayTeamFocused: Bool
|
|
@FocusState private var homeTeamFocused: Bool
|
|
|
|
// Data
|
|
@ObservedObject private var dataProvider = AppDataProvider.shared
|
|
|
|
// Teams for autocomplete
|
|
private var teamsForSport: [Team] {
|
|
dataProvider.teams.filter { $0.sport == selectedSport }
|
|
.sorted { $0.name < $1.name }
|
|
}
|
|
|
|
private var awayTeamSuggestions: [Team] {
|
|
guard !awayTeamName.isEmpty else { return teamsForSport }
|
|
return teamsForSport.filter {
|
|
$0.name.localizedCaseInsensitiveContains(awayTeamName) ||
|
|
$0.city.localizedCaseInsensitiveContains(awayTeamName)
|
|
}
|
|
}
|
|
|
|
private var homeTeamSuggestions: [Team] {
|
|
guard !homeTeamName.isEmpty else { return teamsForSport }
|
|
return teamsForSport.filter {
|
|
$0.name.localizedCaseInsensitiveContains(homeTeamName) ||
|
|
$0.city.localizedCaseInsensitiveContains(homeTeamName)
|
|
}
|
|
}
|
|
|
|
init(
|
|
initialStadium: Stadium? = nil,
|
|
initialSport: Sport? = nil,
|
|
onSave: ((StadiumVisit) -> Void)? = nil
|
|
) {
|
|
self.initialStadium = initialStadium
|
|
self.initialSport = initialSport
|
|
self.onSave = onSave
|
|
_selectedSport = State(initialValue: initialSport ?? .mlb)
|
|
_selectedStadium = State(initialValue: initialStadium)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// Sport & Stadium Section
|
|
Section {
|
|
// Sport Picker
|
|
Picker("Sport", selection: $selectedSport) {
|
|
ForEach(Sport.supported) { sport in
|
|
HStack {
|
|
Image(systemName: sport.iconName)
|
|
Text(sport.displayName)
|
|
}
|
|
.tag(sport)
|
|
}
|
|
}
|
|
|
|
// Stadium Selection
|
|
Button {
|
|
showStadiumPicker = true
|
|
} label: {
|
|
HStack {
|
|
Text("Stadium")
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Spacer()
|
|
if let stadium = selectedStadium {
|
|
Text(stadium.name)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
} else {
|
|
Text("Select Stadium")
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
.accessibilityIdentifier("visitSheet.stadiumButton")
|
|
.accessibilityLabel(selectedStadium != nil ? "Stadium: \(selectedStadium!.name)" : "Select stadium")
|
|
.accessibilityHint("Opens stadium picker")
|
|
} header: {
|
|
Text("Location")
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
|
|
// Visit Details Section
|
|
Section {
|
|
DatePicker("Date", selection: $visitDate, displayedComponents: .date)
|
|
.accessibilityHint("Select the date you visited this stadium")
|
|
|
|
Picker("Visit Type", selection: $visitType) {
|
|
ForEach(VisitType.allCases, id: \.self) { type in
|
|
Text(type.displayName).tag(type)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Visit Details")
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
|
|
// Game Info Section (only for game visits)
|
|
if visitType == .game {
|
|
Section {
|
|
// Away Team with autocomplete
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Text("Away Team")
|
|
Spacer()
|
|
TextField("Team Name", text: $awayTeamName)
|
|
.multilineTextAlignment(.trailing)
|
|
.focused($awayTeamFocused)
|
|
.onChange(of: awayTeamFocused) { _, focused in
|
|
showAwayTeamSuggestions = focused
|
|
}
|
|
}
|
|
|
|
if showAwayTeamSuggestions && !awayTeamSuggestions.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(awayTeamSuggestions.prefix(10)) { team in
|
|
Button {
|
|
awayTeamName = team.name
|
|
awayTeamFocused = false
|
|
} label: {
|
|
Text(team.name)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(Capsule())
|
|
}
|
|
.accessibilityLabel("Select team \(team.name)")
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Home Team with autocomplete
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
Text("Home Team")
|
|
Spacer()
|
|
TextField("Team Name", text: $homeTeamName)
|
|
.multilineTextAlignment(.trailing)
|
|
.focused($homeTeamFocused)
|
|
.onChange(of: homeTeamFocused) { _, focused in
|
|
showHomeTeamSuggestions = focused
|
|
}
|
|
}
|
|
|
|
if showHomeTeamSuggestions && !homeTeamSuggestions.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(homeTeamSuggestions.prefix(10)) { team in
|
|
Button {
|
|
homeTeamName = team.name
|
|
homeTeamFocused = false
|
|
} label: {
|
|
Text(team.name)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(Capsule())
|
|
}
|
|
.accessibilityLabel("Select team \(team.name)")
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Text("Final Score")
|
|
Spacer()
|
|
TextField("Away", text: $awayScore)
|
|
.keyboardType(.numberPad)
|
|
.frame(width: 50)
|
|
.multilineTextAlignment(.center)
|
|
Text("-")
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
TextField("Home", text: $homeScore)
|
|
.keyboardType(.numberPad)
|
|
.frame(width: 50)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
// Look Up Game Button
|
|
if selectedStadium != nil {
|
|
Button {
|
|
Task {
|
|
await lookUpGame()
|
|
}
|
|
} label: {
|
|
HStack {
|
|
if isLookingUpGame {
|
|
LoadingSpinner(size: .small)
|
|
} else {
|
|
Image(systemName: "magnifyingglass")
|
|
}
|
|
Text("Look Up Game")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
.disabled(isLookingUpGame)
|
|
}
|
|
} header: {
|
|
Text("Game Info")
|
|
} footer: {
|
|
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))
|
|
}
|
|
|
|
// Optional Details Section
|
|
Section {
|
|
HStack {
|
|
Text("Seat Location")
|
|
Spacer()
|
|
TextField("e.g., Section 120", text: $seatLocation)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
Text("Notes")
|
|
TextEditor(text: $notes)
|
|
.frame(minHeight: 80)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
|
}
|
|
} header: {
|
|
Text("Additional Info")
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
|
|
// Error Message
|
|
if let error = errorMessage {
|
|
Section {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
.accessibilityHidden(true)
|
|
Text(error)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
}
|
|
}
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.scrollContentBackground(.hidden)
|
|
.themedBackground()
|
|
.navigationTitle("Log Visit")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save") {
|
|
saveVisit()
|
|
}
|
|
.disabled(!canSave || isSaving)
|
|
.fontWeight(.semibold)
|
|
.accessibilityIdentifier("visitSheet.saveButton")
|
|
}
|
|
}
|
|
.sheet(isPresented: $showStadiumPicker) {
|
|
StadiumPickerSheet(
|
|
sport: selectedSport,
|
|
selectedStadium: $selectedStadium
|
|
)
|
|
}
|
|
.onChange(of: selectedSport) { _, _ in
|
|
// Clear selections when sport changes
|
|
if let stadium = selectedStadium {
|
|
// Check if stadium belongs to new sport
|
|
let sportTeams = dataProvider.teams.filter { $0.sport == selectedSport }
|
|
if !sportTeams.contains(where: { $0.stadiumId == stadium.id }) {
|
|
selectedStadium = nil
|
|
}
|
|
}
|
|
// Clear team names when sport changes
|
|
homeTeamName = ""
|
|
awayTeamName = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var canSave: Bool {
|
|
selectedStadium != nil
|
|
}
|
|
|
|
private var finalScoreString: String? {
|
|
guard let away = Int(awayScore), let home = Int(homeScore) else {
|
|
return nil
|
|
}
|
|
return "\(away)-\(home)"
|
|
}
|
|
|
|
// 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"
|
|
return
|
|
}
|
|
|
|
isSaving = true
|
|
errorMessage = nil
|
|
|
|
// Create the visit
|
|
let visit = StadiumVisit(
|
|
stadiumId: stadium.id,
|
|
stadiumNameAtVisit: stadium.name,
|
|
visitDate: visitDate,
|
|
sport: selectedSport,
|
|
visitType: visitType,
|
|
homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName,
|
|
awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName,
|
|
finalScore: finalScoreString,
|
|
scoreSource: finalScoreString != nil ? (scoreFromScraper ? .scraped : .user) : nil,
|
|
dataSource: scoreFromScraper ? .automatic : .fullyManual,
|
|
seatLocation: seatLocation.isEmpty ? nil : seatLocation,
|
|
notes: notes.isEmpty ? nil : notes,
|
|
source: .manual
|
|
)
|
|
|
|
// Save to SwiftData
|
|
modelContext.insert(visit)
|
|
|
|
do {
|
|
try modelContext.save()
|
|
AnalyticsManager.shared.track(.stadiumVisitAdded(stadiumId: stadium.id, sport: selectedSport.rawValue))
|
|
onSave?(visit)
|
|
dismiss()
|
|
} catch {
|
|
errorMessage = "Failed to save visit: \(error.localizedDescription)"
|
|
isSaving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Stadium Picker Sheet
|
|
|
|
struct StadiumPickerSheet: View {
|
|
let sport: Sport
|
|
@Binding var selectedStadium: Stadium?
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var searchText = ""
|
|
|
|
@ObservedObject private var dataProvider = AppDataProvider.shared
|
|
|
|
private var stadiums: [Stadium] {
|
|
let sportTeams = dataProvider.teams.filter { $0.sport == sport }
|
|
let stadiumIds = Set(sportTeams.map { $0.stadiumId })
|
|
return dataProvider.stadiums.filter { stadiumIds.contains($0.id) }
|
|
}
|
|
|
|
private var filteredStadiums: [Stadium] {
|
|
if searchText.isEmpty {
|
|
return stadiums.sorted { $0.name < $1.name }
|
|
}
|
|
return stadiums.filter {
|
|
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
|
$0.city.localizedCaseInsensitiveContains(searchText)
|
|
}.sorted { $0.name < $1.name }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if stadiums.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Stadiums",
|
|
systemImage: "building.2",
|
|
description: Text("No stadiums found for \(sport.displayName)")
|
|
)
|
|
} else if filteredStadiums.isEmpty {
|
|
ContentUnavailableView.search(text: searchText)
|
|
} else {
|
|
List(filteredStadiums) { stadium in
|
|
Button {
|
|
selectedStadium = stadium
|
|
dismiss()
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(stadium.name)
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text(stadium.fullAddress)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if selectedStadium?.id == stadium.id {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
}
|
|
}
|
|
.accessibilityIdentifier("stadiumPicker.stadiumRow")
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
}
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
}
|
|
.themedBackground()
|
|
.searchable(text: $searchText, prompt: "Search stadiums")
|
|
.navigationTitle("Select Stadium")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
StadiumVisitSheet()
|
|
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
|
}
|