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:
148
SportsTime/Core/Services/RouteDescriptionGenerator.swift
Normal file
148
SportsTime/Core/Services/RouteDescriptionGenerator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user