UI overhaul: new color palette, trip creation improvements, crash fix

Theme:
- New teal/cyan/mint/pink/gold color palette replacing orange/cream
- Added Theme.swift, ViewModifiers.swift, AnimatedComponents.swift

Trip Creation:
- Removed Drive/Fly toggle (drive-only for now)
- Removed Lodging Type picker
- Renamed "Number of Stops" to "Number of Cities" with explanation
- Added explanation for "Find Other Sports Along Route"
- Removed staggered animation from trip options list

Bug Fix:
- Disabled AI route description generation (Foundation Models crashes
  in iOS 26.2 Simulator due to NLLanguageRecognizer assertion failure)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 15:34:27 -06:00
parent 8ec8ed02b1
commit 40a6f879e3
13 changed files with 2429 additions and 745 deletions

View File

@@ -0,0 +1,324 @@
//
// AnimatedComponents.swift
// SportsTime
//
// Animated UI components for visual delight.
//
import SwiftUI
// MARK: - Animated Route Graphic
/// A stylized animated route illustration for loading states
struct AnimatedRouteGraphic: View {
@State private var animationProgress: CGFloat = 0
@State private var dotPositions: [CGFloat] = [0.1, 0.4, 0.7]
var body: some View {
GeometryReader { geo in
let width = geo.size.width
let height = geo.size.height
Canvas { context, size in
// Draw the route path
let path = createRoutePath(in: CGRect(origin: .zero, size: size))
// Glow layer
context.addFilter(.blur(radius: 8))
context.stroke(
path,
with: .linearGradient(
Gradient(colors: [Theme.routeGold.opacity(0.5), Theme.warmOrange.opacity(0.5)]),
startPoint: .zero,
endPoint: CGPoint(x: size.width, y: size.height)
),
lineWidth: 6
)
// Reset filter and draw main line
context.addFilter(.blur(radius: 0))
context.stroke(
path,
with: .linearGradient(
Gradient(colors: [Theme.routeGold, Theme.warmOrange]),
startPoint: .zero,
endPoint: CGPoint(x: size.width, y: size.height)
),
style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)
)
}
// Animated traveling dot
Circle()
.fill(Theme.warmOrange)
.frame(width: 12, height: 12)
.glowEffect(color: Theme.warmOrange, radius: 12)
.position(
x: width * 0.1 + (width * 0.8) * animationProgress,
y: height * 0.6 + sin(animationProgress * .pi * 2) * (height * 0.2)
)
// Stadium markers
ForEach(Array(dotPositions.enumerated()), id: \.offset) { index, pos in
PulsingDot(color: index == 0 ? Theme.mlbRed : (index == 1 ? Theme.nbaOrange : Theme.nhlBlue))
.frame(width: 16, height: 16)
.position(
x: width * 0.1 + (width * 0.8) * pos,
y: height * 0.6 + sin(pos * .pi * 2) * (height * 0.2)
)
}
}
.onAppear {
withAnimation(.easeInOut(duration: Theme.Animation.routeDrawDuration).repeatForever(autoreverses: false)) {
animationProgress = 1
}
}
}
private func createRoutePath(in rect: CGRect) -> Path {
Path { path in
let startX = rect.width * 0.1
let endX = rect.width * 0.9
let midY = rect.height * 0.6
let amplitude = rect.height * 0.2
path.move(to: CGPoint(x: startX, y: midY))
// Create a smooth curve through the points
let controlPoints: [(CGFloat, CGFloat)] = [
(0.25, midY - amplitude * 0.5),
(0.4, midY + amplitude * 0.3),
(0.55, midY - amplitude * 0.4),
(0.7, midY + amplitude * 0.2),
(0.85, midY - amplitude * 0.1)
]
for (progress, y) in controlPoints {
let x = startX + (endX - startX) * progress
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: endX, y: midY))
}
}
}
// MARK: - Pulsing Dot
struct PulsingDot: View {
var color: Color = Theme.warmOrange
var size: CGFloat = 12
@State private var isPulsing = false
var body: some View {
ZStack {
// Outer pulse ring
Circle()
.stroke(color.opacity(0.3), lineWidth: 2)
.frame(width: size * 2, height: size * 2)
.scaleEffect(isPulsing ? 1.5 : 1)
.opacity(isPulsing ? 0 : 1)
// Inner dot
Circle()
.fill(color)
.frame(width: size, height: size)
.shadow(color: color.opacity(0.5), radius: 4)
}
.onAppear {
withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) {
isPulsing = true
}
}
}
}
// MARK: - Route Preview Strip
/// A compact horizontal visualization of route stops
struct RoutePreviewStrip: View {
let cities: [String]
@Environment(\.colorScheme) private var colorScheme
init(cities: [String]) {
self.cities = cities
}
var body: some View {
HStack(spacing: 4) {
ForEach(Array(cities.prefix(5).enumerated()), id: \.offset) { index, city in
if index > 0 {
// Connector line
Rectangle()
.fill(Theme.routeGold.opacity(0.5))
.frame(width: 16, height: 2)
}
// City dot with label
VStack(spacing: 4) {
Circle()
.fill(index == 0 || index == cities.count - 1 ? Theme.warmOrange : Theme.routeGold)
.frame(width: 8, height: 8)
Text(abbreviateCity(city))
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
.lineLimit(1)
}
}
if cities.count > 5 {
Text("+\(cities.count - 5)")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
}
private func abbreviateCity(_ city: String) -> String {
let words = city.split(separator: " ")
if words.count > 1 {
return String(words[0].prefix(3))
}
return String(city.prefix(4))
}
}
// MARK: - Planning Progress View
struct PlanningProgressView: View {
@State private var currentStep = 0
let steps = ["Finding games...", "Calculating routes...", "Optimizing itinerary..."]
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 32) {
// Animated route illustration
AnimatedRouteGraphic()
.frame(height: 150)
.padding(.horizontal, 40)
// Current step text
Text(steps[currentStep])
.font(.system(size: 18, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
.animation(.easeInOut, value: currentStep)
// Progress dots
HStack(spacing: 12) {
ForEach(0..<steps.count, id: \.self) { i in
Circle()
.fill(i <= currentStep ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3))
.frame(width: i == currentStep ? 10 : 8, height: i == currentStep ? 10 : 8)
.animation(Theme.Animation.spring, value: currentStep)
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
.task {
await animateSteps()
}
}
private func animateSteps() async {
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(1500))
guard !Task.isCancelled else { break }
withAnimation(Theme.Animation.spring) {
currentStep = (currentStep + 1) % steps.count
}
}
}
}
// MARK: - Stat Pill
struct StatPill: View {
let icon: String
let value: String
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 12))
Text(value)
.font(.system(size: 13, weight: .medium))
}
.foregroundStyle(Theme.textSecondary(colorScheme))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
}
// MARK: - Empty State View
struct EmptyStateView: View {
let icon: String
let title: String
let message: String
var actionTitle: String? = nil
var action: (() -> Void)? = nil
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 20) {
Image(systemName: icon)
.font(.system(size: 48))
.foregroundStyle(Theme.warmOrange.opacity(0.7))
VStack(spacing: 8) {
Text(title)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(message)
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
}
if let actionTitle = actionTitle, let action = action {
Button(action: action) {
Text(actionTitle)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Theme.warmOrange)
.clipShape(Capsule())
}
.pressableStyle()
.padding(.top, 8)
}
}
.padding(40)
}
}
// MARK: - Preview
#Preview("Animated Components") {
VStack(spacing: 40) {
AnimatedRouteGraphic()
.frame(height: 150)
RoutePreviewStrip(cities: ["San Diego", "Los Angeles", "San Francisco", "Seattle", "Portland"])
PlanningProgressView()
HStack {
StatPill(icon: "car", value: "450 mi")
StatPill(icon: "clock", value: "8h driving")
}
PulsingDot(color: Theme.warmOrange)
.frame(width: 40, height: 40)
}
.padding()
.themedBackground()
}

