diff --git a/SportsTime/Core/Theme/SportSelectorGrid.swift b/SportsTime/Core/Theme/SportSelectorGrid.swift new file mode 100644 index 0000000..38ec6a8 --- /dev/null +++ b/SportsTime/Core/Theme/SportSelectorGrid.swift @@ -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: 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, + 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 = [.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() +} diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 0d1ba66..2eaa383 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -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 diff --git a/SportsTime/Features/Progress/Views/ProgressTabView.swift b/SportsTime/Features/Progress/Views/ProgressTabView.swift index 65b52a9..14d6755 100644 --- a/SportsTime/Features/Progress/Views/ProgressTabView.swift +++ b/SportsTime/Features/Progress/Views/ProgressTabView.swift @@ -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 diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift index 59e1d56..86942e0 100644 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ b/SportsTime/Features/Trip/Views/TripCreationView.swift @@ -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()) }