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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user