View File

@@ -0,0 +1,186 @@
//
// Theme.swift
// SportsTime
//
// Central design system for colors, typography, spacing, and animations.
//
import SwiftUI
// MARK: - Theme
enum Theme {
// MARK: - Accent Colors (Same in Light/Dark)
static let warmOrange = Color(hex: "4ECDC4") // Strong Cyan (primary accent)
static let warmOrangeGlow = Color(hex: "6ED9D1") // Lighter cyan glow
static let routeGold = Color(hex: "FFE66D") // Royal Gold
static let routeAmber = Color(hex: "FF6B6B") // Grapefruit Pink
// New palette colors
static let primaryCyan = Color(hex: "4ECDC4") // Strong Cyan
static let darkTeal = Color(hex: "1A535C") // Dark Teal
static let mintCream = Color(hex: "F7FFF7") // Mint Cream
static let grapefruit = Color(hex: "FF6B6B") // Grapefruit Pink
static let royalGold = Color(hex: "FFE66D") // Royal Gold
// MARK: - Sport Colors
static let mlbRed = Color(hex: "E31937")
static let nbaOrange = Color(hex: "F58426")
static let nhlBlue = Color(hex: "003087")
static let nflBrown = Color(hex: "8B5A2B")
static let mlsGreen = Color(hex: "00A651")
// MARK: - Dark Mode Colors
static let darkBackground1 = Color(hex: "1A535C") // Dark Teal
static let darkBackground2 = Color(hex: "143F46") // Darker teal
static let darkCardBackground = Color(hex: "1E5A64") // Slightly lighter teal
static let darkCardBackgroundLight = Color(hex: "2A6B75") // Card elevated
static let darkSurfaceGlow = Color(hex: "4ECDC4").opacity(0.15) // Cyan glow
static let darkTextPrimary = Color(hex: "F7FFF7") // Mint Cream
static let darkTextSecondary = Color(hex: "B8E8E4") // Light cyan-tinted
static let darkTextMuted = Color(hex: "7FADA8") // Muted teal
// MARK: - Light Mode Colors
static let lightBackground1 = Color(hex: "F7FFF7") // Mint Cream
static let lightBackground2 = Color(hex: "E8F8F5") // Slightly darker mint
static let lightCardBackground = Color.white
static let lightCardBackgroundElevated = Color(hex: "F7FFF7") // Mint cream
static let lightSurfaceBorder = Color(hex: "4ECDC4").opacity(0.3) // Cyan border
static let lightTextPrimary = Color(hex: "1A535C") // Dark Teal
static let lightTextSecondary = Color(hex: "2A6B75") // Medium teal
static let lightTextMuted = Color(hex: "5A9A94") // Light teal
// MARK: - Adaptive Gradients
static func backgroundGradient(_ colorScheme: ColorScheme) -> LinearGradient {
colorScheme == .dark
? LinearGradient(colors: [darkBackground1, darkBackground2], startPoint: .top, endPoint: .bottom)
: LinearGradient(colors: [lightBackground1, lightBackground2], startPoint: .top, endPoint: .bottom)
}
// MARK: - Adaptive Colors
static func cardBackground(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkCardBackground : lightCardBackground
}
static func cardBackgroundElevated(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkCardBackgroundLight : lightCardBackgroundElevated
}
static func textPrimary(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkTextPrimary : lightTextPrimary
}
static func textSecondary(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkTextSecondary : lightTextSecondary
}
static func textMuted(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkTextMuted : lightTextMuted
}
static func surfaceGlow(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkSurfaceGlow : lightSurfaceBorder
}
static func cardShadow(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? Color.black.opacity(0.3) : Color.black.opacity(0.08)
}
// MARK: - Typography
enum FontSize {
static let heroTitle: CGFloat = 34
static let sectionTitle: CGFloat = 24
static let cardTitle: CGFloat = 18
static let body: CGFloat = 16
static let caption: CGFloat = 14
static let micro: CGFloat = 12
}
// MARK: - Spacing
enum Spacing {
static let xxs: CGFloat = 4
static let xs: CGFloat = 8
static let sm: CGFloat = 12
static let md: CGFloat = 16
static let lg: CGFloat = 20
static let xl: CGFloat = 24
static let xxl: CGFloat = 32
}
// MARK: - Corner Radius
enum CornerRadius {
static let small: CGFloat = 8
static let medium: CGFloat = 12
static let large: CGFloat = 16
static let xlarge: CGFloat = 20
}
// MARK: - Animation
enum Animation {
static let springResponse: Double = 0.3
static let springDamping: Double = 0.7
static let staggerDelay: Double = 0.1
static let routeDrawDuration: Double = 2.0
static var spring: SwiftUI.Animation {
.spring(response: springResponse, dampingFraction: springDamping)
}
static var gentleSpring: SwiftUI.Animation {
.spring(response: 0.5, dampingFraction: 0.8)
}
}
}
// MARK: - Color Hex Extension
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
// MARK: - Environment Key for Theme
private struct ColorSchemeKey: EnvironmentKey {
static let defaultValue: ColorScheme = .light
}
extension EnvironmentValues {
var themeColorScheme: ColorScheme {
get { self[ColorSchemeKey.self] }
set { self[ColorSchemeKey.self] = newValue }
}
}

