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