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