Files
Sportstime/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift
Trey t 588938d2a1 Add team autocomplete and themed backgrounds to Log Visit sheet
- Add autocomplete suggestions for Home/Away team fields filtered by selected sport
- Apply themed background to StadiumVisitSheet and StadiumPickerSheet
- Add listRowBackground for consistent card styling in Form sections
- Fix data observation with @ObservedObject for AppDataProvider
- Clear team names when sport selection changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:32:44 -06:00

455 lines
18 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 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))
}
}
} header: {
Text("Location")
}
.listRowBackground(Theme.cardBackground(colorScheme))
// Visit Details Section
Section {
DatePicker("Date", selection: $visitDate, displayedComponents: .date)
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(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textPrimary(colorScheme))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
}
}
.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(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textPrimary(colorScheme))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
}
}
.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)
}
} header: {
Text("Game Info")
} footer: {
Text("Leave blank if you don't remember the score")
}
.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)
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)
}
}
.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 saveVisit() {
guard let stadium = selectedStadium else {
errorMessage = "Please select a stadium"
return
}
isSaving = true
errorMessage = nil
// Create the visit
let visit = StadiumVisit(
canonicalStadiumId: stadium.id.uuidString, // Simplified - in production use StadiumIdentityService
stadiumUUID: 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 ? .user : nil,
dataSource: .fullyManual,
seatLocation: seatLocation.isEmpty ? nil : seatLocation,
notes: notes.isEmpty ? nil : notes,
source: .manual
)
// Save to SwiftData
modelContext.insert(visit)
do {
try modelContext.save()
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(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(stadium.fullAddress)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
if selectedStadium?.id == stadium.id {
Image(systemName: "checkmark")
.foregroundStyle(Theme.warmOrange)
}
}
}
.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)
}