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>
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-clutch.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-midnight.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-orbit.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-retro.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SportsTime/Assets.xcassets/AppIcon-sunset.appiconset/icon.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 231 KiB |
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
85
SportsTime/Features/Trip/Views/TravelPlacement.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||