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:
@@ -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