Refactor itinerary display: separate day sections and travel sections
- Days are now headers with games as rows in that section - Travel between days shown in standalone "Travel" sections - Travel timing is flexible (user decides when to drive) - Removed complex ItineraryDay bundling approach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -326,17 +326,49 @@ struct TripDetailView: View {
|
|||||||
Text("Itinerary")
|
Text("Itinerary")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
ForEach(tripDays, id: \.self) { dayDate in
|
ForEach(itinerarySections.indices, id: \.self) { index in
|
||||||
SimpleDayCard(
|
let section = itinerarySections[index]
|
||||||
dayNumber: dayNumber(for: dayDate),
|
switch section {
|
||||||
date: dayDate,
|
case .day(let dayNumber, let date, let gamesOnDay):
|
||||||
gamesOnDay: gamesOn(date: dayDate),
|
DaySection(
|
||||||
travelOnDay: travelOn(date: dayDate)
|
dayNumber: dayNumber,
|
||||||
)
|
date: date,
|
||||||
|
games: gamesOnDay
|
||||||
|
)
|
||||||
|
case .travel(let segment):
|
||||||
|
TravelSection(segment: segment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build itinerary sections: days and travel between days
|
||||||
|
private var itinerarySections: [ItinerarySection] {
|
||||||
|
var sections: [ItinerarySection] = []
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
// Get all days
|
||||||
|
let days = tripDays
|
||||||
|
|
||||||
|
for (index, dayDate) in days.enumerated() {
|
||||||
|
let dayNum = index + 1
|
||||||
|
let gamesOnDay = gamesOn(date: dayDate)
|
||||||
|
|
||||||
|
// Add day section (even if no games - could be rest day)
|
||||||
|
if !gamesOnDay.isEmpty || index == 0 || index == days.count - 1 {
|
||||||
|
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for travel AFTER this day (between this day and next)
|
||||||
|
let travelAfterDay = travelDepartingAfter(date: dayDate, beforeNextGameDay: days.indices.contains(index + 1) ? days[index + 1] : nil)
|
||||||
|
for segment in travelAfterDay {
|
||||||
|
sections.append(.travel(segment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
/// All calendar days in the trip
|
/// All calendar days in the trip
|
||||||
private var tripDays: [Date] {
|
private var tripDays: [Date] {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
@@ -354,14 +386,6 @@ struct TripDetailView: View {
|
|||||||
return days
|
return days
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Day number for a given date
|
|
||||||
private func dayNumber(for date: Date) -> Int {
|
|
||||||
guard let firstDay = tripDays.first else { return 1 }
|
|
||||||
let calendar = Calendar.current
|
|
||||||
let days = calendar.dateComponents([.day], from: firstDay, to: date).day ?? 0
|
|
||||||
return days + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Games scheduled on a specific date
|
/// Games scheduled on a specific date
|
||||||
private func gamesOn(date: Date) -> [RichGame] {
|
private func gamesOn(date: Date) -> [RichGame] {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
@@ -372,16 +396,19 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
return allGameIds.compactMap { games[$0] }.filter { richGame in
|
return allGameIds.compactMap { games[$0] }.filter { richGame in
|
||||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
}
|
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Travel segments departing on a specific date
|
/// Travel segments that depart after a given day (for between-day travel)
|
||||||
private func travelOn(date: Date) -> [TravelSegment] {
|
private func travelDepartingAfter(date: Date, beforeNextGameDay: Date?) -> [TravelSegment] {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let dayStart = calendar.startOfDay(for: date)
|
let dayEnd = calendar.startOfDay(for: date)
|
||||||
|
|
||||||
return trip.travelSegments.filter { segment in
|
return trip.travelSegments.filter { segment in
|
||||||
calendar.startOfDay(for: segment.departureTime) == dayStart
|
let segmentDay = calendar.startOfDay(for: segment.departureTime)
|
||||||
|
// Travel is "after" this day if it departs on or after this day
|
||||||
|
// and arrives at a different city
|
||||||
|
return segmentDay == dayEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,13 +458,19 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Simple Day Card (queries games and travel separately by date)
|
// MARK: - Itinerary Section (enum for day vs travel sections)
|
||||||
|
|
||||||
struct SimpleDayCard: View {
|
enum ItinerarySection {
|
||||||
|
case day(dayNumber: Int, date: Date, games: [RichGame])
|
||||||
|
case travel(TravelSegment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Section (header + games)
|
||||||
|
|
||||||
|
struct DaySection: View {
|
||||||
let dayNumber: Int
|
let dayNumber: Int
|
||||||
let date: Date
|
let date: Date
|
||||||
let gamesOnDay: [RichGame]
|
let games: [RichGame]
|
||||||
let travelOnDay: [TravelSegment]
|
|
||||||
|
|
||||||
private var formattedDate: String {
|
private var formattedDate: String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
@@ -445,17 +478,16 @@ struct SimpleDayCard: View {
|
|||||||
return formatter.string(from: date)
|
return formatter.string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isRestDay: Bool {
|
private var gameCity: String? {
|
||||||
gamesOnDay.isEmpty && travelOnDay.isEmpty
|
games.first?.stadium.city
|
||||||
}
|
}
|
||||||
|
|
||||||
/// City where games are (from stadium)
|
private var isRestDay: Bool {
|
||||||
private var gameCity: String? {
|
games.isEmpty
|
||||||
gamesOnDay.first?.stadium.city
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
// Day header
|
// Day header
|
||||||
HStack {
|
HStack {
|
||||||
Text("Day \(dayNumber)")
|
Text("Day \(dayNumber)")
|
||||||
@@ -479,63 +511,30 @@ struct SimpleDayCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Games (each as its own row)
|
// City label (if games exist)
|
||||||
if !gamesOnDay.isEmpty {
|
if let city = gameCity {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
Label(city, systemImage: "mappin")
|
||||||
// City label
|
.font(.caption)
|
||||||
if let city = gameCity {
|
.foregroundStyle(.secondary)
|
||||||
Label(city, systemImage: "mappin")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(gamesOnDay, id: \.game.id) { richGame in
|
|
||||||
HStack {
|
|
||||||
Image(systemName: richGame.game.sport.iconName)
|
|
||||||
.foregroundStyle(.blue)
|
|
||||||
.frame(width: 20)
|
|
||||||
Text(richGame.matchupDescription)
|
|
||||||
.font(.subheadline)
|
|
||||||
Spacer()
|
|
||||||
Text(richGame.game.gameTime)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Travel segments (each as its own row, separate from games)
|
// Games
|
||||||
ForEach(travelOnDay) { segment in
|
ForEach(games, id: \.game.id) { richGame in
|
||||||
HStack(spacing: 8) {
|
HStack {
|
||||||
Image(systemName: segment.travelMode.iconName)
|
Image(systemName: richGame.game.sport.iconName)
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.blue)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
|
Text(richGame.matchupDescription)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.font(.subheadline)
|
||||||
Text("Drive to \(segment.toLocation.name)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
|
|
||||||
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Text(richGame.game.gameTime)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.background(Color.orange.opacity(0.1))
|
.background(Color(.tertiarySystemBackground))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -544,6 +543,52 @@ struct SimpleDayCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Travel Section (standalone travel between days)
|
||||||
|
|
||||||
|
struct TravelSection: View {
|
||||||
|
let segment: TravelSegment
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Travel header
|
||||||
|
Text("Travel")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
// Travel details
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: segment.travelMode.iconName)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.frame(width: 20)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(segment.fromLocation.name) → \(segment.toLocation.name)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Share Sheet
|
// MARK: - Share Sheet
|
||||||
|
|
||||||
struct ShareSheet: UIViewControllerRepresentable {
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
|
|||||||
Reference in New Issue
Block a user