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,148 @@
//
// RouteDescriptionGenerator.swift
// SportsTime
//
// On-device AI route description generation using Foundation Models
//
import Foundation
import FoundationModels
/// Generates human-readable route descriptions using on-device AI
@MainActor
@Observable
final class RouteDescriptionGenerator {
static let shared = RouteDescriptionGenerator()
private(set) var isAvailable = false
private var session: LanguageModelSession?
// Cache generated descriptions by option ID
private var cache: [UUID: String] = [:]
private init() {
checkAvailability()
}
private func checkAvailability() {
// TEMPORARILY DISABLED: Foundation Models crashes in iOS 26.2 Simulator
// due to NLLanguageRecognizer.processString: assertion failure in Collection.suffix(from:)
// TODO: Re-enable when Apple fixes the Foundation Models framework
isAvailable = false
return
// Original code (disabled):
// switch SystemLanguageModel.default.availability {
// case .available:
// isAvailable = true
// session = LanguageModelSession(instructions: """
// You are a travel copywriter creating exciting, brief descriptions for sports road trips.
// Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
// Keep descriptions to 1-2 short sentences maximum.
// """)
// case .unavailable:
// isAvailable = false
// }
}
/// Generate a brief, exciting description for a route option
func generateDescription(for option: RouteDescriptionInput) async -> String? {
// Check cache first
if let cached = cache[option.id] {
return cached
}
guard isAvailable, let session = session else {
return nil
}
let prompt = buildPrompt(for: option)
do {
let response = try await session.respond(
to: prompt,
generating: RouteDescription.self
)
let description = response.content.description
cache[option.id] = description
return description
} catch LanguageModelSession.GenerationError.guardrailViolation {
return nil
} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
// Reset session if context exceeded
self.session = LanguageModelSession(instructions: """
You are a travel copywriter creating exciting, brief descriptions for sports road trips.
Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
Keep descriptions to 1-2 short sentences maximum.
""")
return nil
} catch {
return nil
}
}
private func buildPrompt(for option: RouteDescriptionInput) -> String {
let citiesText = option.cities.joined(separator: ", ")
let sportsText = option.sports.isEmpty ? "sports" : option.sports.joined(separator: ", ")
var details = [String]()
details.append("\(option.totalGames) games")
details.append("\(option.cities.count) cities: \(citiesText)")
if option.totalMiles > 0 {
details.append("\(Int(option.totalMiles)) miles")
}
if option.totalDrivingHours > 0 {
details.append("\(String(format: "%.1f", option.totalDrivingHours)) hours driving")
}
return """
Write a brief, exciting 1-sentence description for this sports road trip:
- Route: \(citiesText)
- Sports: \(sportsText)
- Details: \(details.joined(separator: ", "))
Make it sound like an adventure. Be concise.
"""
}
/// Clear the cache (e.g., when starting a new trip search)
func clearCache() {
cache.removeAll()
}
}
// MARK: - Generable Types
@Generable
struct RouteDescription {
@Guide(description: "A brief, exciting 1-2 sentence description of the road trip route")
let description: String
}
// MARK: - Input Model
struct RouteDescriptionInput: Identifiable {
let id: UUID
let cities: [String]
let sports: [String]
let totalGames: Int
let totalMiles: Double
let totalDrivingHours: Double
init(from option: ItineraryOption, games: [UUID: RichGame]) {
self.id = option.id
self.cities = Array(NSOrderedSet(array: option.stops.map { $0.city })) as? [String] ?? []
// Extract sports from games
let gameIds = option.stops.flatMap { $0.games }
let sportSet = Set(gameIds.compactMap { games[$0]?.game.sport.rawValue })
self.sports = Array(sportSet)
self.totalGames = option.totalGames
self.totalMiles = option.totalDistanceMiles
self.totalDrivingHours = option.totalDrivingHours
}
}

View File

@@ -282,7 +282,7 @@ actor StubDataProvider: DataProvider {
let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "")
let components = timeWithoutAMPM.split(separator: ":")
if let h = Int(components[0]) {
if !components.isEmpty, let h = Int(components[0]) {
hour = h
if isPM && hour != 12 {
hour += 12