feat(ui): add 23 home screen design variants with picker

Add design style system with 23 unique home screen aesthetics:
- Classic (original SportsTime design, now default)
- 12 experimental styles (Brutalist, Luxury Editorial, etc.)
- 10 polished app-inspired styles (Flighty, SeatGeek, Apple Maps,
  Things 3, Airbnb, Spotify, Nike Run Club, Fantastical, Strava,
  Carrot Weather)

Includes settings picker to switch between styles and persists
selection via UserDefaults.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-13 14:44:30 -06:00
parent 3d40145ffb
commit 56869ce479
27 changed files with 8636 additions and 27 deletions

View File

@@ -0,0 +1,330 @@
//
// HomeContent_Spotify.swift
// SportsTime
//
// SPOTIFY-INSPIRED: Dark elegance, bold typography.
// Content-focused cards, horizontal scrolling.
// Green accent, immersive feel.
//
import SwiftUI
import SwiftData
struct HomeContent_Spotify: View {
@Environment(\.colorScheme) private var colorScheme
@Binding var showNewTrip: Bool
@Binding var selectedTab: Int
@Binding var selectedSuggestedTrip: SuggestedTrip?
let savedTrips: [SavedTrip]
let suggestedTripsGenerator: SuggestedTripsGenerator
let displayedTips: [PlanningTip]
// Spotify-inspired colors (dark-first design)
private let bgColor = Color(red: 0.07, green: 0.07, blue: 0.07)
private let cardBg = Color(red: 0.11, green: 0.11, blue: 0.11)
private let spotifyGreen = Color(red: 0.12, green: 0.84, blue: 0.38)
private let textPrimary = Color.white
private let textSecondary = Color(white: 0.65)
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
// Greeting header
greetingHeader
.padding(.horizontal, 16)
.padding(.top, 12)
// Quick actions
quickActions
.padding(.horizontal, 16)
// Your trips
if !savedTrips.isEmpty {
yourTripsSection
}
// Made for you
if !suggestedTripsGenerator.suggestedTrips.isEmpty {
madeForYouSection
}
// Browse sports
browseSportsSection
Spacer(minLength: 50)
}
}
.background(bgColor.ignoresSafeArea())
}
// MARK: - Greeting Header
private var greetingHeader: some View {
HStack {
Text(greetingText)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(textPrimary)
Spacer()
// Settings/gear button
Button {
// Navigate to settings
} label: {
Image(systemName: "gearshape.fill")
.font(.system(size: 18))
.foregroundStyle(textPrimary)
}
}
}
private var greetingText: String {
let hour = Calendar.current.component(.hour, from: Date())
if hour < 12 {
return "Good morning"
} else if hour < 17 {
return "Good afternoon"
} else {
return "Good evening"
}
}
// MARK: - Quick Actions
private var quickActions: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
// New trip button
quickActionButton(title: "Plan Trip", icon: "plus.circle.fill", isPrimary: true) {
showNewTrip = true
}
// Saved trips button
if !savedTrips.isEmpty {
quickActionButton(title: "Your Trips", icon: "folder.fill", isPrimary: false) {
selectedTab = 2
}
}
// First saved trip quick access
if let firstTrip = savedTrips.first?.trip {
quickActionButton(title: firstTrip.name, icon: "play.circle.fill", isPrimary: false) {
// Could navigate to trip
}
}
// Refresh suggestions
quickActionButton(title: "Refresh", icon: "arrow.clockwise", isPrimary: false) {
Task {
await suggestedTripsGenerator.refreshTrips()
}
}
}
}
private func quickActionButton(title: String, icon: String, isPrimary: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 22))
.foregroundStyle(isPrimary ? spotifyGreen : textPrimary)
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(textPrimary)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(cardBg)
)
}
.buttonStyle(.plain)
}
// MARK: - Your Trips Section
private var yourTripsSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Your Trips")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(textPrimary)
.padding(.horizontal, 16)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(savedTrips.prefix(5)) { savedTrip in
if let trip = savedTrip.trip {
NavigationLink {
TripDetailView(trip: trip, games: savedTrip.games)
} label: {
tripCoverCard(trip)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, 16)
}
}
}
private func tripCoverCard(_ trip: Trip) -> some View {
VStack(alignment: .leading, spacing: 10) {
// Album art style cover
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [
trip.uniqueSports.first?.themeColor ?? spotifyGreen,
(trip.uniqueSports.first?.themeColor ?? spotifyGreen).opacity(0.4)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 150, height: 150)
VStack(spacing: 8) {
Image(systemName: trip.uniqueSports.first?.iconName ?? "sportscourt.fill")
.font(.system(size: 40))
.foregroundStyle(.white)
Text("\(trip.totalGames) games")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.9))
}
}
.shadow(color: Color.black.opacity(0.4), radius: 8, y: 4)
VStack(alignment: .leading, spacing: 4) {
Text(trip.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(textPrimary)
.lineLimit(1)
Text("\(trip.stops.count) stops")
.font(.system(size: 12))
.foregroundStyle(textSecondary)
}
}
.frame(width: 150)
}
// MARK: - Made For You Section
private var madeForYouSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Made For You")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(textPrimary)
.padding(.horizontal, 16)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(suggestedTripsGenerator.suggestedTrips.prefix(5)) { suggestedTrip in
Button {
selectedSuggestedTrip = suggestedTrip
} label: {
suggestionCoverCard(suggestedTrip.trip)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
}
}
private func suggestionCoverCard(_ trip: Trip) -> some View {
VStack(alignment: .leading, spacing: 10) {
// Cover with gradient mesh
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [
Color(red: 0.3, green: 0.2, blue: 0.5),
Color(red: 0.1, green: 0.4, blue: 0.5),
Color(red: 0.2, green: 0.3, blue: 0.4)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 150, height: 150)
VStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.system(size: 24))
.foregroundStyle(.white.opacity(0.8))
Text("Daily Mix")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white.opacity(0.8))
}
}
.shadow(color: Color.black.opacity(0.4), radius: 8, y: 4)
VStack(alignment: .leading, spacing: 4) {
Text(trip.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(textPrimary)
.lineLimit(2)
Text("\(trip.stops.count) cities")
.font(.system(size: 12))
.foregroundStyle(textSecondary)
}
}
.frame(width: 150)
}
// MARK: - Browse Sports Section
private var browseSportsSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Browse Sports")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(textPrimary)
.padding(.horizontal, 16)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach(Sport.supported.prefix(4)) { sport in
sportBrowseCard(sport)
}
}
.padding(.horizontal, 16)
}
}
private func sportBrowseCard(_ sport: Sport) -> some View {
Button {
showNewTrip = true
} label: {
ZStack(alignment: .bottomLeading) {
RoundedRectangle(cornerRadius: 6)
.fill(
LinearGradient(
colors: [sport.themeColor, sport.themeColor.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(height: 100)
Text(sport.displayName)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
.padding(12)
}
}
.buttonStyle(.plain)
}
}