feat: fix travel placement bug, add theme-based alternate icons, fix animated background crash

- Fix repeat-city travel placement: use stop indices instead of global city name
  matching so Follow Team trips with repeat cities show travel correctly
- Add TravelPlacement helper and regression tests (7 tests)
- Add alternate app icons for each theme, auto-switch on theme change
- Fix index-out-of-range crash in AnimatedSportsBackground (19 configs, was iterating 20)
- Add marketing video configs, engine, and new video components
- Add docs and data exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-06 09:36:34 -06:00
parent fdcecafaa3
commit 8e937a5646
77 changed files with 143400 additions and 83 deletions

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 231 KiB

View File

@@ -24,7 +24,7 @@ struct AnimatedSportsBackground: View {
RouteMapLayer(animate: animate)
// Floating sports icons with gentle glow
ForEach(0..<20, id: \.self) { index in
ForEach(0..<AnimatedSportsIcon.configs.count, id: \.self) { index in
AnimatedSportsIcon(index: index, animate: animate)
}
}
@@ -108,7 +108,7 @@ struct AnimatedSportsIcon: View {
let animate: Bool
@State private var glowOpacity: Double = 0
private let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
static let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
// Edge icons
(0.06, 0.08, "football.fill", -15, 0.85),
(0.94, 0.1, "basketball.fill", 12, 0.8),
@@ -133,7 +133,7 @@ struct AnimatedSportsIcon: View {
]
var body: some View {
let config = configs[index]
let config = Self.configs[index]
GeometryReader { geo in
ZStack {

View File

@@ -34,6 +34,19 @@ enum AppTheme: String, CaseIterable, Identifiable {
}
}
/// The alternate icon name in the asset catalog, or nil for the default (teal).
var alternateIconName: String? {
switch self {
case .teal: return nil
case .orbit: return "AppIcon-orbit"
case .retro: return "AppIcon-retro"
case .clutch: return "AppIcon-clutch"
case .monochrome: return "AppIcon-monochrome"
case .sunset: return "AppIcon-sunset"
case .midnight: return "AppIcon-midnight"
}
}
var previewColors: [Color] {
switch self {
case .teal: return [Color(hex: "4ECDC4"), Color(hex: "1A535C"), Color(hex: "FFE66D")]
@@ -56,9 +69,16 @@ final class ThemeManager {
var currentTheme: AppTheme {
didSet {
UserDefaults.standard.set(currentTheme.rawValue, forKey: "selectedTheme")
updateAppIcon(for: currentTheme)
}
}
private func updateAppIcon(for theme: AppTheme) {
let iconName = theme.alternateIconName
guard UIApplication.shared.alternateIconName != iconName else { return }
UIApplication.shared.setAlternateIconName(iconName)
}
private init() {
if let saved = UserDefaults.standard.string(forKey: "selectedTheme"),
let theme = AppTheme(rawValue: saved) {

View File

@@ -0,0 +1,85 @@
//
// TravelPlacement.swift
// SportsTime
//
// Computes which day number each travel segment should be displayed on.
// Extracted from TripDetailView for testability.
//
import Foundation
enum TravelPlacement {
/// Result of computing travel placement for a trip.
struct Placement {
let day: Int
let segmentIndex: Int
}
/// Computes which day each travel segment belongs to.
///
/// Uses stop indices (not city name matching) so repeat cities work correctly.
/// `trip.travelSegments[i]` connects `trip.stops[i]` to `trip.stops[i+1]`.
///
/// - Parameters:
/// - trip: The trip containing stops and travel segments.
/// - tripDays: Array of dates (one per trip day, start-of-day normalized).
/// - Returns: Dictionary mapping day number (1-based) to TravelSegment.
static func computeTravelByDay(
trip: Trip,
tripDays: [Date]
) -> [Int: TravelSegment] {
var travelByDay: [Int: TravelSegment] = [:]
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let minDay: Int
let maxDay: Int
let defaultDay: Int
if segmentIndex < trip.stops.count - 1 {
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
let fromDayNum = dayNumber(for: fromStop.departureDate, in: tripDays)
let toDayNum = dayNumber(for: toStop.arrivalDate, in: tripDays)
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, tripDays.count)
defaultDay = minDay
} else {
minDay = 1
maxDay = tripDays.count
defaultDay = 1
}
let clampedDefault: Int
if minDay <= maxDay {
clampedDefault = max(minDay, min(defaultDay, maxDay))
} else {
clampedDefault = minDay
}
travelByDay[clampedDefault] = segment
}
return travelByDay
}
/// Convert a date to a 1-based day number within the trip days array.
/// Returns 0 if before trip start, tripDays.count + 1 if after trip end.
static func dayNumber(for date: Date, in tripDays: [Date]) -> Int {
let calendar = Calendar.current
let target = calendar.startOfDay(for: date)
for (index, tripDay) in tripDays.enumerated() {
if calendar.startOfDay(for: tripDay) == target {
return index + 1
}
}
if let firstDay = tripDays.first, target < firstDay {
return 0
}
return tripDays.count + 1
}
}

View File

@@ -821,32 +821,38 @@ struct TripDetailView: View {
var sections: [ItinerarySection] = []
let days = tripDays
// Pre-calculate which day each travel segment belongs to
// Default: day after last game in departure city, or use validated override
// Pre-calculate which day each travel segment belongs to.
// Uses stop indices (not city name matching) so repeat cities work correctly.
// trip.travelSegments[i] connects trip.stops[i] trip.stops[i+1].
var travelByDay: [Int: TravelSegment] = [:]
for segment in trip.travelSegments {
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let travelId = stableTravelAnchorId(segment)
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
// Calculate valid range for this travel
// Travel can only happen AFTER the last game in departure city
let lastGameInFromCity = findLastGameDay(in: fromCity)
let firstGameInToCity = findFirstGameDay(in: toCity)
let minDay = max(lastGameInFromCity + 1, 1)
let maxDay = min(firstGameInToCity, tripDays.count)
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
// Calculate default day (day after last game in departure city)
// Use stop dates for precise placement (handles repeat cities)
let minDay: Int
let maxDay: Int
let defaultDay: Int
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count {
defaultDay = lastGameInFromCity + 1
} else if lastGameInFromCity > 0 {
defaultDay = lastGameInFromCity
if segmentIndex < trip.stops.count - 1 {
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
let fromDayNum = dayNumber(for: fromStop.departureDate)
let toDayNum = dayNumber(for: toStop.arrivalDate)
// Travel goes after the from stop's last day, up to the to stop's first day
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, days.count)
defaultDay = minDay
} else {
// Fallback: segment doesn't align with stops
minDay = 1
maxDay = days.count
defaultDay = 1
}
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
// Check for user override - only use if within valid range
if let override = travelOverrides[travelId],
validRange.contains(override.day) {
@@ -928,60 +934,46 @@ struct TripDetailView: View {
}?.city
}
/// Find the last day number that has a game in the given city
private func findLastGameDay(in city: String) -> Int {
let cityLower = city.lowercased()
let days = tripDays
var lastDay = 0
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
lastDay = dayNum
}
}
return lastDay
}
/// Find the first day number that has a game in the given city
private func findFirstGameDay(in city: String) -> Int {
let cityLower = city.lowercased()
/// Convert a date to a 1-based day number within the trip.
/// Returns 0 if the date is before the trip, or tripDays.count + 1 if after.
private func dayNumber(for date: Date) -> Int {
let calendar = Calendar.current
let target = calendar.startOfDay(for: date)
let days = tripDays
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
return dayNum
for (index, tripDay) in days.enumerated() {
if calendar.startOfDay(for: tripDay) == target {
return index + 1
}
}
return tripDays.count // Default to last day if no games found
// Date is outside the trip range
if let firstDay = days.first, target < firstDay {
return 0
}
return days.count + 1
}
/// Get valid day range for a travel segment
/// Travel can be displayed from the day of last departure game to the day of first arrival game
/// Get valid day range for a travel segment using stop indices.
/// Uses the from/to stop dates so repeat cities don't confuse placement.
private func validDayRange(for travelId: String) -> ClosedRange<Int>? {
// Find the segment matching this travel ID
guard let segment = trip.travelSegments.first(where: { stableTravelAnchorId($0) == travelId }) else {
// Find the segment index matching this travel ID
guard let segmentIndex = trip.travelSegments.firstIndex(where: { stableTravelAnchorId($0) == travelId }),
segmentIndex < trip.stops.count - 1 else {
return nil
}
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
// Travel can only happen AFTER the last game in departure city
// So the earliest travel day is the day AFTER the last game
let lastGameInFromCity = findLastGameDay(in: fromCity)
let minDay = max(lastGameInFromCity + 1, 1)
let fromDayNum = dayNumber(for: fromStop.departureDate)
let toDayNum = dayNumber(for: toStop.arrivalDate)
// Travel must happen BEFORE or ON the first game day in arrival city
let firstGameInToCity = findFirstGameDay(in: toCity)
let maxDay = min(firstGameInToCity, tripDays.count)
let minDay = max(fromDayNum + 1, 1)
let maxDay = min(toDayNum, tripDays.count)
// Handle edge case where minDay > maxDay shouldn't happen, but safeguard
if minDay > maxDay {
return minDay...minDay
return nil
}
return minDay...maxDay