diff --git a/SportsTime/Export/PDFGenerator.swift b/SportsTime/Export/PDFGenerator.swift index 7f62b5b..08bf281 100644 --- a/SportsTime/Export/PDFGenerator.swift +++ b/SportsTime/Export/PDFGenerator.swift @@ -2,6 +2,8 @@ // PDFGenerator.swift // SportsTime // +// Generates beautiful PDF trip itineraries with maps, photos, and attractions. +// import Foundation import PDFKit @@ -9,64 +11,47 @@ import UIKit actor PDFGenerator { + // MARK: - Constants + + 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 + + // 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 + // MARK: - Generate PDF - func generatePDF(for trip: Trip, games: [UUID: RichGame]) async throws -> Data { - let pageWidth: CGFloat = 612 // Letter size - let pageHeight: CGFloat = 792 - let margin: CGFloat = 50 - - let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)) + func generatePDF( + for trip: Trip, + games: [UUID: RichGame], + assets: PDFAssetPrefetcher.PrefetchedAssets? = nil + ) async throws -> Data { + let pdfRenderer = UIGraphicsPDFRenderer( + bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) + ) let data = pdfRenderer.pdfData { context in - var currentY: CGFloat = margin - // Page 1: Cover context.beginPage() - currentY = drawCoverPage( - context: context, - trip: trip, - pageWidth: pageWidth, - margin: margin - ) + drawCoverPage(context: context, trip: trip, assets: assets) - // Page 2+: Itinerary + // Page 2: Route Overview context.beginPage() - currentY = margin - currentY = drawItineraryHeader( - context: context, - y: currentY, - pageWidth: pageWidth, - margin: margin - ) + drawRouteOverviewPage(context: context, trip: trip, assets: assets) - for day in trip.itineraryDays() { - // Check if we need a new page - if currentY > pageHeight - 200 { - context.beginPage() - currentY = margin - } + // Day-by-Day Itinerary + drawItineraryPages(context: context, trip: trip, games: games, assets: assets) - currentY = drawDay( - context: context, - day: day, - games: games, - y: currentY, - pageWidth: pageWidth, - margin: margin - ) + // City Spotlight pages + drawCitySpotlightPages(context: context, trip: trip, games: games, assets: assets) - currentY += 20 // Space between days - } - - // Summary page + // Final page: Summary context.beginPage() - drawSummaryPage( - context: context, - trip: trip, - pageWidth: pageWidth, - margin: margin - ) + drawSummaryPage(context: context, trip: trip, games: games) } return data @@ -77,246 +62,826 @@ actor PDFGenerator { private func drawCoverPage( context: UIGraphicsPDFRendererContext, trip: Trip, - pageWidth: CGFloat, - margin: CGFloat - ) -> CGFloat { - var y: CGFloat = 150 + assets: PDFAssetPrefetcher.PrefetchedAssets? + ) { + var y: CGFloat = margin - // Title + // Hero route map (if available) + if let routeMap = assets?.routeMap { + let mapHeight: CGFloat = 350 + let mapRect = CGRect(x: margin, y: y, width: contentWidth, height: mapHeight) + + // Draw map with rounded corners + let path = UIBezierPath(roundedRect: mapRect, cornerRadius: 12) + context.cgContext.saveGState() + path.addClip() + routeMap.draw(in: mapRect) + context.cgContext.restoreGState() + + // Border + UIColor.lightGray.setStroke() + path.lineWidth = 1 + path.stroke() + + y += mapHeight + 30 + } else { + // No map - add extra spacing + y = 180 + } + + // Color accent bar + let barRect = CGRect(x: margin, y: y, width: contentWidth, height: 6) + let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 3) + primaryColor.setFill() + barPath.fill() + y += 25 + + // Trip name let titleAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 32), .foregroundColor: UIColor.black ] - - let title = trip.name - let titleRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 50) - (title as NSString).draw(in: titleRect, withAttributes: titleAttributes) - y += 60 + let titleRect = CGRect(x: margin, y: y, width: contentWidth, height: 50) + (trip.name 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 - let dateRange = trip.formattedDateRange - let dateRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 30) - (dateRange as NSString).draw(in: dateRect, withAttributes: subtitleAttributes) - y += 50 - - // Quick stats + // 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 stats = """ - \(trip.stops.count) Cities • \(trip.totalGames) Games • \(trip.formattedTotalDistance) - \(trip.tripDuration) Days • \(trip.formattedTotalDriving) Driving - """ - - let statsRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 50) - (stats as NSString).draw(in: statsRect, withAttributes: statsAttributes) - y += 80 + let drivingRect = CGRect(x: margin, y: y, width: contentWidth, height: 22) + (drivingText as NSString).draw(in: drivingRect, withAttributes: drivingAttributes) + y += 50 // Cities list - let citiesTitle = "Cities Visited" - let citiesTitleRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 25) - (citiesTitle as NSString).draw(in: citiesTitleRect, withAttributes: [ - .font: UIFont.boldSystemFont(ofSize: 16), - .foregroundColor: UIColor.black - ]) - y += 30 + drawSectionHeader("Route", at: &y) - for city in trip.cities { - let cityRect = CGRect(x: margin + 20, y: y, width: pageWidth - margin * 2 - 20, height: 20) - ("• \(city)" as NSString).draw(in: cityRect, withAttributes: statsAttributes) + let cityAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 13), + .foregroundColor: UIColor.darkGray + ] + + 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) y += 22 } - return y + // Footer + drawFooter(context: context, pageNumber: 1) } - // MARK: - Itinerary Header + // MARK: - Route Overview Page - private func drawItineraryHeader( + private func drawRouteOverviewPage( context: UIGraphicsPDFRendererContext, - y: CGFloat, - pageWidth: CGFloat, - margin: CGFloat - ) -> CGFloat { + 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 + } + + // Stop table header + drawSectionHeader("Stops", at: &y) + + // Table headers let headerAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 24), + .font: UIFont.boldSystemFont(ofSize: 11), + .foregroundColor: UIColor.darkGray + ] + + 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) + y += 20 + + // Separator line + drawHorizontalLine(at: y) + y += 8 + + // Table rows + let rowAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 11), .foregroundColor: UIColor.black ] - let header = "Day-by-Day Itinerary" - let headerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 35) - (header as NSString).draw(in: headerRect, withAttributes: headerAttributes) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d" - return y + 50 + 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 + } + + drawFooter(context: context, pageNumber: 2) } - // MARK: - Day Section + // MARK: - Itinerary Pages + + private func drawItineraryPages( + context: UIGraphicsPDFRendererContext, + trip: Trip, + games: [UUID: RichGame], + assets: PDFAssetPrefetcher.PrefetchedAssets? + ) { + var pageNumber = 3 + var y: CGFloat = margin + var isFirstDayOnPage = true + + context.beginPage() + drawPageHeader("Day-by-Day Itinerary", at: &y) + + for day in trip.itineraryDays() { + // Check if we need a new page + let estimatedHeight = estimateDayHeight(day: day, games: games) + if y + estimatedHeight > pageHeight - 80 { + drawFooter(context: context, pageNumber: pageNumber) + pageNumber += 1 + context.beginPage() + y = margin + isFirstDayOnPage = true + } + + if !isFirstDayOnPage { + y += 15 // Space between days + } + + y = drawDay( + context: context, + day: day, + games: games, + assets: assets, + y: y + ) + + isFirstDayOnPage = false + } + + drawFooter(context: context, pageNumber: pageNumber) + } + + private func estimateDayHeight(day: ItineraryDay, games: [UUID: RichGame]) -> CGFloat { + var height: CGFloat = 60 // Day header + city + + // Games + height += CGFloat(day.gameIds.count) * 80 // Each game card + + // Travel + if day.hasTravelSegment { + height += 50 + } + + // Rest day + if day.isRestDay { + height += 30 + } + + return height + } private func drawDay( context: UIGraphicsPDFRendererContext, day: ItineraryDay, games: [UUID: RichGame], - y: CGFloat, - pageWidth: CGFloat, - margin: CGFloat + assets: PDFAssetPrefetcher.PrefetchedAssets?, + y: CGFloat ) -> CGFloat { var currentY = y - // Day header + // 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() + let dayHeaderAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 16), - .foregroundColor: UIColor.systemBlue + .foregroundColor: primaryColor ] - - let dayHeader = "Day \(day.dayNumber): \(day.formattedDate)" - let dayHeaderRect = CGRect(x: margin, y: currentY, width: pageWidth - margin * 2, height: 25) - (dayHeader as NSString).draw(in: dayHeaderRect, withAttributes: dayHeaderAttributes) - currentY += 28 + let dayHeader = "Day \(day.dayNumber) - \(day.formattedDate)" + (dayHeader as NSString).draw( + at: CGPoint(x: margin + 12, y: currentY + 7), + withAttributes: dayHeaderAttributes + ) + currentY += 40 // City if let city = day.primaryCity { let cityAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 14), + .font: UIFont.systemFont(ofSize: 13), .foregroundColor: UIColor.darkGray ] - let cityRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20) - ("📍 \(city)" as NSString).draw(in: cityRect, withAttributes: cityAttributes) - currentY += 24 - } - - // Travel segment - if day.hasTravelSegment { - for segment in day.travelSegments { - let travelText = "🚗 \(segment.fromLocation.name) → \(segment.toLocation.name) (\(segment.formattedDistance), \(segment.formattedDuration))" - let travelRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20) - (travelText as NSString).draw(in: travelRect, withAttributes: [ - .font: UIFont.systemFont(ofSize: 12), - .foregroundColor: UIColor.gray - ]) - currentY += 22 - } + let cityText = "Location: \(city)" + (cityText as NSString).draw(at: CGPoint(x: margin + 12, y: currentY), withAttributes: cityAttributes) + currentY += 25 } // Games for gameId in day.gameIds { if let richGame = games[gameId] { - let gameText = "⚾ \(richGame.fullMatchupDescription)" - let gameRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20) - (gameText as NSString).draw(in: gameRect, withAttributes: [ - .font: UIFont.systemFont(ofSize: 13), - .foregroundColor: UIColor.black - ]) - currentY += 20 - - let venueText = " \(richGame.venueDescription) • \(richGame.game.gameTime)" - let venueRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 18) - (venueText as NSString).draw(in: venueRect, withAttributes: [ - .font: UIFont.systemFont(ofSize: 11), - .foregroundColor: UIColor.gray - ]) - currentY += 22 + currentY = drawGameCard( + context: context, + richGame: richGame, + assets: assets, + y: currentY + ) + currentY += 10 } } - // Rest day indicator - if day.isRestDay { - let restText = "😴 Rest Day" - let restRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20) - (restText as NSString).draw(in: restRect, withAttributes: [ - .font: UIFont.italicSystemFont(ofSize: 12), - .foregroundColor: UIColor.gray - ]) - currentY += 22 + // Travel segments + for segment in day.travelSegments { + currentY = drawTravelSegment(segment: segment, y: currentY) } - // Separator line - currentY += 5 - let path = UIBezierPath() - path.move(to: CGPoint(x: margin, y: currentY)) - path.addLine(to: CGPoint(x: pageWidth - margin, y: currentY)) - UIColor.lightGray.setStroke() - path.lineWidth = 0.5 - path.stroke() - currentY += 10 + // Rest day + if day.isRestDay && day.gameIds.isEmpty { + let restAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.italicSystemFont(ofSize: 13), + .foregroundColor: UIColor.gray + ] + 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 + } + + // 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 { + var 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 + ] + (sportText as NSString).draw( + at: CGPoint(x: badgeRect.midX - 10, y: currentY + 10), + withAttributes: badgeAttributes + ) + + // 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.game.gameTime)" + (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 { + var 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 + ] + 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 + ) + + return currentY + 45 + } + + // MARK: - City Spotlight Pages + + private func drawCitySpotlightPages( + context: UIGraphicsPDFRendererContext, + trip: Trip, + games: [UUID: 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 { + var 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, - pageWidth: CGFloat, - margin: CGFloat + games: [UUID: RichGame] ) { var y: CGFloat = margin - // Header - let headerAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.boldSystemFont(ofSize: 24), - .foregroundColor: UIColor.black - ] - let header = "Trip Summary" - let headerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 35) - (header as NSString).draw(in: headerRect, withAttributes: headerAttributes) - y += 50 + drawPageHeader("Trip Summary", at: &y) - // Stats - let statAttributes: [NSAttributedString.Key: Any] = [ - .font: UIFont.systemFont(ofSize: 14), - .foregroundColor: UIColor.black + // 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") ] - let stats = [ - ("Total Duration", "\(trip.tripDuration) days"), - ("Total Distance", trip.formattedTotalDistance), - ("Total Driving Time", trip.formattedTotalDriving), - ("Average Daily Driving", String(format: "%.1f hours", trip.averageDrivingHoursPerDay)), - ("Cities Visited", "\(trip.stops.count)"), - ("Games Attended", "\(trip.totalGames)"), - ("Sports", trip.uniqueSports.map { $0.rawValue }.joined(separator: ", ")) - ] + 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) - for (label, value) in stats { - let labelRect = CGRect(x: margin, y: y, width: 200, height: 22) - ("\(label):" as NSString).draw(in: labelRect, withAttributes: [ - .font: UIFont.boldSystemFont(ofSize: 13), + 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 + ) - let valueRect = CGRect(x: margin + 200, y: y, width: pageWidth - margin * 2 - 200, height: 22) - (value as NSString).draw(in: valueRect, withAttributes: statAttributes) - y += 26 + col += 1 } - // Score (if available) + 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 scoreHeader = "Trip Score: \(score.scoreGrade) (\(score.formattedOverallScore)/100)" - let scoreRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 30) - (scoreHeader as NSString).draw(in: scoreRect, withAttributes: [ - .font: UIFont.boldSystemFont(ofSize: 18), + 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 - y = 720 - let footerText = "Generated by Sport Travel Planner" - let footerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 20) - (footerText as NSString).draw(in: footerRect, withAttributes: [ + 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 + +extension UIColor { + convenience init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + guard hexSanitized.count == 6 else { return nil } + + var rgb: UInt64 = 0 + Scanner(string: hexSanitized).scanHexInt64(&rgb) + + let red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 + let green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 + let blue = CGFloat(rgb & 0x0000FF) / 255.0 + + self.init(red: red, green: green, blue: blue, alpha: 1.0) } } @@ -324,9 +889,35 @@ actor PDFGenerator { actor ExportService { private let pdfGenerator = PDFGenerator() + private let assetPrefetcher = PDFAssetPrefetcher() - func exportToPDF(trip: Trip, games: [UUID: RichGame]) async throws -> URL { - let data = try await pdfGenerator.generatePDF(for: trip, games: games) + /// Export trip to PDF with full prefetched assets + func exportToPDF( + trip: Trip, + games: [UUID: RichGame], + progressCallback: ((PDFAssetPrefetcher.PrefetchProgress) async -> Void)? = nil + ) async throws -> URL { + // Prefetch all assets + let assets = await assetPrefetcher.prefetchAssets( + for: trip, + games: games, + progressCallback: progressCallback + ) + + // Generate PDF with assets + let data = try await pdfGenerator.generatePDF(for: trip, games: games, assets: assets) + + // Save to temp file + let fileName = "\(trip.name.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).pdf" + let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + + try data.write(to: url) + return url + } + + /// Quick export without prefetching (basic PDF) + func exportToPDFBasic(trip: Trip, games: [UUID: RichGame]) async throws -> URL { + let data = try await pdfGenerator.generatePDF(for: trip, games: games, assets: nil) let fileName = "\(trip.name.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).pdf" let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) @@ -336,8 +927,6 @@ actor ExportService { } func shareTrip(_ trip: Trip) -> URL? { - // Generate a shareable deep link - // In production, this would create a proper share URL let baseURL = "sportstime://trip/" return URL(string: baseURL + trip.id.uuidString) } diff --git a/SportsTime/Export/Services/MapSnapshotService.swift b/SportsTime/Export/Services/MapSnapshotService.swift new file mode 100644 index 0000000..c00adfd --- /dev/null +++ b/SportsTime/Export/Services/MapSnapshotService.swift @@ -0,0 +1,291 @@ +// +// MapSnapshotService.swift +// SportsTime +// +// Generates static map images for PDF export using MKMapSnapshotter. +// + +import Foundation +import MapKit +import UIKit + +actor MapSnapshotService { + + // MARK: - Errors + + enum MapSnapshotError: Error, LocalizedError { + case noStops + case snapshotFailed(String) + case invalidCoordinates + + var errorDescription: String? { + switch self { + case .noStops: + return "No stops provided for map generation" + case .snapshotFailed(let reason): + return "Map snapshot failed: \(reason)" + case .invalidCoordinates: + return "Invalid coordinates for map region" + } + } + } + + // MARK: - Route Map + + /// Generate a full route map showing all stops with route line + func generateRouteMap( + stops: [TripStop], + size: CGSize, + routeColor: UIColor = UIColor(red: 0.12, green: 0.23, blue: 0.54, alpha: 1.0) + ) async throws -> UIImage { + guard !stops.isEmpty else { + throw MapSnapshotError.noStops + } + + let coordinates = stops.compactMap { $0.coordinate } + guard !coordinates.isEmpty else { + throw MapSnapshotError.invalidCoordinates + } + + // Calculate region to fit all stops with padding + let region = regionToFit(coordinates: coordinates, paddingPercent: 0.2) + + // Configure snapshotter + let options = MKMapSnapshotter.Options() + options.region = region + options.size = size + options.mapType = .standard + options.showsBuildings = false + options.pointOfInterestFilter = .excludingAll + + // Generate snapshot + let snapshotter = MKMapSnapshotter(options: options) + let snapshot = try await snapshotter.start() + + // Draw route and markers on snapshot + let image = drawRouteOverlay( + on: snapshot, + coordinates: coordinates, + stops: stops, + routeColor: routeColor, + size: size + ) + + return image + } + + // MARK: - City Map + + /// Generate a city-level map showing stadium location + func generateCityMap( + stop: TripStop, + size: CGSize, + radiusMeters: Double = 5000 + ) async throws -> UIImage { + guard let coordinate = stop.coordinate else { + throw MapSnapshotError.invalidCoordinates + } + + // Create region centered on stadium + let region = MKCoordinateRegion( + center: coordinate, + latitudinalMeters: radiusMeters * 2, + longitudinalMeters: radiusMeters * 2 + ) + + // Configure snapshotter + let options = MKMapSnapshotter.Options() + options.region = region + options.size = size + options.mapType = .standard + options.showsBuildings = true + options.pointOfInterestFilter = .includingAll + + // Generate snapshot + let snapshotter = MKMapSnapshotter(options: options) + let snapshot = try await snapshotter.start() + + // Draw stadium marker + let image = drawStadiumMarker( + on: snapshot, + coordinate: coordinate, + cityName: stop.city, + size: size + ) + + return image + } + + // MARK: - Helper Methods + + /// Calculate a region that fits all coordinates with padding + private func regionToFit(coordinates: [CLLocationCoordinate2D], paddingPercent: Double) -> MKCoordinateRegion { + var minLat = coordinates[0].latitude + var maxLat = coordinates[0].latitude + var minLon = coordinates[0].longitude + var maxLon = coordinates[0].longitude + + for coord in coordinates { + minLat = min(minLat, coord.latitude) + maxLat = max(maxLat, coord.latitude) + minLon = min(minLon, coord.longitude) + maxLon = max(maxLon, coord.longitude) + } + + let latSpan = (maxLat - minLat) * (1 + paddingPercent) + let lonSpan = (maxLon - minLon) * (1 + paddingPercent) + + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + + let span = MKCoordinateSpan( + latitudeDelta: max(latSpan, 0.5), + longitudeDelta: max(lonSpan, 0.5) + ) + + return MKCoordinateRegion(center: center, span: span) + } + + /// Draw route line and numbered markers on snapshot + private func drawRouteOverlay( + on snapshot: MKMapSnapshotter.Snapshot, + coordinates: [CLLocationCoordinate2D], + stops: [TripStop], + routeColor: UIColor, + size: CGSize + ) -> UIImage { + UIGraphicsBeginImageContextWithOptions(size, true, 0) + defer { UIGraphicsEndImageContext() } + + // Draw base map + snapshot.image.draw(at: .zero) + + guard let context = UIGraphicsGetCurrentContext() else { + return snapshot.image + } + + // Draw route line connecting stops + if coordinates.count > 1 { + context.setStrokeColor(routeColor.cgColor) + context.setLineWidth(3.0) + context.setLineCap(.round) + context.setLineJoin(.round) + + let points = coordinates.map { snapshot.point(for: $0) } + context.move(to: points[0]) + for i in 1.. UIImage { + UIGraphicsBeginImageContextWithOptions(size, true, 0) + defer { UIGraphicsEndImageContext() } + + // Draw base map + snapshot.image.draw(at: .zero) + + guard let context = UIGraphicsGetCurrentContext() else { + return snapshot.image + } + + let point = snapshot.point(for: coordinate) + + // Draw stadium pin + let pinSize: CGFloat = 30 + let pinRect = CGRect( + x: point.x - pinSize / 2, + y: point.y - pinSize, + width: pinSize, + height: pinSize + ) + + // Draw pin shadow + context.saveGState() + context.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor) + + // Draw pin body (red marker) + context.setFillColor(UIColor.systemRed.cgColor) + context.fillEllipse(in: pinRect.insetBy(dx: 2, dy: 2)) + + // Draw pin center (white dot) + context.setFillColor(UIColor.white.cgColor) + let centerRect = CGRect( + x: point.x - 5, + y: point.y - pinSize / 2 - 5, + width: 10, + height: 10 + ) + context.fillEllipse(in: centerRect) + + context.restoreGState() + + return UIGraphicsGetImageFromCurrentImageContext() ?? snapshot.image + } + + /// Draw a numbered circular marker + private func drawNumberedMarker( + at point: CGPoint, + number: Int, + color: UIColor, + in context: CGContext + ) { + let radius: CGFloat = 14 + let rect = CGRect( + x: point.x - radius, + y: point.y - radius, + width: radius * 2, + height: radius * 2 + ) + + // Draw white outline + context.saveGState() + context.setShadow(offset: CGSize(width: 0, height: 1), blur: 2, color: UIColor.black.withAlphaComponent(0.3).cgColor) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: rect.insetBy(dx: -2, dy: -2)) + context.restoreGState() + + // Draw colored circle + context.setFillColor(color.cgColor) + context.fillEllipse(in: rect) + + // Draw number text + let numberString = "\(number)" as NSString + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 12), + .foregroundColor: UIColor.white + ] + + let textSize = numberString.size(withAttributes: attributes) + let textRect = CGRect( + x: point.x - textSize.width / 2, + y: point.y - textSize.height / 2, + width: textSize.width, + height: textSize.height + ) + numberString.draw(in: textRect, withAttributes: attributes) + } +} diff --git a/SportsTime/Export/Services/PDFAssetPrefetcher.swift b/SportsTime/Export/Services/PDFAssetPrefetcher.swift new file mode 100644 index 0000000..26f8dcc --- /dev/null +++ b/SportsTime/Export/Services/PDFAssetPrefetcher.swift @@ -0,0 +1,180 @@ +// +// PDFAssetPrefetcher.swift +// SportsTime +// +// Orchestrates parallel prefetching of all assets needed for PDF generation. +// + +import Foundation +import UIKit + +actor PDFAssetPrefetcher { + + // MARK: - Types + + struct PrefetchedAssets { + let routeMap: UIImage? + let cityMaps: [String: UIImage] + let teamLogos: [UUID: UIImage] + let stadiumPhotos: [UUID: UIImage] + let cityPOIs: [String: [POISearchService.POI]] + + var isEmpty: Bool { + routeMap == nil && cityMaps.isEmpty && teamLogos.isEmpty && stadiumPhotos.isEmpty && cityPOIs.isEmpty + } + } + + struct PrefetchProgress { + var routeMapComplete: Bool = false + var cityMapsComplete: Bool = false + var logosComplete: Bool = false + var photosComplete: Bool = false + var poisComplete: Bool = false + + var percentComplete: Double { + let completedSteps = [routeMapComplete, cityMapsComplete, logosComplete, photosComplete, poisComplete] + .filter { $0 }.count + return Double(completedSteps) / 5.0 + } + + var currentStep: String { + if !routeMapComplete { return "Generating route map..." } + if !cityMapsComplete { return "Generating city maps..." } + if !logosComplete { return "Downloading team logos..." } + if !photosComplete { return "Downloading stadium photos..." } + if !poisComplete { return "Finding nearby attractions..." } + return "Complete" + } + } + + // MARK: - Properties + + private let mapService = MapSnapshotService() + private let imageService = RemoteImageService() + private let poiService = POISearchService() + + // MARK: - Public Methods + + /// Prefetch all assets needed for PDF generation + /// - Parameters: + /// - trip: The trip to generate PDF for + /// - games: Map of game IDs to RichGame objects + /// - progressCallback: Optional callback for progress updates + /// - Returns: All prefetched assets + func prefetchAssets( + for trip: Trip, + games: [UUID: RichGame], + progressCallback: ((PrefetchProgress) async -> Void)? = nil + ) async -> PrefetchedAssets { + var progress = PrefetchProgress() + + // Collect unique teams and stadiums from games + var teams: [Team] = [] + var stadiums: [Stadium] = [] + var seenTeamIds: Set = [] + var seenStadiumIds: Set = [] + + for (_, richGame) in games { + if !seenTeamIds.contains(richGame.homeTeam.id) { + teams.append(richGame.homeTeam) + seenTeamIds.insert(richGame.homeTeam.id) + } + if !seenTeamIds.contains(richGame.awayTeam.id) { + teams.append(richGame.awayTeam) + seenTeamIds.insert(richGame.awayTeam.id) + } + if !seenStadiumIds.contains(richGame.stadium.id) { + stadiums.append(richGame.stadium) + seenStadiumIds.insert(richGame.stadium.id) + } + } + + // Run all fetches in parallel + async let routeMapTask = fetchRouteMap(stops: trip.stops) + async let cityMapsTask = fetchCityMaps(stops: trip.stops) + async let logosTask = imageService.fetchTeamLogos(teams: teams) + async let photosTask = imageService.fetchStadiumPhotos(stadiums: stadiums) + async let poisTask = poiService.findPOIsForCities(stops: trip.stops, limit: 5) + + // Await each result and update progress + let routeMap = await routeMapTask + progress.routeMapComplete = true + await progressCallback?(progress) + + let cityMaps = await cityMapsTask + progress.cityMapsComplete = true + await progressCallback?(progress) + + let teamLogos = await logosTask + progress.logosComplete = true + await progressCallback?(progress) + + let stadiumPhotos = await photosTask + progress.photosComplete = true + await progressCallback?(progress) + + let cityPOIs = await poisTask + progress.poisComplete = true + await progressCallback?(progress) + + print("[PDFAssetPrefetcher] Prefetch complete:") + print(" - Route map: \(routeMap != nil ? "OK" : "Failed")") + print(" - City maps: \(cityMaps.count) cities") + print(" - Team logos: \(teamLogos.count) logos") + print(" - Stadium photos: \(stadiumPhotos.count) photos") + print(" - POIs: \(cityPOIs.values.reduce(0) { $0 + $1.count }) total POIs") + + return PrefetchedAssets( + routeMap: routeMap, + cityMaps: cityMaps, + teamLogos: teamLogos, + stadiumPhotos: stadiumPhotos, + cityPOIs: cityPOIs + ) + } + + // MARK: - Private Methods + + private func fetchRouteMap(stops: [TripStop]) async -> UIImage? { + do { + // PDF page is 612 wide, minus margins + let mapSize = CGSize(width: 512, height: 350) + return try await mapService.generateRouteMap(stops: stops, size: mapSize) + } catch { + print("[PDFAssetPrefetcher] Route map failed: \(error.localizedDescription)") + return nil + } + } + + private func fetchCityMaps(stops: [TripStop]) async -> [String: UIImage] { + var results: [String: UIImage] = [:] + let mapSize = CGSize(width: 250, height: 200) + + await withTaskGroup(of: (String, UIImage?).self) { group in + // Deduplicate cities + var seenCities: Set = [] + for stop in stops { + guard !seenCities.contains(stop.city) else { continue } + seenCities.insert(stop.city) + + group.addTask { + do { + let map = try await self.mapService.generateCityMap(stop: stop, size: mapSize) + return (stop.city, map) + } catch { + print("[PDFAssetPrefetcher] City map for \(stop.city) failed: \(error.localizedDescription)") + return (stop.city, nil) + } + } + } + + for await (city, map) in group { + if let map = map { + results[city] = map + } + } + } + + return results + } +} diff --git a/SportsTime/Export/Services/POISearchService.swift b/SportsTime/Export/Services/POISearchService.swift new file mode 100644 index 0000000..d135b96 --- /dev/null +++ b/SportsTime/Export/Services/POISearchService.swift @@ -0,0 +1,246 @@ +// +// POISearchService.swift +// SportsTime +// +// Searches for nearby points of interest using MapKit for PDF city spotlights. +// + +import Foundation +import MapKit +import CoreLocation + +actor POISearchService { + + // MARK: - Types + + struct POI: Identifiable, Hashable { + let id: UUID + let name: String + let category: POICategory + let coordinate: CLLocationCoordinate2D + let distanceMeters: Double + let address: String? + + var formattedDistance: String { + let miles = distanceMeters * 0.000621371 + if miles < 0.1 { + let feet = distanceMeters * 3.28084 + return String(format: "%.0f ft", feet) + } else { + return String(format: "%.1f mi", miles) + } + } + + // Hashable conformance for CLLocationCoordinate2D + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: POI, rhs: POI) -> Bool { + lhs.id == rhs.id + } + } + + enum POICategory: String, CaseIterable { + case restaurant + case attraction + case entertainment + case nightlife + case museum + + var displayName: String { + switch self { + case .restaurant: return "Restaurant" + case .attraction: return "Attraction" + case .entertainment: return "Entertainment" + case .nightlife: return "Nightlife" + case .museum: return "Museum" + } + } + + var iconName: String { + switch self { + case .restaurant: return "fork.knife" + case .attraction: return "star.fill" + case .entertainment: return "theatermasks.fill" + case .nightlife: return "moon.stars.fill" + case .museum: return "building.columns.fill" + } + } + + var mkPointOfInterestCategory: MKPointOfInterestCategory { + switch self { + case .restaurant: return .restaurant + case .attraction: return .nationalPark + case .entertainment: return .theater + case .nightlife: return .nightlife + case .museum: return .museum + } + } + + var searchQuery: String { + switch self { + case .restaurant: return "restaurants" + case .attraction: return "tourist attractions" + case .entertainment: return "entertainment" + case .nightlife: return "bars nightlife" + case .museum: return "museums" + } + } + } + + // MARK: - Errors + + enum POISearchError: Error, LocalizedError { + case searchFailed(String) + case noResults + + var errorDescription: String? { + switch self { + case .searchFailed(let reason): + return "POI search failed: \(reason)" + case .noResults: + return "No points of interest found" + } + } + } + + // MARK: - Public Methods + + /// Find nearby POIs for a city/stadium location + func findNearbyPOIs( + near coordinate: CLLocationCoordinate2D, + categories: [POICategory] = [.restaurant, .attraction, .entertainment], + radiusMeters: Double = 3000, + limitPerCategory: Int = 2 + ) async throws -> [POI] { + var allPOIs: [POI] = [] + + // Search each category in parallel + await withTaskGroup(of: [POI].self) { group in + for category in categories { + group.addTask { + do { + return try await self.searchCategory( + category, + near: coordinate, + radiusMeters: radiusMeters, + limit: limitPerCategory + ) + } catch { + print("[POISearchService] Search failed for \(category): \(error.localizedDescription)") + return [] + } + } + } + + for await pois in group { + allPOIs.append(contentsOf: pois) + } + } + + // Sort by distance + allPOIs.sort { $0.distanceMeters < $1.distanceMeters } + + return allPOIs + } + + /// Find POIs for multiple cities (one search per city) + func findPOIsForCities( + stops: [TripStop], + categories: [POICategory] = [.restaurant, .attraction, .entertainment], + limit: Int = 5 + ) async -> [String: [POI]] { + var results: [String: [POI]] = [:] + + await withTaskGroup(of: (String, [POI]).self) { group in + for stop in stops { + guard let coordinate = stop.coordinate else { continue } + + group.addTask { + do { + let pois = try await self.findNearbyPOIs( + near: coordinate, + categories: categories, + limitPerCategory: 2 + ) + // Take top N overall + return (stop.city, Array(pois.prefix(limit))) + } catch { + print("[POISearchService] Failed for \(stop.city): \(error.localizedDescription)") + return (stop.city, []) + } + } + } + + for await (city, pois) in group { + if !pois.isEmpty { + results[city] = pois + } + } + } + + return results + } + + // MARK: - Private Methods + + private func searchCategory( + _ category: POICategory, + near coordinate: CLLocationCoordinate2D, + radiusMeters: Double, + limit: Int + ) async throws -> [POI] { + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = category.searchQuery + request.region = MKCoordinateRegion( + center: coordinate, + latitudinalMeters: radiusMeters * 2, + longitudinalMeters: radiusMeters * 2 + ) + request.resultTypes = .pointOfInterest + + let search = MKLocalSearch(request: request) + let response = try await search.start() + + let referenceLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + + let pois: [POI] = response.mapItems.prefix(limit).compactMap { item in + guard let name = item.name else { return nil } + + let itemLocation = CLLocation( + latitude: item.placemark.coordinate.latitude, + longitude: item.placemark.coordinate.longitude + ) + let distance = referenceLocation.distance(from: itemLocation) + + // Only include POIs within radius + guard distance <= radiusMeters else { return nil } + + return POI( + id: UUID(), + name: name, + category: category, + coordinate: item.placemark.coordinate, + distanceMeters: distance, + address: formatAddress(item.placemark) + ) + } + + return pois + } + + private func formatAddress(_ placemark: MKPlacemark) -> String? { + var components: [String] = [] + + if let subThoroughfare = placemark.subThoroughfare { + components.append(subThoroughfare) + } + if let thoroughfare = placemark.thoroughfare { + components.append(thoroughfare) + } + + guard !components.isEmpty else { return nil } + return components.joined(separator: " ") + } +} diff --git a/SportsTime/Export/Services/RemoteImageService.swift b/SportsTime/Export/Services/RemoteImageService.swift new file mode 100644 index 0000000..5a4b227 --- /dev/null +++ b/SportsTime/Export/Services/RemoteImageService.swift @@ -0,0 +1,179 @@ +// +// RemoteImageService.swift +// SportsTime +// +// Downloads and caches remote images for PDF export (team logos, stadium photos). +// + +import Foundation +import UIKit + +actor RemoteImageService { + + // MARK: - Errors + + enum ImageFetchError: Error, LocalizedError { + case invalidURL + case downloadFailed(String) + case invalidImageData + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid image URL" + case .downloadFailed(let reason): + return "Image download failed: \(reason)" + case .invalidImageData: + return "Downloaded data is not a valid image" + } + } + } + + // MARK: - Properties + + private let urlSession: URLSession + private var imageCache: [URL: UIImage] = [:] + + // MARK: - Init + + init() { + // Configure URLSession with caching + let config = URLSessionConfiguration.default + config.requestCachePolicy = .returnCacheDataElseLoad + config.urlCache = URLCache( + memoryCapacity: 50 * 1024 * 1024, // 50 MB memory + diskCapacity: 100 * 1024 * 1024, // 100 MB disk + diskPath: "ImageCache" + ) + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + + self.urlSession = URLSession(configuration: config) + } + + // MARK: - Public Methods + + /// Fetch a single image from URL + func fetchImage(from url: URL) async throws -> UIImage { + // Check cache first + if let cached = imageCache[url] { + return cached + } + + // Download image + let (data, response) = try await urlSession.data(from: url) + + // Validate response + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw ImageFetchError.downloadFailed("HTTP \(statusCode)") + } + + // Create image + guard let image = UIImage(data: data) else { + throw ImageFetchError.invalidImageData + } + + // Scale image for PDF (max 400pt width to keep file size reasonable) + let scaledImage = scaleImage(image, maxWidth: 400) + + // Cache it + imageCache[url] = scaledImage + + return scaledImage + } + + /// Batch fetch multiple images in parallel + func fetchImages(from urls: [URL]) async -> [URL: UIImage] { + var results: [URL: UIImage] = [:] + + await withTaskGroup(of: (URL, UIImage?).self) { group in + for url in urls { + group.addTask { + do { + let image = try await self.fetchImage(from: url) + return (url, image) + } catch { + print("[RemoteImageService] Failed to fetch \(url): \(error.localizedDescription)") + return (url, nil) + } + } + } + + for await (url, image) in group { + if let image = image { + results[url] = image + } + } + } + + return results + } + + /// Fetch team logos by team ID + func fetchTeamLogos(teams: [Team]) async -> [UUID: UIImage] { + let urlToTeam: [URL: UUID] = Dictionary( + uniqueKeysWithValues: teams.compactMap { team in + guard let logoURL = team.logoURL else { return nil } + return (logoURL, team.id) + } + ) + + let images = await fetchImages(from: Array(urlToTeam.keys)) + + var result: [UUID: UIImage] = [:] + for (url, image) in images { + if let teamId = urlToTeam[url] { + result[teamId] = image + } + } + + return result + } + + /// Fetch stadium photos by stadium ID + func fetchStadiumPhotos(stadiums: [Stadium]) async -> [UUID: UIImage] { + let urlToStadium: [URL: UUID] = Dictionary( + uniqueKeysWithValues: stadiums.compactMap { stadium in + guard let imageURL = stadium.imageURL else { return nil } + return (imageURL, stadium.id) + } + ) + + let images = await fetchImages(from: Array(urlToStadium.keys)) + + var result: [UUID: UIImage] = [:] + for (url, image) in images { + if let stadiumId = urlToStadium[url] { + result[stadiumId] = image + } + } + + return result + } + + /// Clear the in-memory cache + func clearCache() { + imageCache.removeAll() + } + + // MARK: - Private Methods + + /// Scale image to max width while maintaining aspect ratio + private func scaleImage(_ image: UIImage, maxWidth: CGFloat) -> UIImage { + let currentWidth = image.size.width + guard currentWidth > maxWidth else { return image } + + let scale = maxWidth / currentWidth + let newSize = CGSize( + width: image.size.width * scale, + height: image.size.height * scale + ) + + let renderer = UIGraphicsImageRenderer(size: newSize) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + } +} diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 6cdaf9b..a556bd3 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -19,6 +19,8 @@ struct TripDetailView: View { @State private var showShareSheet = false @State private var exportURL: URL? @State private var shareURL: URL? + @State private var isExporting = false + @State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress? @State private var mapCameraPosition: MapCameraPosition = .automatic @State private var isSaved = false @State private var routePolylines: [MKPolyline] = [] @@ -93,6 +95,64 @@ struct TripDetailView: View { .onAppear { checkIfSaved() } + .overlay { + if isExporting { + exportProgressOverlay + } + } + } + + // MARK: - Export Progress Overlay + + private var exportProgressOverlay: some View { + ZStack { + // Background dimmer + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Progress card + VStack(spacing: Theme.Spacing.lg) { + // Progress ring + ZStack { + Circle() + .stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 8) + .frame(width: 80, height: 80) + + Circle() + .trim(from: 0, to: exportProgress?.percentComplete ?? 0) + .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .frame(width: 80, height: 80) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.3), value: exportProgress?.percentComplete) + + Image(systemName: "doc.fill") + .font(.system(size: 24)) + .foregroundStyle(Theme.warmOrange) + } + + VStack(spacing: Theme.Spacing.xs) { + Text("Creating PDF") + .font(.system(size: Theme.FontSize.cardTitle, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(exportProgress?.currentStep ?? "Preparing...") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + + if let progress = exportProgress { + Text("\(Int(progress.percentComplete * 100))%") + .font(.system(size: Theme.FontSize.micro, weight: .medium, design: .monospaced)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + } + .padding(Theme.Spacing.xl) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .shadow(color: .black.opacity(0.3), radius: 20, y: 10) + } + .transition(.opacity) } // MARK: - Hero Map Section @@ -432,13 +492,22 @@ struct TripDetailView: View { // MARK: - Actions private func exportPDF() async { + isExporting = true + exportProgress = nil + do { - let url = try await exportService.exportToPDF(trip: trip, games: games) + let url = try await exportService.exportToPDF(trip: trip, games: games) { progress in + await MainActor.run { + self.exportProgress = progress + } + } exportURL = url showExportSheet = true } catch { print("Failed to export PDF: \(error)") } + + isExporting = false } private func shareTrip() async {