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:
301
SportsTime/Core/Theme/SportSelectorGrid.swift
Normal file
301
SportsTime/Core/Theme/SportSelectorGrid.swift
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user