refactor: extract reusable SportSelectorGrid component

Create unified sport selector grid used across Home (Quick Start),
Trip Creation, and Progress views. Removes duplicate button implementations
and ensures consistent grid layout with centered bottom row.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-11 10:38:10 -06:00
parent a292b5c20c
commit 475f444288
4 changed files with 321 additions and 211 deletions

View File

@@ -0,0 +1,301 @@
//
// SportSelectorGrid.swift
// SportsTime
//
// Reusable sport selector grid with centered bottom row layout.
// Supports action, single-select, and multi-select modes.
//
import SwiftUI
// MARK: - Sport Selector Grid
struct SportSelectorGrid<Content: View>: View {
let sports: [Sport]
@ViewBuilder let buttonContent: (Sport) -> Content
@Environment(\.colorScheme) private var colorScheme
init(
sports: [Sport] = Sport.supported,
@ViewBuilder buttonContent: @escaping (Sport) -> Content
) {
self.sports = sports
self.buttonContent = buttonContent
}
private var rows: [[Sport]] {
sports.chunked(into: 4)
}
var body: some View {
GeometryReader { geometry in
let spacing = Theme.Spacing.sm
let buttonWidth = (geometry.size.width - 3 * spacing) / 4
VStack(spacing: Theme.Spacing.md) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
if row.count == 4 {
// Full row - evenly distributed
HStack(spacing: spacing) {
ForEach(row) { sport in
buttonContent(sport)
.frame(width: buttonWidth)
}
}
} else {
// Partial row - centered with same button width
HStack {
Spacer()
HStack(spacing: spacing) {
ForEach(row) { sport in
buttonContent(sport)
.frame(width: buttonWidth)
}
}
Spacer()
}
}
}
}
}
.frame(height: calculateGridHeight())
}
private func calculateGridHeight() -> CGFloat {
let rowCount = CGFloat(rows.count)
let buttonHeight: CGFloat = 76 // Icon (48) + spacing (6) + text (~22)
let spacing = Theme.Spacing.md
return rowCount * buttonHeight + (rowCount - 1) * spacing
}
}
// MARK: - Sport Action Button (for Quick Start - single tap action)
struct SportActionButton: View {
let sport: Sport
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isPressed = false
var body: some View {
Button(action: action) {
VStack(spacing: 6) {
ZStack {
Circle()
.fill(sport.themeColor.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(sport.themeColor)
}
Text(sport.rawValue)
.font(.caption2)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
.scaleEffect(isPressed ? 0.9 : 1.0)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true }
}
.onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false }
}
)
}
}
// MARK: - Sport Toggle Button (for multi-select)
struct SportToggleButton: View {
let sport: Sport
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isPressed = false
var body: some View {
Button(action: onTap) {
VStack(spacing: 6) {
ZStack {
Circle()
.fill(isSelected ? sport.themeColor : sport.themeColor.opacity(0.15))
.frame(width: 48, height: 48)
.overlay {
if isSelected {
Circle()
.stroke(sport.themeColor.opacity(0.3), lineWidth: 3)
.frame(width: 54, height: 54)
}
}
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .white : sport.themeColor)
}
Text(sport.rawValue)
.font(.system(size: 10, weight: isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
.scaleEffect(isPressed ? 0.9 : 1.0)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true }
}
.onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false }
}
)
}
}
// MARK: - Sport Progress Button (for single-select with progress ring)
struct SportProgressButton: View {
let sport: Sport
let isSelected: Bool
let progress: Double
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isPressed = false
var body: some View {
Button(action: action) {
VStack(spacing: 6) {
ZStack {
// Background circle with progress ring
Circle()
.stroke(sport.themeColor.opacity(0.2), lineWidth: 3)
.frame(width: 48, height: 48)
Circle()
.trim(from: 0, to: progress)
.stroke(sport.themeColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 48, height: 48)
.rotationEffect(.degrees(-90))
// Sport icon
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme))
}
.overlay {
if isSelected {
Circle()
.stroke(sport.themeColor, lineWidth: 2)
.frame(width: 54, height: 54)
}
}
Text(sport.rawValue)
.font(.caption2)
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
.scaleEffect(isPressed ? 0.9 : 1.0)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true }
}
.onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false }
}
)
}
}
// MARK: - Convenience Initializers
extension SportSelectorGrid where Content == SportActionButton {
/// Creates a grid with action buttons (tap to perform action)
init(
sports: [Sport] = Sport.supported,
onSelect: @escaping (Sport) -> Void
) {
self.sports = sports
self.buttonContent = { sport in
SportActionButton(sport: sport) {
onSelect(sport)
}
}
}
}
extension SportSelectorGrid where Content == SportToggleButton {
/// Creates a grid with toggle buttons (multi-select)
init(
sports: [Sport] = Sport.supported,
selectedSports: Set<Sport>,
onToggle: @escaping (Sport) -> Void
) {
self.sports = sports
self.buttonContent = { sport in
SportToggleButton(
sport: sport,
isSelected: selectedSports.contains(sport),
onTap: { onToggle(sport) }
)
}
}
}
// MARK: - Preview
#Preview("Action Grid") {
VStack {
Text("Quick Start")
.font(.title2)
SportSelectorGrid { sport in
SportActionButton(sport: sport) {
print("Selected \(sport.rawValue)")
}
}
}
.padding()
.frame(height: 250)
}
#Preview("Toggle Grid") {
struct PreviewWrapper: View {
@State private var selected: Set<Sport> = [.mlb, .nfl]
var body: some View {
VStack {
Text("Sports")
.font(.title2)
SportSelectorGrid(
selectedSports: selected
) { sport in
if selected.contains(sport) {
selected.remove(sport)
} else {
selected.insert(sport)
}
}
}
.padding()
.frame(height: 250)
}
}
return PreviewWrapper()
}

