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>
This commit is contained in:
Trey t
2026-01-08 20:32:44 -06:00
parent 92d808caf5
commit 588938d2a1
2 changed files with 133 additions and 45 deletions

View File

@@ -106,52 +106,36 @@ struct AnimatedRouteGraphic: View {
// MARK: - Themed Spinner // MARK: - Themed Spinner
/// A custom animated spinner matching the app's visual style /// A custom animated spinner matching the app's visual style
/// Both ThemedSpinner and ThemedSpinnerCompact use the same visual style for consistency
struct ThemedSpinner: View { struct ThemedSpinner: View {
var size: CGFloat = 40 var size: CGFloat = 40
var lineWidth: CGFloat = 4 var lineWidth: CGFloat = 4
var color: Color = Theme.warmOrange
@State private var rotation: Double = 0 @State private var rotation: Double = 0
@State private var trimEnd: CGFloat = 0.6
var body: some View { var body: some View {
ZStack { ZStack {
// Background track // Background track
Circle() Circle()
.stroke(Theme.warmOrange.opacity(0.15), lineWidth: lineWidth) .stroke(color.opacity(0.2), lineWidth: lineWidth)
// Animated arc // Animated arc
Circle() Circle()
.trim(from: 0, to: trimEnd) .trim(from: 0, to: 0.7)
.stroke( .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
AngularGradient(
gradient: Gradient(colors: [Theme.warmOrange, Theme.routeGold, Theme.warmOrange.opacity(0.3)]),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(rotation)) .rotationEffect(.degrees(rotation))
// Center glow dot
Circle()
.fill(Theme.warmOrange.opacity(0.2))
.frame(width: size * 0.3, height: size * 0.3)
.blur(radius: 4)
} }
.frame(width: size, height: size) .frame(width: size, height: size)
.onAppear { .onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
rotation = 360 rotation = 360
} }
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
trimEnd = 0.8
}
} }
} }
} }
/// Compact themed spinner for inline use /// Compact themed spinner for inline use (same style as ThemedSpinner, smaller default)
struct ThemedSpinnerCompact: View { struct ThemedSpinnerCompact: View {
var size: CGFloat = 20 var size: CGFloat = 20
var color: Color = Theme.warmOrange var color: Color = Theme.warmOrange
@@ -159,16 +143,23 @@ struct ThemedSpinnerCompact: View {
@State private var rotation: Double = 0 @State private var rotation: Double = 0
var body: some View { var body: some View {
Circle() ZStack {
.trim(from: 0, to: 0.7) // Background track
.stroke(color, style: StrokeStyle(lineWidth: size > 16 ? 2.5 : 2, lineCap: .round)) Circle()
.frame(width: size, height: size) .stroke(color.opacity(0.2), lineWidth: size > 16 ? 2.5 : 2)
.rotationEffect(.degrees(rotation))
.onAppear { // Animated arc
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { Circle()
rotation = 360 .trim(from: 0, to: 0.7)
} .stroke(color, style: StrokeStyle(lineWidth: size > 16 ? 2.5 : 2, lineCap: .round))
.rotationEffect(.degrees(rotation))
}
.frame(width: size, height: size)
.onAppear {
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
rotation = 360
} }
}
} }
} }

View File

@@ -34,9 +34,35 @@ struct StadiumVisitSheet: View {
@State private var showStadiumPicker = false @State private var showStadiumPicker = false
@State private var isSaving = false @State private var isSaving = false
@State private var errorMessage: String? @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 // Data
private let dataProvider = AppDataProvider.shared @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( init(
initialStadium: Stadium? = nil, initialStadium: Stadium? = nil,
@@ -89,6 +115,7 @@ struct StadiumVisitSheet: View {
} header: { } header: {
Text("Location") Text("Location")
} }
.listRowBackground(Theme.cardBackground(colorScheme))
// Visit Details Section // Visit Details Section
Section { Section {
@@ -102,22 +129,81 @@ struct StadiumVisitSheet: View {
} header: { } header: {
Text("Visit Details") Text("Visit Details")
} }
.listRowBackground(Theme.cardBackground(colorScheme))
// Game Info Section (only for game visits) // Game Info Section (only for game visits)
if visitType == .game { if visitType == .game {
Section { Section {
HStack { // Away Team with autocomplete
Text("Away Team") VStack(alignment: .leading, spacing: 0) {
Spacer() HStack {
TextField("Team Name", text: $awayTeamName) Text("Away Team")
.multilineTextAlignment(.trailing) 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)
}
}
} }
HStack { // Home Team with autocomplete
Text("Home Team") VStack(alignment: .leading, spacing: 0) {
Spacer() HStack {
TextField("Team Name", text: $homeTeamName) Text("Home Team")
.multilineTextAlignment(.trailing) 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 { HStack {
@@ -139,6 +225,7 @@ struct StadiumVisitSheet: View {
} footer: { } footer: {
Text("Leave blank if you don't remember the score") Text("Leave blank if you don't remember the score")
} }
.listRowBackground(Theme.cardBackground(colorScheme))
} }
// Optional Details Section // Optional Details Section
@@ -161,6 +248,7 @@ struct StadiumVisitSheet: View {
} header: { } header: {
Text("Additional Info") Text("Additional Info")
} }
.listRowBackground(Theme.cardBackground(colorScheme))
// Error Message // Error Message
if let error = errorMessage { if let error = errorMessage {
@@ -172,9 +260,12 @@ struct StadiumVisitSheet: View {
.foregroundStyle(.red) .foregroundStyle(.red)
} }
} }
.listRowBackground(Theme.cardBackground(colorScheme))
} }
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.scrollContentBackground(.hidden)
.themedBackground()
.navigationTitle("Log Visit") .navigationTitle("Log Visit")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -199,7 +290,7 @@ struct StadiumVisitSheet: View {
) )
} }
.onChange(of: selectedSport) { _, _ in .onChange(of: selectedSport) { _, _ in
// Clear stadium selection when sport changes // Clear selections when sport changes
if let stadium = selectedStadium { if let stadium = selectedStadium {
// Check if stadium belongs to new sport // Check if stadium belongs to new sport
let sportTeams = dataProvider.teams.filter { $0.sport == selectedSport } let sportTeams = dataProvider.teams.filter { $0.sport == selectedSport }
@@ -207,6 +298,9 @@ struct StadiumVisitSheet: View {
selectedStadium = nil selectedStadium = nil
} }
} }
// Clear team names when sport changes
homeTeamName = ""
awayTeamName = ""
} }
} }
} }
@@ -277,7 +371,7 @@ struct StadiumPickerSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var searchText = "" @State private var searchText = ""
private let dataProvider = AppDataProvider.shared @ObservedObject private var dataProvider = AppDataProvider.shared
private var stadiums: [Stadium] { private var stadiums: [Stadium] {
let sportTeams = dataProvider.teams.filter { $0.sport == sport } let sportTeams = dataProvider.teams.filter { $0.sport == sport }
@@ -331,10 +425,13 @@ struct StadiumPickerSheet: View {
} }
} }
} }
.listRowBackground(Theme.cardBackground(colorScheme))
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.scrollContentBackground(.hidden)
} }
} }
.themedBackground()
.searchable(text: $searchText, prompt: "Search stadiums") .searchable(text: $searchText, prompt: "Search stadiums")
.navigationTitle("Select Stadium") .navigationTitle("Select Stadium")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)