View File

@@ -0,0 +1,220 @@
//
// ViewModifiers.swift
// SportsTime
//
// Reusable view modifiers for consistent styling across the app.
//
import SwiftUI
// MARK: - Card Style Modifier
struct CardStyle: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
var cornerRadius: CGFloat = Theme.CornerRadius.large
var padding: CGFloat = Theme.Spacing.lg
func body(content: Content) -> some View {
content
.padding(padding)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
}
}
extension View {
func cardStyle(cornerRadius: CGFloat = Theme.CornerRadius.large, padding: CGFloat = Theme.Spacing.lg) -> some View {
modifier(CardStyle(cornerRadius: cornerRadius, padding: padding))
}
}
// MARK: - Glow Effect Modifier
struct GlowEffect: ViewModifier {
var color: Color = Theme.warmOrange
var radius: CGFloat = 8
func body(content: Content) -> some View {
content
.shadow(color: color.opacity(0.5), radius: radius / 2, y: 0)
.shadow(color: color.opacity(0.3), radius: radius, y: 0)
}
}
extension View {
func glowEffect(color: Color = Theme.warmOrange, radius: CGFloat = 8) -> some View {
modifier(GlowEffect(color: color, radius: radius))
}
}
// MARK: - Pressable Button Style
struct PressableButtonStyle: ButtonStyle {
var scale: CGFloat = 0.96
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? scale : 1.0)
.animation(Theme.Animation.spring, value: configuration.isPressed)
}
}
extension View {
func pressableStyle(scale: CGFloat = 0.96) -> some View {
buttonStyle(PressableButtonStyle(scale: scale))
}
}
// MARK: - Shimmer Effect Modifier
struct ShimmerEffect: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay {
GeometryReader { geo in
LinearGradient(
colors: [
.clear,
Color.white.opacity(0.3),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geo.size.width * 2)
.offset(x: -geo.size.width + (geo.size.width * 2 * phase))
}
.mask(content)
}
.onAppear {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerEffect())
}
}
// MARK: - Staggered Animation Modifier
struct StaggeredAnimation: ViewModifier {
var index: Int
var delay: Double = Theme.Animation.staggerDelay
@State private var appeared = false
func body(content: Content) -> some View {
content
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.onAppear {
withAnimation(Theme.Animation.spring.delay(Double(index) * delay)) {
appeared = true
}
}
}
}
extension View {
func staggeredAnimation(index: Int, delay: Double = Theme.Animation.staggerDelay) -> some View {
modifier(StaggeredAnimation(index: index, delay: delay))
}
}
// MARK: - Badge Style Modifier
struct BadgeStyle: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
var color: Color = Theme.warmOrange
var filled: Bool = true
func body(content: Content) -> some View {
content
.font(.system(size: Theme.FontSize.micro, weight: .semibold))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(filled ? color : color.opacity(0.2))
.foregroundStyle(filled ? .white : color)
.clipShape(Capsule())
}
}
extension View {
func badgeStyle(color: Color = Theme.warmOrange, filled: Bool = true) -> some View {
modifier(BadgeStyle(color: color, filled: filled))
}
}
// MARK: - Section Header Style
struct SectionHeaderStyle: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
extension View {
func sectionHeaderStyle() -> some View {
modifier(SectionHeaderStyle())
}
}
// MARK: - Themed Background Modifier
struct ThemedBackground: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.background(Theme.backgroundGradient(colorScheme))
}
}
extension View {
func themedBackground() -> some View {
modifier(ThemedBackground())
}
}
// MARK: - Sport Color Bar
struct SportColorBar: View {
let sport: Sport
var body: some View {
RoundedRectangle(cornerRadius: 2)
.fill(sport.themeColor)
.frame(width: 4)
}
}
// MARK: - Sport Extension for Theme Colors
extension Sport {
var themeColor: Color {
switch self {
case .mlb: return Theme.mlbRed
case .nba: return Theme.nbaOrange
case .nhl: return Theme.nhlBlue
case .nfl: return Theme.nflBrown
case .mls: return Theme.mlsGreen
}
}
}