View File

@@ -169,51 +169,15 @@ struct HomeView: View {
// MARK: - Quick Actions
private var quickActions: some View {
let sports = Sport.supported
let rows = sports.chunked(into: 4)
return VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Quick Start")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
GeometryReader { geometry in
let spacing = Theme.Spacing.sm
let buttonWidth = (geometry.size.width - 3 * spacing) / 4
VStack(spacing: Theme.Spacing.md) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
if row.count == 4 {
// Full row - evenly distributed
HStack(spacing: spacing) {
ForEach(row) { sport in
QuickSportButton(sport: sport) {
selectedSport = sport
showNewTrip = true
}
.frame(width: buttonWidth)
}
}
} else {
// Partial row - centered with same button width and spacing
HStack {
Spacer()
HStack(spacing: spacing) {
ForEach(row) { sport in
QuickSportButton(sport: sport) {
selectedSport = sport
showNewTrip = true
}
.frame(width: buttonWidth)
}
}
Spacer()
}
}
}
}
SportSelectorGrid { sport in
selectedSport = sport
showNewTrip = true
}
.frame(height: 180) // Approximate height for 2 rows
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
@@ -371,45 +335,6 @@ struct HomeView: View {
// MARK: - Supporting Views
struct QuickSportButton: View {
let sport: Sport
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isPressed = false
var body: some View {
Button(action: action) {
VStack(spacing: 6) {
ZStack {
Circle()
.fill(sport.themeColor.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(sport.themeColor)
}
Text(sport.rawValue)
.font(.caption2)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
.scaleEffect(isPressed ? 0.9 : 1.0)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true }
}
.onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false }
}
)
}
}
struct SavedTripCard: View {
let savedTrip: SavedTrip
let trip: Trip

View File

@@ -133,16 +133,14 @@ struct ProgressTabView: View {
// MARK: - League Selector
private var leagueSelector: some View {
HStack(spacing: Theme.Spacing.sm) {
ForEach(Sport.supported) { sport in
LeagueSelectorButton(
sport: sport,
isSelected: viewModel.selectedSport == sport,
progress: progressForSport(sport)
) {
withAnimation(Theme.Animation.spring) {
viewModel.selectSport(sport)
}
SportSelectorGrid { sport in
SportProgressButton(
sport: sport,
isSelected: viewModel.selectedSport == sport,
progress: progressForSport(sport)
) {
withAnimation(Theme.Animation.spring) {
viewModel.selectSport(sport)
}
}
}
@@ -392,54 +390,6 @@ struct ProgressTabView: View {
// MARK: - Supporting Views
struct LeagueSelectorButton: View {
let sport: Sport
let isSelected: Bool
let progress: Double
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: action) {
VStack(spacing: Theme.Spacing.xs) {
ZStack {
// Background circle with progress
Circle()
.stroke(sport.themeColor.opacity(0.2), lineWidth: 3)
.frame(width: 50, height: 50)
Circle()
.trim(from: 0, to: progress)
.stroke(sport.themeColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 50, height: 50)
.rotationEffect(.degrees(-90))
// Sport icon
Image(systemName: sport.iconName)
.font(.title2)
.foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme))
}
Text(sport.rawValue)
.font(.caption)
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
}
.frame(maxWidth: .infinity)
.padding(.vertical, Theme.Spacing.sm)
.background(isSelected ? Theme.cardBackground(colorScheme) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
if isSelected {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(sport.themeColor, lineWidth: 2)
}
}
}
.buttonStyle(.plain)
}
}
struct ProgressStatPill: View {
let icon: String
let value: String

View File

@@ -493,33 +493,14 @@ struct TripCreationView: View {
}
private var sportsSection: some View {
let sports = Sport.supported
let rows = sports.chunked(into: 4)
return ThemedSection(title: "Sports") {
VStack(spacing: Theme.Spacing.sm) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(spacing: Theme.Spacing.sm) {
ForEach(row) { sport in
SportSelectionChip(
sport: sport,
isSelected: viewModel.selectedSports.contains(sport),
onTap: {
if viewModel.selectedSports.contains(sport) {
viewModel.selectedSports.remove(sport)
} else {
viewModel.selectedSports.insert(sport)
}
}
)
}
// Fill remaining space if row has fewer than 4 items
if row.count < 4 {
ForEach(0..<(4 - row.count), id: \.self) { _ in
Color.clear.frame(maxWidth: .infinity)
}
}
}
ThemedSection(title: "Sports") {
SportSelectorGrid(
selectedSports: viewModel.selectedSports
) { sport in
if viewModel.selectedSports.contains(sport) {
viewModel.selectedSports.remove(sport)
} else {
viewModel.selectedSports.insert(sport)
}
}
.padding(.vertical, Theme.Spacing.xs)
@@ -2224,53 +2205,6 @@ struct DayCell: View {
}
}
struct SportSelectionChip: View {
let sport: Sport
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isPressed = false
var body: some View {
Button(action: onTap) {
VStack(spacing: 6) {
ZStack {
Circle()
.fill(isSelected ? sport.themeColor : sport.themeColor.opacity(0.15))
.frame(width: 48, height: 48)
.overlay {
if isSelected {
Circle()
.stroke(sport.themeColor.opacity(0.3), lineWidth: 3)
.frame(width: 54, height: 54)
}
}
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .white : sport.themeColor)
}
Text(sport.rawValue)
.font(.system(size: 10, weight: isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
.scaleEffect(isPressed ? 0.9 : 1.0)
}
.buttonStyle(.plain)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true }
}
.onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false }
}
)
}
}
#Preview {
TripCreationView(viewModel: TripCreationViewModel())
}