diff --git a/SportsTime/Core/Models/Domain/Trip.swift b/SportsTime/Core/Models/Domain/Trip.swift index 0c01b27..b6ba890 100644 --- a/SportsTime/Core/Models/Domain/Trip.swift +++ b/SportsTime/Core/Models/Domain/Trip.swift @@ -6,7 +6,7 @@ // // - Expected Behavior: // - itineraryDays() returns one ItineraryDay per calendar day from first arrival to last activity -// - Last activity day is departure - 1 (departure is when you leave) +// - Last activity day includes departure day if there are games on that day // - tripDuration is max(1, days between first arrival and last departure + 1) // - cities returns deduplicated city list preserving visit order // - displayName uses " → " separator between cities @@ -113,16 +113,19 @@ struct Trip: Identifiable, Codable, Hashable { var days: [ItineraryDay] = [] let calendar = Calendar.current - guard let firstDate = stops.first?.arrivalDate else { return days } + guard let firstDate = stops.first?.arrivalDate, + let lastStop = stops.last else { return days } - // Find the last day with actual activity (last game date or last arrival) - // Departure date is the day AFTER the last game, so we use day before departure + // Find the last day with actual activity + // If the last stop has games, include the departure day (games can happen on departure day) + // Otherwise, use departure - 1 (departure is a pure travel day) let lastActivityDate: Date - if let lastDeparture = stops.last?.departureDate { - // Last activity is day before departure (departure is when you leave) - lastActivityDate = calendar.date(byAdding: .day, value: -1, to: lastDeparture) ?? lastDeparture + if !lastStop.games.isEmpty { + // Last stop has games - include departure day since games may occur on it + lastActivityDate = lastStop.departureDate } else { - lastActivityDate = stops.last?.arrivalDate ?? firstDate + // No games at last stop - departure is just when you leave + lastActivityDate = calendar.date(byAdding: .day, value: -1, to: lastStop.departureDate) ?? lastStop.departureDate } var currentDate = calendar.startOfDay(for: firstDate) diff --git a/SportsTime/Export/PDFGenerator.swift b/SportsTime/Export/PDFGenerator.swift index 08ba788..5845845 100644 --- a/SportsTime/Export/PDFGenerator.swift +++ b/SportsTime/Export/PDFGenerator.swift @@ -2,7 +2,7 @@ // PDFGenerator.swift // SportsTime // -// Generates beautiful PDF trip itineraries with maps, photos, and attractions. +// Generates magazine-style PDF trip itineraries with clean typography and generous whitespace. // import Foundation @@ -16,13 +16,43 @@ final class PDFGenerator { private let pageWidth: CGFloat = 612 // Letter size private let pageHeight: CGFloat = 792 - private let margin: CGFloat = 50 - private let contentWidth: CGFloat = 512 // 612 - 50*2 + private let margin: CGFloat = 54 // Print-safe margins + private let contentWidth: CGFloat = 504 // 612 - 54*2 + + // Header/footer heights + private let headerHeight: CGFloat = 0 // No top header (title in cover only) + private let footerHeight: CGFloat = 40 // Room for trip info + page number + + // Magazine-style spacing + private let sectionSpacing: CGFloat = 28 + private let itemSpacing: CGFloat = 16 + private let daySpacing: CGFloat = 36 // MARK: - Colors private let primaryColor = UIColor(red: 0.12, green: 0.23, blue: 0.54, alpha: 1.0) // Dark blue - private let accentColor = UIColor(red: 0.97, green: 0.45, blue: 0.09, alpha: 1.0) // Orange + private let accentColor = UIColor(red: 0.97, green: 0.45, blue: 0.09, alpha: 1.0) // SportsTime orange + private let textPrimary = UIColor.black + private let textSecondary = UIColor.darkGray + private let textMuted = UIColor.gray + + // Sport-specific accent colors + private func sportAccentColor(for sport: Sport) -> UIColor { + switch sport { + case .mlb: return UIColor(red: 0.76, green: 0.09, blue: 0.16, alpha: 1.0) // Baseball red + case .nba: return UIColor(red: 0.91, green: 0.33, blue: 0.11, alpha: 1.0) // Basketball orange + case .nhl: return UIColor(red: 0.0, green: 0.32, blue: 0.58, alpha: 1.0) // Hockey blue + case .nfl: return UIColor(red: 0.0, green: 0.21, blue: 0.38, alpha: 1.0) // Football navy + case .mls: return UIColor(red: 0.0, green: 0.5, blue: 0.35, alpha: 1.0) // Soccer green + case .wnba: return UIColor(red: 0.98, green: 0.33, blue: 0.14, alpha: 1.0) // WNBA orange + case .nwsl: return UIColor(red: 0.0, green: 0.45, blue: 0.65, alpha: 1.0) // NWSL blue + } + } + + // MARK: - Trip Info (for footer) + + private var currentTrip: Trip? + private var totalPages: Int = 1 // MARK: - Generate PDF @@ -32,33 +62,72 @@ final class PDFGenerator { assets: PDFAssetPrefetcher.PrefetchedAssets? = nil, itineraryItems: [ItineraryItem]? = nil ) async throws -> Data { + // Store trip for footer access + currentTrip = trip + + // Calculate total pages for footer + totalPages = calculateTotalPages(trip: trip, games: games, itineraryItems: itineraryItems) + let pdfRenderer = UIGraphicsPDFRenderer( bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) ) let data = pdfRenderer.pdfData { context in - // Page 1: Cover + // Page 1: Cover with hero map context.beginPage() drawCoverPage(context: context, trip: trip, assets: assets) - // Page 2: Route Overview - context.beginPage() - drawRouteOverviewPage(context: context, trip: trip, assets: assets) - - // Day-by-Day Itinerary + // Pages 2+: Day-by-Day Itinerary (magazine-style continuous flow) drawItineraryPages(context: context, trip: trip, games: games, assets: assets, itineraryItems: itineraryItems) - - // City Spotlight pages - drawCitySpotlightPages(context: context, trip: trip, games: games, assets: assets) - - // Final page: Summary - context.beginPage() - drawSummaryPage(context: context, trip: trip, games: games) } + currentTrip = nil return data } + /// Estimate total pages for footer display + private func calculateTotalPages(trip: Trip, games: [String: RichGame], itineraryItems: [ItineraryItem]?) -> Int { + var pages = 1 // Cover page + + let usableHeight = pageHeight - margin - footerHeight - 50 // Content area per page + var currentPageUsed: CGFloat = 0 + let allTripDays = trip.itineraryDays() + + if let items = itineraryItems, !items.isEmpty { + let groupedItems = Dictionary(grouping: items) { $0.day } + // Iterate over ALL trip days, not just days with items + for day in allTripDays { + let dayItems = groupedItems[day.dayNumber] ?? [] + let dayHeight = estimateItemsDayHeight(items: dayItems, games: games) + + if currentPageUsed + dayHeight > usableHeight { + pages += 1 + currentPageUsed = dayHeight + } else { + currentPageUsed += dayHeight + daySpacing + } + } + } else { + for day in trip.itineraryDays() { + let dayHeight = estimateDayHeight(day: day, games: games) + + if currentPageUsed + dayHeight > usableHeight { + pages += 1 + currentPageUsed = dayHeight + } else { + currentPageUsed += dayHeight + daySpacing + } + } + } + + // Add one more page for any remaining content + if currentPageUsed > 0 { + pages += 1 + } + + return max(2, pages) // At least cover + 1 itinerary page + } + // MARK: - Cover Page private func drawCoverPage( @@ -68,86 +137,100 @@ final class PDFGenerator { ) { var y: CGFloat = margin - // Hero route map (if available) + // Hero route map (full width, large) if let routeMap = assets?.routeMap { - let mapHeight: CGFloat = 350 + let mapHeight: CGFloat = 320 let mapRect = CGRect(x: margin, y: y, width: contentWidth, height: mapHeight) // Draw map with rounded corners - let path = UIBezierPath(roundedRect: mapRect, cornerRadius: 12) + let path = UIBezierPath(roundedRect: mapRect, cornerRadius: 16) context.cgContext.saveGState() path.addClip() routeMap.draw(in: mapRect) context.cgContext.restoreGState() - // Border - UIColor.lightGray.setStroke() + // Subtle border + UIColor.lightGray.withAlphaComponent(0.5).setStroke() path.lineWidth = 1 path.stroke() - y += mapHeight + 30 + y += mapHeight + sectionSpacing } else { - // No map - add extra spacing - y = 180 + y = 140 } - // Color accent bar - let barRect = CGRect(x: margin, y: y, width: contentWidth, height: 6) - let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 3) - primaryColor.setFill() + // Accent bar + let barRect = CGRect(x: margin, y: y, width: 80, height: 4) + let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 2) + accentColor.setFill() barPath.fill() - y += 25 + y += 20 - // Trip name + // Trip name (large, bold) + let titleFont = UIFont.systemFont(ofSize: 34, weight: .bold) let titleAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 32), - .foregroundColor: UIColor.black + .font: titleFont, + .foregroundColor: textPrimary ] - let titleRect = CGRect(x: margin, y: y, width: contentWidth, height: 50) + let titleRect = CGRect(x: margin, y: y, width: contentWidth, height: 45) (trip.displayName as NSString).draw(in: titleRect, withAttributes: titleAttributes) - y += 55 - - // Date range - let subtitleAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 18), - .foregroundColor: UIColor.darkGray - ] - let dateRect = CGRect(x: margin, y: y, width: contentWidth, height: 30) - (trip.formattedDateRange as NSString).draw(in: dateRect, withAttributes: subtitleAttributes) - y += 45 - - // Quick stats row - let statsText = "\(trip.stops.count) Cities | \(trip.totalGames) Games | \(trip.formattedTotalDistance)" - let statsAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 16, weight: .medium), - .foregroundColor: primaryColor - ] - let statsRect = CGRect(x: margin, y: y, width: contentWidth, height: 25) - (statsText as NSString).draw(in: statsRect, withAttributes: statsAttributes) - y += 40 - - // Driving info - let drivingText = "\(trip.tripDuration) Days | \(trip.formattedTotalDriving) Total Driving" - let drivingAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 14), - .foregroundColor: UIColor.gray - ] - let drivingRect = CGRect(x: margin, y: y, width: contentWidth, height: 22) - (drivingText as NSString).draw(in: drivingRect, withAttributes: drivingAttributes) y += 50 - // Cities list - drawSectionHeader("Route", at: &y) + // Date range (formatted nicely) + let dateRangeText = formatCoverDateRange(trip: trip) + let dateAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 18, weight: .regular), + .foregroundColor: textSecondary + ] + (dateRangeText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: dateAttributes) + y += 36 + // Quick stats with bullet separators + let statsText = "\(trip.stops.count) Cities • \(trip.totalGames) Games • \(formatMiles(trip.totalDistanceMiles))" + let statsAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 15, weight: .medium), + .foregroundColor: primaryColor + ] + (statsText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: statsAttributes) + y += 28 + + // Driving summary + let drivingText = "\(trip.tripDuration) Days • \(trip.formattedTotalDriving) driving" + let drivingAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 14), + .foregroundColor: textMuted + ] + (drivingText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: drivingAttributes) + y += sectionSpacing + 12 + + // Route overview (compact city list) + drawCoverSectionHeader("Route", at: &y) + + let cityFont = UIFont.systemFont(ofSize: 13) let cityAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 13), - .foregroundColor: UIColor.darkGray + .font: cityFont, + .foregroundColor: textSecondary ] + // Draw cities in a flowing layout + var cityLine = "" for (index, city) in trip.cities.enumerated() { - let cityText = "\(index + 1). \(city)" - let cityRect = CGRect(x: margin + 15, y: y, width: contentWidth - 15, height: 20) - (cityText as NSString).draw(in: cityRect, withAttributes: cityAttributes) + let separator = index == 0 ? "" : " → " + let testLine = cityLine + separator + city + + // Check if we need to wrap + let testSize = (testLine as NSString).size(withAttributes: cityAttributes) + if testSize.width > contentWidth - 20 && !cityLine.isEmpty { + (cityLine as NSString).draw(at: CGPoint(x: margin + 12, y: y), withAttributes: cityAttributes) + y += 22 + cityLine = city + } else { + cityLine = testLine + } + } + // Draw remaining + if !cityLine.isEmpty { + (cityLine as NSString).draw(at: CGPoint(x: margin + 12, y: y), withAttributes: cityAttributes) y += 22 } @@ -155,89 +238,98 @@ final class PDFGenerator { drawFooter(context: context, pageNumber: 1) } - // MARK: - Route Overview Page + /// Format date range for cover: "Jan 18 – Jan 25, 2026" + private func formatCoverDateRange(trip: Trip) -> String { + let formatter = DateFormatter() - private func drawRouteOverviewPage( - context: UIGraphicsPDFRendererContext, - trip: Trip, - assets: PDFAssetPrefetcher.PrefetchedAssets? - ) { - var y: CGFloat = margin - - // Page header - drawPageHeader("Route Overview", at: &y) - - // Route map (larger on this page) - if let routeMap = assets?.routeMap { - let mapHeight: CGFloat = 300 - let mapRect = CGRect(x: margin, y: y, width: contentWidth, height: mapHeight) - - let path = UIBezierPath(roundedRect: mapRect, cornerRadius: 8) - context.cgContext.saveGState() - path.addClip() - routeMap.draw(in: mapRect) - context.cgContext.restoreGState() - - UIColor.lightGray.setStroke() - path.lineWidth = 1 - path.stroke() - - y += mapHeight + 25 + // Get first and last dates + guard let firstStop = trip.stops.first, + let lastStop = trip.stops.last else { + return trip.formattedDateRange } - // Stop table header - drawSectionHeader("Stops", at: &y) + let startDate = firstStop.arrivalDate + let endDate = lastStop.departureDate - // Table headers - let headerAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 11), - .foregroundColor: UIColor.darkGray + // Same year? + let startYear = Calendar.current.component(.year, from: startDate) + let endYear = Calendar.current.component(.year, from: endDate) + + if startYear == endYear { + formatter.dateFormat = "MMM d" + let startStr = formatter.string(from: startDate) + formatter.dateFormat = "MMM d, yyyy" + let endStr = formatter.string(from: endDate) + return "\(startStr) – \(endStr)" + } else { + formatter.dateFormat = "MMM d, yyyy" + return "\(formatter.string(from: startDate)) – \(formatter.string(from: endDate))" + } + } + + /// Format miles: "1,234 mi" + private func formatMiles(_ miles: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + let formatted = formatter.string(from: NSNumber(value: miles)) ?? "\(Int(miles))" + return "\(formatted) mi" + } + + private func drawCoverSectionHeader(_ title: String, at y: inout CGFloat) { + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 12, weight: .semibold), + .foregroundColor: textMuted ] - - let col1 = margin + 5 - let col2 = margin + 35 - let col3 = margin + 200 - let col4 = margin + 310 - let col5 = margin + 410 - - ("#" as NSString).draw(at: CGPoint(x: col1, y: y), withAttributes: headerAttributes) - ("City" as NSString).draw(at: CGPoint(x: col2, y: y), withAttributes: headerAttributes) - ("Arrival" as NSString).draw(at: CGPoint(x: col3, y: y), withAttributes: headerAttributes) - ("Games" as NSString).draw(at: CGPoint(x: col4, y: y), withAttributes: headerAttributes) - ("Distance" as NSString).draw(at: CGPoint(x: col5, y: y), withAttributes: headerAttributes) + (title.uppercased() as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attributes) y += 20 + } + + // MARK: - Enhanced Footer + + private func drawFooter(context: UIGraphicsPDFRendererContext, pageNumber: Int) { + let footerY = pageHeight - footerHeight // Separator line - drawHorizontalLine(at: y) - y += 8 + let linePath = UIBezierPath() + linePath.move(to: CGPoint(x: margin, y: footerY)) + linePath.addLine(to: CGPoint(x: pageWidth - margin, y: footerY)) + UIColor.lightGray.withAlphaComponent(0.3).setStroke() + linePath.lineWidth = 0.5 + linePath.stroke() - // Table rows - let rowAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 11), - .foregroundColor: UIColor.black + let textY = footerY + 12 + + // Left: SportsTime branding + let brandAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 9, weight: .semibold), + .foregroundColor: accentColor ] + ("SportsTime" as NSString).draw(at: CGPoint(x: margin, y: textY), withAttributes: brandAttributes) - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM d" - - for (index, stop) in trip.stops.enumerated() { - ("\(index + 1)" as NSString).draw(at: CGPoint(x: col1, y: y), withAttributes: rowAttributes) - ("\(stop.city), \(stop.state)" as NSString).draw(at: CGPoint(x: col2, y: y), withAttributes: rowAttributes) - (dateFormatter.string(from: stop.arrivalDate) as NSString).draw(at: CGPoint(x: col3, y: y), withAttributes: rowAttributes) - ("\(stop.games.count)" as NSString).draw(at: CGPoint(x: col4, y: y), withAttributes: rowAttributes) - - // Distance to next stop - if index < trip.travelSegments.count { - let segment = trip.travelSegments[index] - (segment.formattedDistance as NSString).draw(at: CGPoint(x: col5, y: y), withAttributes: rowAttributes) - } else { - ("--" as NSString).draw(at: CGPoint(x: col5, y: y), withAttributes: rowAttributes) - } - - y += 22 + // Center: Trip name + dates + if let trip = currentTrip { + let centerText = "\(trip.displayName) • \(formatCoverDateRange(trip: trip))" + let centerAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 9), + .foregroundColor: textMuted + ] + let centerSize = (centerText as NSString).size(withAttributes: centerAttributes) + let centerX = (pageWidth - centerSize.width) / 2 + (centerText as NSString).draw(at: CGPoint(x: centerX, y: textY), withAttributes: centerAttributes) } - drawFooter(context: context, pageNumber: 2) + // Right: Page number + let pageText = "Page \(pageNumber)" + let pageAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 9), + .foregroundColor: textMuted + ] + let pageSize = (pageText as NSString).size(withAttributes: pageAttributes) + (pageText as NSString).draw( + at: CGPoint(x: pageWidth - margin - pageSize.width, y: textY), + withAttributes: pageAttributes + ) } // MARK: - Itinerary Pages @@ -249,25 +341,30 @@ final class PDFGenerator { assets: PDFAssetPrefetcher.PrefetchedAssets?, itineraryItems: [ItineraryItem]? ) { - var pageNumber = 3 + var pageNumber = 2 var y: CGFloat = margin var isFirstDayOnPage = true context.beginPage() - drawPageHeader("Day-by-Day Itinerary", at: &y) + drawItineraryPageHeader(at: &y) + + // Usable content height (accounting for footer) + let usableHeight = pageHeight - footerHeight - 20 + + // Get all trip days to ensure we render every day + let allTripDays = trip.itineraryDays() // If we have custom itinerary items, use their order; otherwise fall back to derived order if let items = itineraryItems, !items.isEmpty { - // Group items by day, sorted by sortOrder let groupedItems = Dictionary(grouping: items) { $0.day } - let sortedDays = groupedItems.keys.sorted() - for dayNumber in sortedDays { - guard let dayItems = groupedItems[dayNumber]?.sorted(by: { $0.sortOrder < $1.sortOrder }) else { continue } + // Iterate over ALL trip days, not just days with items + for day in allTripDays { + let dayNumber = day.dayNumber + let dayItems = groupedItems[dayNumber]?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? [] - // Check if we need a new page let estimatedHeight = estimateItemsDayHeight(items: dayItems, games: games) - if y + estimatedHeight > pageHeight - 80 { + if y + estimatedHeight > usableHeight { drawFooter(context: context, pageNumber: pageNumber) pageNumber += 1 context.beginPage() @@ -276,17 +373,13 @@ final class PDFGenerator { } if !isFirstDayOnPage { - y += 15 // Space between days + y += daySpacing } - // Find corresponding itinerary day for date info - let itineraryDays = trip.itineraryDays() - let itineraryDay = itineraryDays.first { $0.dayNumber == dayNumber } - y = drawDayWithItems( context: context, dayNumber: dayNumber, - date: itineraryDay?.date, + date: day.date, items: dayItems, games: games, assets: assets, @@ -296,11 +389,9 @@ final class PDFGenerator { isFirstDayOnPage = false } } else { - // Fall back to derived order from trip for day in trip.itineraryDays() { - // Check if we need a new page let estimatedHeight = estimateDayHeight(day: day, games: games) - if y + estimatedHeight > pageHeight - 80 { + if y + estimatedHeight > usableHeight { drawFooter(context: context, pageNumber: pageNumber) pageNumber += 1 context.beginPage() @@ -309,7 +400,7 @@ final class PDFGenerator { } if !isFirstDayOnPage { - y += 15 // Space between days + y += daySpacing } y = drawDay( @@ -327,25 +418,34 @@ final class PDFGenerator { drawFooter(context: context, pageNumber: pageNumber) } + private func drawItineraryPageHeader(at y: inout CGFloat) { + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 11, weight: .semibold), + .foregroundColor: textMuted + ] + ("ITINERARY" as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attributes) + y += sectionSpacing + } + /// Estimate height for a day rendered from ItineraryItems private func estimateItemsDayHeight(items: [ItineraryItem], games: [String: RichGame]) -> CGFloat { - var height: CGFloat = 60 // Day header + city + var height: CGFloat = 50 // Day header for item in items { switch item.kind { case .game: - height += 80 // Game card + height += 44 + itemSpacing // Simplified game entry case .travel: - height += 50 // Travel segment + height += 36 + itemSpacing // Travel segment case .custom: - height += 55 // Custom item card + height += 40 + itemSpacing // Custom item } } return height } - /// Draw a day using ItineraryItems for order + /// Draw a day using ItineraryItems for order (magazine-style) private func drawDayWithItems( context: UIGraphicsPDFRendererContext, dayNumber: Int, @@ -357,82 +457,52 @@ final class PDFGenerator { ) -> CGFloat { var currentY = y - // Day header with accent background - let headerRect = CGRect(x: margin, y: currentY, width: contentWidth, height: 32) - let headerPath = UIBezierPath(roundedRect: headerRect, cornerRadius: 6) - primaryColor.withAlphaComponent(0.1).setFill() - headerPath.fill() - + // Day header: "Day 1 • Sat, Jan 18" let dayHeaderAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 16), - .foregroundColor: primaryColor + .font: UIFont.systemFont(ofSize: 16, weight: .bold), + .foregroundColor: textPrimary ] - let dateText: String + var dayHeaderText = "Day \(dayNumber)" if let date = date { let formatter = DateFormatter() - formatter.dateFormat = "EEEE, MMM d" - dateText = formatter.string(from: date) - } else { - dateText = "" + formatter.dateFormat = "EEE, MMM d" // "Sat, Jan 18" + dayHeaderText += " • \(formatter.string(from: date))" } - let dayHeader = "Day \(dayNumber)" + (dateText.isEmpty ? "" : " - \(dateText)") - (dayHeader as NSString).draw( - at: CGPoint(x: margin + 12, y: currentY + 7), - withAttributes: dayHeaderAttributes - ) - currentY += 40 - - // Determine primary city from first game or travel - var primaryCity: String? - for item in items { - switch item.kind { - case .game(_, let city): - primaryCity = city - break - case .travel(let info): - primaryCity = info.toCity - break - case .custom(let info): - if let address = info.address { - primaryCity = address - break - } - } - if primaryCity != nil { break } - } + (dayHeaderText as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: dayHeaderAttributes) + currentY += 28 + // Location subtitle (if we can determine it) + let primaryCity = determinePrimaryCity(from: items, games: games) if let city = primaryCity { - let cityAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 13), - .foregroundColor: UIColor.darkGray + let locationAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 12), + .foregroundColor: textMuted ] - let cityText = "Location: \(city)" - (cityText as NSString).draw(at: CGPoint(x: margin + 12, y: currentY), withAttributes: cityAttributes) - currentY += 25 + (city as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: locationAttributes) + currentY += 22 } + currentY += 8 // Space before items + // Draw items in sortOrder var hasContent = false for item in items { switch item.kind { case .game(let gameId, _): if let richGame = games[gameId] { - currentY = drawGameCard( - context: context, - richGame: richGame, - assets: assets, - y: currentY - ) - currentY += 10 + currentY = drawGameEntry(richGame: richGame, y: currentY) + currentY += itemSpacing hasContent = true } case .travel(let travelInfo): - currentY = drawTravelItem(info: travelInfo, y: currentY) + currentY = drawTravelEntry(info: travelInfo, y: currentY) + currentY += itemSpacing hasContent = true case .custom(let customInfo): - currentY = drawCustomItem(info: customInfo, y: currentY) + currentY = drawCustomEntry(info: customInfo, y: currentY) + currentY += itemSpacing hasContent = true } } @@ -441,130 +511,194 @@ final class PDFGenerator { if !hasContent { let restAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.italicSystemFont(ofSize: 13), - .foregroundColor: UIColor.gray + .foregroundColor: textMuted ] - let restRect = CGRect(x: margin + 12, y: currentY, width: contentWidth - 12, height: 25) - ("Rest Day - Explore the city!" as NSString).draw(in: restRect, withAttributes: restAttributes) - currentY += 30 + ("Rest Day – Explore the city!" as NSString).draw( + at: CGPoint(x: margin + 12, y: currentY), + withAttributes: restAttributes + ) + currentY += 28 } - // Day separator line - drawHorizontalLine(at: currentY + 5, alpha: 0.3) - currentY += 15 - return currentY } - /// Draw a travel item from ItineraryItem - private func drawTravelItem(info: TravelInfo, y: CGFloat) -> CGFloat { - let currentY = y - - let travelRect = CGRect(x: margin + 10, y: currentY, width: contentWidth - 20, height: 35) - let travelPath = UIBezierPath(roundedRect: travelRect, cornerRadius: 6) - UIColor.systemGray6.setFill() - travelPath.fill() - - // Car icon placeholder - let iconAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 14), - .foregroundColor: UIColor.gray - ] - (">" as NSString).draw(at: CGPoint(x: margin + 20, y: currentY + 10), withAttributes: iconAttributes) - - // Travel text - let travelAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 12), - .foregroundColor: UIColor.darkGray - ] - - var travelText = "Drive: \(info.fromCity) → \(info.toCity)" - if !info.formattedDistance.isEmpty || !info.formattedDuration.isEmpty { - let details = [info.formattedDistance, info.formattedDuration].filter { !$0.isEmpty }.joined(separator: " | ") - if !details.isEmpty { - travelText += " | \(details)" + /// Determine primary city from items + private func determinePrimaryCity(from items: [ItineraryItem], games: [String: RichGame]) -> String? { + for item in items { + switch item.kind { + case .game(let gameId, let city): + // Prefer stadium city if available + if let richGame = games[gameId] { + return "\(richGame.stadium.city), \(richGame.stadium.state)" + } + return city + case .travel(let info): + return info.toCity + case .custom: + continue // Skip custom items for city detection } } - - (travelText as NSString).draw( - at: CGPoint(x: margin + 40, y: currentY + 10), - withAttributes: travelAttributes - ) - - return currentY + 45 + return nil } - /// Draw a custom item card - private func drawCustomItem(info: CustomInfo, y: CGFloat) -> CGFloat { + // MARK: - Magazine-Style Item Drawing + + /// Draw a simplified game entry: "Lakers vs. Celtics • 7:00 PM" + private func drawGameEntry(richGame: RichGame, y: CGFloat) -> CGFloat { let currentY = y - // Card background - let cardRect = CGRect(x: margin + 10, y: currentY, width: contentWidth - 20, height: 45) - let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 8) + // Sport color accent (left border) + let accentBarRect = CGRect(x: margin, y: currentY, width: 3, height: 36) + let sportColor = sportAccentColor(for: richGame.game.sport) + sportColor.setFill() + UIBezierPath(roundedRect: accentBarRect, cornerRadius: 1.5).fill() - accentColor.withAlphaComponent(0.08).setFill() - cardPath.fill() + // Teams: "Lakers vs. Celtics" + let teamsText = "\(richGame.awayTeam.name) vs. \(richGame.homeTeam.name)" + let teamsAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 14, weight: .semibold), + .foregroundColor: textPrimary + ] + (teamsText as NSString).draw(at: CGPoint(x: margin + 14, y: currentY), withAttributes: teamsAttributes) - // Card border - accentColor.withAlphaComponent(0.3).setStroke() - cardPath.lineWidth = 1 - cardPath.stroke() + // Time: "7:00 PM" + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "h:mm a" // "7:00 PM" + let timeText = timeFormatter.string(from: richGame.game.gameDate) + let timeAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 13), + .foregroundColor: textSecondary + ] + (timeText as NSString).draw(at: CGPoint(x: margin + 14, y: currentY + 20), withAttributes: timeAttributes) + + // Venue name (subtle, after time) + let venueText = " • \(richGame.stadium.name)" + let venueAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 13), + .foregroundColor: textMuted + ] + let timeSize = (timeText as NSString).size(withAttributes: timeAttributes) + (venueText as NSString).draw( + at: CGPoint(x: margin + 14 + timeSize.width, y: currentY + 20), + withAttributes: venueAttributes + ) + + return currentY + 40 + } + + /// Draw a travel entry: "→ 3h 45m • 220 mi to Phoenix" + private func drawTravelEntry(info: TravelInfo, y: CGFloat) -> CGFloat { + let currentY = y + + // Arrow indicator + let arrowAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 12, weight: .medium), + .foregroundColor: textMuted + ] + ("→" as NSString).draw(at: CGPoint(x: margin + 4, y: currentY + 2), withAttributes: arrowAttributes) + + // Build travel text: "3h 45m • 220 mi to Phoenix" + var travelParts: [String] = [] + if !info.formattedDuration.isEmpty { + travelParts.append(info.formattedDuration) + } + if !info.formattedDistance.isEmpty { + // Convert to miles format + travelParts.append(info.formattedDistance) + } + travelParts.append("to \(info.toCity)") + + let travelText = travelParts.joined(separator: " • ") + let travelAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 12), + .foregroundColor: textSecondary + ] + (travelText as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 2), withAttributes: travelAttributes) + + return currentY + 28 + } + + /// Draw a custom item entry: "🍽️ Lunch at Joe's Diner • 123 Main St" + private func drawCustomEntry(info: CustomInfo, y: CGFloat) -> CGFloat { + let currentY = y // Icon let iconAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 16), - .foregroundColor: accentColor + .font: UIFont.systemFont(ofSize: 14) ] - (info.icon as NSString).draw(at: CGPoint(x: margin + 20, y: currentY + 12), withAttributes: iconAttributes) + (info.icon as NSString).draw(at: CGPoint(x: margin + 2, y: currentY), withAttributes: iconAttributes) // Title let titleAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 13), - .foregroundColor: UIColor.black + .font: UIFont.systemFont(ofSize: 13, weight: .medium), + .foregroundColor: textPrimary ] - (info.title as NSString).draw(at: CGPoint(x: margin + 45, y: currentY + 8), withAttributes: titleAttributes) + (info.title as NSString).draw(at: CGPoint(x: margin + 24, y: currentY), withAttributes: titleAttributes) - // Time and address - var detailParts: [String] = [] - if let time = info.time { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - detailParts.append(formatter.string(from: time)) - } + // Address (if present) if let address = info.address { - detailParts.append(address) - } - - if !detailParts.isEmpty { - let detailAttributes: [NSAttributedString.Key: Any] = [ + let addressAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11), - .foregroundColor: UIColor.gray + .foregroundColor: textMuted ] - let detailText = detailParts.joined(separator: " | ") - (detailText as NSString).draw(at: CGPoint(x: margin + 45, y: currentY + 26), withAttributes: detailAttributes) + (address as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 18), withAttributes: addressAttributes) + return currentY + 36 } - return currentY + 55 + return currentY + 22 + } + + /// Draw a lodging entry: "🏨 Marriott Downtown • 123 Main St" + private func drawLodgingEntry(name: String, address: String?, y: CGFloat) -> CGFloat { + let currentY = y + + // Hotel icon + let iconAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 14) + ] + ("🏨" as NSString).draw(at: CGPoint(x: margin + 2, y: currentY), withAttributes: iconAttributes) + + // Name + let nameAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 13, weight: .medium), + .foregroundColor: textPrimary + ] + (name as NSString).draw(at: CGPoint(x: margin + 24, y: currentY), withAttributes: nameAttributes) + + // Address + if let address = address { + let addressAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 11), + .foregroundColor: textMuted + ] + (address as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 18), withAttributes: addressAttributes) + return currentY + 36 + } + + return currentY + 22 } private func estimateDayHeight(day: ItineraryDay, games: [String: RichGame]) -> CGFloat { - var height: CGFloat = 60 // Day header + city + var height: CGFloat = 50 // Day header + city - // Games - height += CGFloat(day.gameIds.count) * 80 // Each game card + // Games (simplified) + height += CGFloat(day.gameIds.count) * (44 + itemSpacing) // Travel if day.hasTravelSegment { - height += 50 + height += 36 + itemSpacing } // Rest day - if day.isRestDay { - height += 30 + if day.isRestDay && day.gameIds.isEmpty { + height += 28 } return height } + /// Draw a day using derived order (magazine-style) private func drawDay( context: UIGraphicsPDFRendererContext, day: ItineraryDay, @@ -574,559 +708,82 @@ final class PDFGenerator { ) -> CGFloat { var currentY = y - // Day header with accent background - let headerRect = CGRect(x: margin, y: currentY, width: contentWidth, height: 32) - let headerPath = UIBezierPath(roundedRect: headerRect, cornerRadius: 6) - primaryColor.withAlphaComponent(0.1).setFill() - headerPath.fill() - + // Day header: "Day 1 • Sat, Jan 18" let dayHeaderAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 16), - .foregroundColor: primaryColor + .font: UIFont.systemFont(ofSize: 16, weight: .bold), + .foregroundColor: textPrimary ] - let dayHeader = "Day \(day.dayNumber) - \(day.formattedDate)" - (dayHeader as NSString).draw( - at: CGPoint(x: margin + 12, y: currentY + 7), - withAttributes: dayHeaderAttributes - ) - currentY += 40 - // City + let formatter = DateFormatter() + formatter.dateFormat = "EEE, MMM d" // "Sat, Jan 18" + let dayHeaderText = "Day \(day.dayNumber) • \(formatter.string(from: day.date))" + (dayHeaderText as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: dayHeaderAttributes) + currentY += 28 + + // Location subtitle if let city = day.primaryCity { - let cityAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 13), - .foregroundColor: UIColor.darkGray + let locationAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 12), + .foregroundColor: textMuted ] - let cityText = "Location: \(city)" - (cityText as NSString).draw(at: CGPoint(x: margin + 12, y: currentY), withAttributes: cityAttributes) - currentY += 25 + (city as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: locationAttributes) + currentY += 22 } + currentY += 8 // Space before items + // Games for gameId in day.gameIds { if let richGame = games[gameId] { - currentY = drawGameCard( - context: context, - richGame: richGame, - assets: assets, - y: currentY - ) - currentY += 10 + currentY = drawGameEntry(richGame: richGame, y: currentY) + currentY += itemSpacing } } // Travel segments for segment in day.travelSegments { - currentY = drawTravelSegment(segment: segment, y: currentY) + currentY = drawTravelSegmentEntry(segment: segment, y: currentY) + currentY += itemSpacing } // Rest day if day.isRestDay && day.gameIds.isEmpty { let restAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.italicSystemFont(ofSize: 13), - .foregroundColor: UIColor.gray + .foregroundColor: textMuted ] - let restRect = CGRect(x: margin + 12, y: currentY, width: contentWidth - 12, height: 25) - ("Rest Day - Explore the city!" as NSString).draw(in: restRect, withAttributes: restAttributes) - currentY += 30 + ("Rest Day – Explore the city!" as NSString).draw( + at: CGPoint(x: margin + 12, y: currentY), + withAttributes: restAttributes + ) + currentY += 28 } - // Day separator line - drawHorizontalLine(at: currentY + 5, alpha: 0.3) - currentY += 15 - return currentY } - private func drawGameCard( - context: UIGraphicsPDFRendererContext, - richGame: RichGame, - assets: PDFAssetPrefetcher.PrefetchedAssets?, - y: CGFloat - ) -> CGFloat { + /// Draw a travel segment entry (from TravelSegment, not TravelInfo) + private func drawTravelSegmentEntry(segment: TravelSegment, y: CGFloat) -> CGFloat { let currentY = y - // Card background - let cardRect = CGRect(x: margin + 10, y: currentY, width: contentWidth - 20, height: 65) - let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 8) - - UIColor.white.setFill() - cardPath.fill() - - // Card border with team color if available - let borderColor = teamColor(from: richGame.homeTeam.primaryColor) ?? primaryColor - borderColor.withAlphaComponent(0.5).setStroke() - cardPath.lineWidth = 1.5 - cardPath.stroke() - - // Sport badge - let sportText = richGame.game.sport.rawValue.uppercased() - let badgeRect = CGRect(x: margin + 20, y: currentY + 8, width: 35, height: 18) - let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 4) - borderColor.withAlphaComponent(0.2).setFill() - badgePath.fill() - - let badgeAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 9), - .foregroundColor: borderColor + // Arrow indicator + let arrowAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 12, weight: .medium), + .foregroundColor: textMuted ] - (sportText as NSString).draw( - at: CGPoint(x: badgeRect.midX - 10, y: currentY + 10), - withAttributes: badgeAttributes - ) + ("→" as NSString).draw(at: CGPoint(x: margin + 4, y: currentY + 2), withAttributes: arrowAttributes) - // Team logos (if available) - let logoSize: CGFloat = 28 - let logoY = currentY + 30 - - // Away team logo - if let awayLogo = assets?.teamLogos[richGame.awayTeam.id] { - let awayLogoRect = CGRect(x: margin + 20, y: logoY, width: logoSize, height: logoSize) - awayLogo.draw(in: awayLogoRect) - } else { - // Placeholder circle with abbreviation - drawTeamPlaceholder( - abbreviation: richGame.awayTeam.abbreviation, - at: CGPoint(x: margin + 20, y: logoY), - size: logoSize, - color: teamColor(from: richGame.awayTeam.primaryColor) - ) - } - - // "at" text - let atAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 11), - .foregroundColor: UIColor.gray - ] - ("@" as NSString).draw(at: CGPoint(x: margin + 55, y: logoY + 8), withAttributes: atAttributes) - - // Home team logo - if let homeLogo = assets?.teamLogos[richGame.homeTeam.id] { - let homeLogoRect = CGRect(x: margin + 70, y: logoY, width: logoSize, height: logoSize) - homeLogo.draw(in: homeLogoRect) - } else { - drawTeamPlaceholder( - abbreviation: richGame.homeTeam.abbreviation, - at: CGPoint(x: margin + 70, y: logoY), - size: logoSize, - color: teamColor(from: richGame.homeTeam.primaryColor) - ) - } - - // Matchup text - let matchupAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 13), - .foregroundColor: UIColor.black - ] - let matchupText = "\(richGame.awayTeam.name) at \(richGame.homeTeam.name)" - (matchupText as NSString).draw( - at: CGPoint(x: margin + 110, y: currentY + 12), - withAttributes: matchupAttributes - ) - - // Venue and time - let venueAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 11), - .foregroundColor: UIColor.darkGray - ] - let venueText = "\(richGame.stadium.name) | \(richGame.localGameTimeShort)" - (venueText as NSString).draw( - at: CGPoint(x: margin + 110, y: currentY + 30), - withAttributes: venueAttributes - ) - - // City - let cityAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 10), - .foregroundColor: UIColor.gray - ] - let cityText = "\(richGame.stadium.city), \(richGame.stadium.state)" - (cityText as NSString).draw( - at: CGPoint(x: margin + 110, y: currentY + 46), - withAttributes: cityAttributes - ) - - return currentY + 70 - } - - private func drawTravelSegment(segment: TravelSegment, y: CGFloat) -> CGFloat { - let currentY = y - - let travelRect = CGRect(x: margin + 10, y: currentY, width: contentWidth - 20, height: 35) - let travelPath = UIBezierPath(roundedRect: travelRect, cornerRadius: 6) - UIColor.systemGray6.setFill() - travelPath.fill() - - // Car icon placeholder - let iconAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 14), - .foregroundColor: UIColor.gray - ] - (">" as NSString).draw(at: CGPoint(x: margin + 20, y: currentY + 10), withAttributes: iconAttributes) - - // Travel text + // Build travel text + let travelText = "\(segment.formattedDuration) • \(segment.formattedDistance) to \(segment.toLocation.name)" let travelAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12), - .foregroundColor: UIColor.darkGray + .foregroundColor: textSecondary ] - let travelText = "Drive to \(segment.toLocation.name) | \(segment.formattedDistance) | \(segment.formattedDuration)" - (travelText as NSString).draw( - at: CGPoint(x: margin + 40, y: currentY + 10), - withAttributes: travelAttributes - ) + (travelText as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 2), withAttributes: travelAttributes) - return currentY + 45 + return currentY + 28 } - // MARK: - City Spotlight Pages - - private func drawCitySpotlightPages( - context: UIGraphicsPDFRendererContext, - trip: Trip, - games: [String: RichGame], - assets: PDFAssetPrefetcher.PrefetchedAssets? - ) { - guard let cityPOIs = assets?.cityPOIs, !cityPOIs.isEmpty else { return } - - var pageNumber = 10 // Estimate, will be overwritten - - // Draw spotlight for each city with POIs - var seenCities: Set = [] - for stop in trip.stops { - guard !seenCities.contains(stop.city), - let pois = cityPOIs[stop.city], !pois.isEmpty else { continue } - seenCities.insert(stop.city) - - context.beginPage() - var y: CGFloat = margin - - drawPageHeader("City Guide: \(stop.city)", at: &y) - - // City map - if let cityMap = assets?.cityMaps[stop.city] { - let mapRect = CGRect(x: margin, y: y, width: 250, height: 180) - let mapPath = UIBezierPath(roundedRect: mapRect, cornerRadius: 8) - context.cgContext.saveGState() - mapPath.addClip() - cityMap.draw(in: mapRect) - context.cgContext.restoreGState() - - UIColor.lightGray.setStroke() - mapPath.lineWidth = 1 - mapPath.stroke() - - // City info next to map - let infoX = margin + 270 - var infoY = y - - let cityNameAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 18), - .foregroundColor: UIColor.black - ] - ("\(stop.city), \(stop.state)" as NSString).draw( - at: CGPoint(x: infoX, y: infoY), - withAttributes: cityNameAttributes - ) - infoY += 28 - - // Find stadium for this city - let cityGames = stop.games.compactMap { games[$0] } - if let stadium = cityGames.first?.stadium { - let venueAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 13), - .foregroundColor: UIColor.darkGray - ] - ("Stadium: \(stadium.name)" as NSString).draw( - at: CGPoint(x: infoX, y: infoY), - withAttributes: venueAttributes - ) - infoY += 20 - - let capacityText = "Capacity: \(NumberFormatter.localizedString(from: NSNumber(value: stadium.capacity), number: .decimal))" - (capacityText as NSString).draw( - at: CGPoint(x: infoX, y: infoY), - withAttributes: venueAttributes - ) - } - - y += 200 - } - - y += 20 - drawSectionHeader("Nearby Attractions", at: &y) - - // POI list - for (index, poi) in pois.enumerated() { - y = drawPOIItem(poi: poi, index: index + 1, y: y) - } - - pageNumber += 1 - } - } - - private func drawPOIItem(poi: POISearchService.POI, index: Int, y: CGFloat) -> CGFloat { - let currentY = y - - // Number badge - let badgeRect = CGRect(x: margin + 10, y: currentY, width: 22, height: 22) - let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 11) - accentColor.withAlphaComponent(0.2).setFill() - badgePath.fill() - - let numberAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 11), - .foregroundColor: accentColor - ] - ("\(index)" as NSString).draw( - at: CGPoint(x: margin + 17, y: currentY + 4), - withAttributes: numberAttributes - ) - - // POI name - let nameAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 13), - .foregroundColor: UIColor.black - ] - (poi.name as NSString).draw( - at: CGPoint(x: margin + 45, y: currentY + 2), - withAttributes: nameAttributes - ) - - // Category and distance - let detailAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 11), - .foregroundColor: UIColor.gray - ] - let detailText = "\(poi.category.displayName) | \(poi.formattedDistance) from stadium" - (detailText as NSString).draw( - at: CGPoint(x: margin + 45, y: currentY + 20), - withAttributes: detailAttributes - ) - - // Address if available - if let address = poi.address { - let addressAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 10), - .foregroundColor: UIColor.lightGray - ] - (address as NSString).draw( - at: CGPoint(x: margin + 45, y: currentY + 36), - withAttributes: addressAttributes - ) - return currentY + 55 - } - - return currentY + 45 - } - - // MARK: - Summary Page - - private func drawSummaryPage( - context: UIGraphicsPDFRendererContext, - trip: Trip, - games: [String: RichGame] - ) { - var y: CGFloat = margin - - drawPageHeader("Trip Summary", at: &y) - - // Stats grid - let statsBoxWidth: CGFloat = 150 - let statsBoxHeight: CGFloat = 80 - let spacing: CGFloat = 20 - - let stats: [(String, String)] = [ - ("\(trip.tripDuration)", "Days"), - ("\(trip.stops.count)", "Cities"), - ("\(trip.totalGames)", "Games"), - (String(format: "%.0f", trip.totalDistanceMiles), "Miles") - ] - - var col: CGFloat = 0 - for (index, stat) in stats.enumerated() { - let x = margin + (statsBoxWidth + spacing) * CGFloat(index % 3) - let boxY = y + (index >= 3 ? statsBoxHeight + 15 : 0) - - if index == 3 && col < 3 { - col = 0 // New row - } - - let boxRect = CGRect(x: x, y: boxY, width: statsBoxWidth, height: statsBoxHeight) - let boxPath = UIBezierPath(roundedRect: boxRect, cornerRadius: 10) - primaryColor.withAlphaComponent(0.08).setFill() - boxPath.fill() - - // Big number - let numberAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 28), - .foregroundColor: primaryColor - ] - let numberSize = (stat.0 as NSString).size(withAttributes: numberAttributes) - (stat.0 as NSString).draw( - at: CGPoint(x: x + (statsBoxWidth - numberSize.width) / 2, y: boxY + 15), - withAttributes: numberAttributes - ) - - // Label - let labelAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 13), - .foregroundColor: UIColor.darkGray - ] - let labelSize = (stat.1 as NSString).size(withAttributes: labelAttributes) - (stat.1 as NSString).draw( - at: CGPoint(x: x + (statsBoxWidth - labelSize.width) / 2, y: boxY + 50), - withAttributes: labelAttributes - ) - - col += 1 - } - - y += statsBoxHeight * 2 + 50 - - // Driving stats - drawSectionHeader("Driving", at: &y) - - let drivingStats = [ - ("Total Driving Time", trip.formattedTotalDriving), - ("Average Per Day", String(format: "%.1f hours", trip.averageDrivingHoursPerDay)), - ("Travel Segments", "\(trip.travelSegments.count)") - ] - - for stat in drivingStats { - drawStatRow(label: stat.0, value: stat.1, y: &y) - } - - y += 20 - - // Sports breakdown - drawSectionHeader("Sports Breakdown", at: &y) - - var sportCounts: [Sport: Int] = [:] - for gameId in trip.stops.flatMap({ $0.games }) { - if let game = games[gameId] { - sportCounts[game.game.sport, default: 0] += 1 - } - } - - for (sport, count) in sportCounts.sorted(by: { $0.value > $1.value }) { - drawStatRow(label: sport.rawValue, value: "\(count) games", y: &y) - } - - // Trip score if available - if let score = trip.score { - y += 20 - drawSectionHeader("Trip Score", at: &y) - - let gradeAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 36), - .foregroundColor: UIColor.systemGreen - ] - let gradeText = score.scoreGrade - (gradeText as NSString).draw(at: CGPoint(x: margin + 20, y: y), withAttributes: gradeAttributes) - - let scoreAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 16), - .foregroundColor: UIColor.darkGray - ] - let scoreText = "\(score.formattedOverallScore)/100" - (scoreText as NSString).draw(at: CGPoint(x: margin + 80, y: y + 10), withAttributes: scoreAttributes) - } - - // Footer - let footerY = pageHeight - 50 - let footerAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.italicSystemFont(ofSize: 10), - .foregroundColor: UIColor.lightGray - ] - ("Generated by SportsTime" as NSString).draw( - at: CGPoint(x: margin, y: footerY), - withAttributes: footerAttributes - ) - } - - // MARK: - Drawing Helpers - - private func drawPageHeader(_ title: String, at y: inout CGFloat) { - let headerAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 22), - .foregroundColor: UIColor.black - ] - (title as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: headerAttributes) - y += 35 - } - - private func drawSectionHeader(_ title: String, at y: inout CGFloat) { - let headerAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 14), - .foregroundColor: primaryColor - ] - (title as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: headerAttributes) - y += 22 - } - - private func drawStatRow(label: String, value: String, y: inout CGFloat) { - let labelAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 12), - .foregroundColor: UIColor.darkGray - ] - let valueAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 12), - .foregroundColor: UIColor.black - ] - - (label as NSString).draw(at: CGPoint(x: margin + 15, y: y), withAttributes: labelAttributes) - (value as NSString).draw(at: CGPoint(x: margin + 200, y: y), withAttributes: valueAttributes) - y += 20 - } - - private func drawHorizontalLine(at y: CGFloat, alpha: CGFloat = 0.5) { - let path = UIBezierPath() - path.move(to: CGPoint(x: margin, y: y)) - path.addLine(to: CGPoint(x: pageWidth - margin, y: y)) - UIColor.lightGray.withAlphaComponent(alpha).setStroke() - path.lineWidth = 0.5 - path.stroke() - } - - private func drawFooter(context: UIGraphicsPDFRendererContext, pageNumber: Int) { - let footerY = pageHeight - 35 - let footerAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 9), - .foregroundColor: UIColor.lightGray - ] - let footerText = "Page \(pageNumber)" - let footerSize = (footerText as NSString).size(withAttributes: footerAttributes) - (footerText as NSString).draw( - at: CGPoint(x: (pageWidth - footerSize.width) / 2, y: footerY), - withAttributes: footerAttributes - ) - } - - private func drawTeamPlaceholder(abbreviation: String, at point: CGPoint, size: CGFloat, color: UIColor?) { - let rect = CGRect(origin: point, size: CGSize(width: size, height: size)) - let path = UIBezierPath(ovalIn: rect) - - (color ?? primaryColor).withAlphaComponent(0.2).setFill() - path.fill() - - let attributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 10), - .foregroundColor: color ?? primaryColor - ] - let textSize = (abbreviation as NSString).size(withAttributes: attributes) - (abbreviation as NSString).draw( - at: CGPoint( - x: point.x + (size - textSize.width) / 2, - y: point.y + (size - textSize.height) / 2 - ), - withAttributes: attributes - ) - } - - private func teamColor(from hexString: String?) -> UIColor? { - guard let hex = hexString else { return nil } - return UIColor(hex: hex) - } } // MARK: - UIColor Extension diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 0bdafc6..4a8c18a 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -1199,7 +1199,14 @@ struct TripDetailView: View { exportProgress = nil do { - let url = try await exportService.exportToPDF(trip: trip, games: games) { progress in + // Build complete itinerary items (games + travel + custom) + let completeItems = buildCompleteItineraryItems() + + let url = try await exportService.exportToPDF( + trip: trip, + games: games, + itineraryItems: completeItems + ) { progress in await MainActor.run { self.exportProgress = progress } @@ -1213,6 +1220,57 @@ struct TripDetailView: View { isExporting = false } + /// Build complete itinerary items by merging games, travel, and custom items + private func buildCompleteItineraryItems() -> [ItineraryItem] { + var allItems: [ItineraryItem] = [] + + // Get itinerary days from trip + let tripDays = trip.itineraryDays() + + // 1. Add game items using day.gameIds (reliable source from trip stops) + for day in tripDays { + for (gameIndex, gameId) in day.gameIds.enumerated() { + guard let richGame = games[gameId] else { continue } + let gameItem = ItineraryItem( + tripId: trip.id, + day: day.dayNumber, + sortOrder: Double(gameIndex) * 0.01, // Games near the start of the day + kind: .game(gameId: gameId, city: richGame.stadium.city) + ) + allItems.append(gameItem) + } + } + + // 2. Add travel items (from trip segments + overrides) + for segment in trip.travelSegments { + let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + + // Use override if available, otherwise default to day 1 + let override = travelOverrides[travelId] + let day = override?.day ?? 1 + let sortOrder = override?.sortOrder ?? 100.0 // After games by default + + let travelItem = ItineraryItem( + tripId: trip.id, + day: day, + sortOrder: sortOrder, + kind: .travel(TravelInfo( + fromCity: segment.fromLocation.name, + toCity: segment.toLocation.name, + distanceMeters: segment.distanceMeters, + durationSeconds: segment.durationSeconds + )) + ) + allItems.append(travelItem) + } + + // 3. Add custom items (from CloudKit) + let customItems = itineraryItems.filter { $0.isCustom } + allItems.append(contentsOf: customItems) + + return allItems + } + private func toggleSaved() { if isSaved { unsaveTrip()