// // DebugShareExporter.swift // SportsTime // // Debug-only bulk export of all shareable image variations for visual QA. // #if DEBUG import SwiftUI import SwiftData import CoreLocation // MARK: - Debug Share Exporter @MainActor @Observable final class DebugShareExporter { var isExporting = false var currentStep = "" var progress: Double = 0 var exportedCount = 0 var totalCount = 0 var exportPath: String? var error: String? // MARK: - Export All func exportAll(modelContext: ModelContext) async { guard !isExporting else { return } isExporting = true error = nil exportPath = nil exportedCount = 0 let achievementCount = AchievementRegistry.all.count // spotlight + milestone + context = 3 * achievements, collection ~5, progress 12, trips 4, icons 1 totalCount = (achievementCount * 3) + 5 + 12 + 4 + 1 do { // Step 1: Create export directory currentStep = "Creating export directory..." let exportDir = try createExportDirectory() // Step 2: Add stadium visits currentStep = "Adding stadium visits..." await addAllStadiumVisitsInternal(modelContext: modelContext) // Step 3: Recalculate achievements currentStep = "Recalculating achievements..." let engine = AchievementEngine(modelContext: modelContext) _ = try await engine.recalculateAllAchievements() // Step 4: Export achievement spotlight + milestone cards currentStep = "Exporting spotlight cards..." let spotlightTheme = ShareThemePreferences.theme(for: .achievementSpotlight) let milestoneTheme = ShareThemePreferences.theme(for: .achievementMilestone) let spotlightDir = exportDir.appendingPathComponent("achievements/spotlight") let milestoneDir = exportDir.appendingPathComponent("achievements/milestone") for definition in AchievementRegistry.all { let achievement = AchievementProgress( definition: definition, currentProgress: totalRequired(for: definition), totalRequired: totalRequired(for: definition), hasStoredAchievement: true, earnedAt: Date() ) // Spotlight currentStep = "Spotlight: \(definition.name)" let spotlightContent = AchievementSpotlightContent(achievement: achievement) let spotlightImage = try await spotlightContent.render(theme: spotlightTheme) try savePNG(spotlightImage, to: spotlightDir.appendingPathComponent("\(definition.id).png")) exportedCount += 1 updateProgress() // Milestone currentStep = "Milestone: \(definition.name)" let milestoneContent = AchievementMilestoneContent(achievement: achievement) let milestoneImage = try await milestoneContent.render(theme: milestoneTheme) try savePNG(milestoneImage, to: milestoneDir.appendingPathComponent("\(definition.id).png")) exportedCount += 1 updateProgress() } // Step 5: Export achievement collection cards currentStep = "Exporting collection cards..." let collectionTheme = ShareThemePreferences.theme(for: .achievementCollection) let collectionDir = exportDir.appendingPathComponent("achievements/collection") let allAchievements = AchievementRegistry.all.map { def in AchievementProgress( definition: def, currentProgress: totalRequired(for: def), totalRequired: totalRequired(for: def), hasStoredAchievement: true, earnedAt: Date() ) } // Per-sport collections for sport in [Sport.mlb, .nba, .nhl] { let sportAchievements = allAchievements.filter { $0.definition.sport == sport } let collectionContent = AchievementCollectionContent( achievements: Array(sportAchievements.prefix(12)), year: 2026, sports: [sport], filterSport: sport ) let image = try await collectionContent.render(theme: collectionTheme) try savePNG(image, to: collectionDir.appendingPathComponent("\(sport.rawValue).png")) exportedCount += 1 updateProgress() } // Cross-sport collection let crossSportAchievements = allAchievements.filter { $0.definition.sport == nil } if !crossSportAchievements.isEmpty { let content = AchievementCollectionContent( achievements: Array(crossSportAchievements.prefix(12)), year: 2026 ) let image = try await content.render(theme: collectionTheme) try savePNG(image, to: collectionDir.appendingPathComponent("cross-sport.png")) } exportedCount += 1 updateProgress() // All top-12 collection let topAchievements = Array(allAchievements.prefix(12)) let allContent = AchievementCollectionContent( achievements: topAchievements, year: 2026 ) let allImage = try await allContent.render(theme: collectionTheme) try savePNG(allImage, to: collectionDir.appendingPathComponent("all.png")) exportedCount += 1 updateProgress() // Step 6: Export achievement context cards currentStep = "Generating map snapshot for context cards..." let contextTheme = ShareThemePreferences.theme(for: .achievementContext) let contextDir = exportDir.appendingPathComponent("achievements/context") let contextStops = Self.eastCoastStops() let mapGenerator = ShareMapSnapshotGenerator() let mapSnapshot = await mapGenerator.generateRouteMap(stops: contextStops, theme: contextTheme) for definition in AchievementRegistry.all { currentStep = "Context: \(definition.name)" let achievement = AchievementProgress( definition: definition, currentProgress: totalRequired(for: definition), totalRequired: totalRequired(for: definition), hasStoredAchievement: true, earnedAt: Date() ) let contextContent = AchievementContextContent( achievement: achievement, tripName: "Road Trip 2026", mapSnapshot: mapSnapshot ) let image = try await contextContent.render(theme: contextTheme) try savePNG(image, to: contextDir.appendingPathComponent("\(definition.id).png")) exportedCount += 1 updateProgress() } // Step 7: Export progress cards currentStep = "Exporting progress cards..." let progressTheme = ShareThemePreferences.theme(for: .stadiumProgress) let progressDir = exportDir.appendingPathComponent("progress") let allStadiums = AppDataProvider.shared.stadiums for sport in [Sport.mlb, .nba, .nhl] { let sportStadiums = allStadiums.filter { $0.sport == sport } let total = sportStadiums.count for pct in [25, 50, 75, 100] { currentStep = "Progress: \(sport.rawValue) \(pct)%" let visitedCount = (total * pct) / 100 let visited = Array(sportStadiums.prefix(visitedCount)) let remaining = Array(sportStadiums.dropFirst(visitedCount)) let leagueProgress = LeagueProgress( sport: sport, totalStadiums: total, visitedStadiums: visitedCount, stadiumsVisited: visited, stadiumsRemaining: remaining ) let tripCount = pct == 100 ? 5 : pct / 25 let progressContent = ProgressShareContent( progress: leagueProgress, tripCount: tripCount ) let image = try await progressContent.render(theme: progressTheme) try savePNG(image, to: progressDir.appendingPathComponent("\(sport.rawValue)_\(pct).png")) exportedCount += 1 updateProgress() } } // Step 8: Export trip cards currentStep = "Exporting trip cards..." let tripTheme = ShareThemePreferences.theme(for: .tripSummary) let tripDir = exportDir.appendingPathComponent("trips") let dummyTrips = Self.buildDummyTrips() for trip in dummyTrips { currentStep = "Trip: \(trip.name)" let tripContent = TripShareContent(trip: trip) let image = try await tripContent.render(theme: tripTheme) let safeName = trip.name.replacingOccurrences(of: " ", with: "_") try savePNG(image, to: tripDir.appendingPathComponent("\(safeName).png")) exportedCount += 1 updateProgress() } // Step 9: Export sports icon currentStep = "Exporting sports icon..." let iconDir = exportDir.appendingPathComponent("icons") if let iconData = SportsIconImageGenerator.generatePNGData(), let iconImage = UIImage(data: iconData) { try savePNG(iconImage, to: iconDir.appendingPathComponent("sports_icons.png")) } exportedCount += 1 updateProgress() // Done exportPath = exportDir.path currentStep = "Export complete!" print("DEBUG EXPORT: \(exportDir.path)") } catch { self.error = error.localizedDescription currentStep = "Export failed" print("DEBUG EXPORT ERROR: \(error)") } isExporting = false } // MARK: - Add All Stadium Visits func addAllStadiumVisits(modelContext: ModelContext) async { guard !isExporting else { return } isExporting = true currentStep = "Adding stadium visits..." error = nil await addAllStadiumVisitsInternal(modelContext: modelContext) // Recalculate achievements currentStep = "Recalculating achievements..." do { let engine = AchievementEngine(modelContext: modelContext) let delta = try await engine.recalculateAllAchievements() currentStep = "Done! \(delta.newlyEarned.count) achievements earned" print("DEBUG: Added all stadium visits. \(delta.newlyEarned.count) new achievements.") } catch { self.error = error.localizedDescription currentStep = "Achievement calc failed" } isExporting = false } // MARK: - Private Helpers private func addAllStadiumVisitsInternal(modelContext: ModelContext) async { let stadiums = AppDataProvider.shared.stadiums let calendar = Calendar.current let today = Date() // Spread visits across 3 days to trigger journey achievements for (index, stadium) in stadiums.enumerated() { let dayOffset = index % 3 let visitDate = calendar.date(byAdding: .day, value: -dayOffset, to: today) ?? today let visit = StadiumVisit( stadiumId: stadium.id, stadiumNameAtVisit: stadium.name, visitDate: visitDate, sport: stadium.sport, visitType: .game, dataSource: .fullyManual, source: .manual ) modelContext.insert(visit) } do { try modelContext.save() print("DEBUG: Inserted \(stadiums.count) stadium visits") } catch { print("DEBUG: Failed to save visits: \(error)") self.error = "Failed to save visits: \(error.localizedDescription)" } } private func createExportDirectory() throws -> URL { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd_HHmmss" let timestamp = formatter.string(from: Date()) let exportDir = docs.appendingPathComponent("DebugExport/\(timestamp)") let subdirs = [ "achievements/spotlight", "achievements/milestone", "achievements/collection", "achievements/context", "progress", "trips", "icons" ] for subdir in subdirs { try FileManager.default.createDirectory( at: exportDir.appendingPathComponent(subdir), withIntermediateDirectories: true ) } return exportDir } private func savePNG(_ image: UIImage, to url: URL) throws { guard let data = image.pngData() else { throw ExportError.renderingFailed } try data.write(to: url) } private func updateProgress() { progress = totalCount > 0 ? Double(exportedCount) / Double(totalCount) : 0 } /// Compute a reasonable totalRequired for an achievement definition. /// For division/conference, we use the team count from LeagueStructure since /// AppDataProvider doesn't expose per-division stadium queries. private func totalRequired(for definition: AchievementDefinition) -> Int { switch definition.requirement { case .firstVisit: return 1 case .visitCount(let count): return count case .visitCountForSport(let count, _): return count case .completeDivision(let divisionId): if let division = LeagueStructure.division(byId: divisionId) { return max(division.teamCount, 5) } return 5 case .completeConference(let conferenceId): if let conference = LeagueStructure.conference(byId: conferenceId) { return conference.divisionIds.count * 5 } return 15 case .completeLeague(let sport): return AppDataProvider.shared.stadiums.filter { $0.sport == sport }.count case .visitsInDays(let visitCount, _): return visitCount case .multipleLeagues(let count): return count case .specificStadium: return 1 } } // MARK: - Dummy Trip Data private static func eastCoastStops() -> [TripStop] { let base = Date() return [ TripStop( stopNumber: 1, city: "New York", state: "NY", coordinate: CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262), arrivalDate: base, departureDate: base.addingTimeInterval(86400), games: ["game1"], stadium: "Yankee Stadium" ), TripStop( stopNumber: 2, city: "Boston", state: "MA", coordinate: CLLocationCoordinate2D(latitude: 42.3467, longitude: -71.0972), arrivalDate: base.addingTimeInterval(86400), departureDate: base.addingTimeInterval(172800), games: ["game2"], stadium: "Fenway Park" ), TripStop( stopNumber: 3, city: "Philadelphia", state: "PA", coordinate: CLLocationCoordinate2D(latitude: 39.9061, longitude: -75.1665), arrivalDate: base.addingTimeInterval(172800), departureDate: base.addingTimeInterval(259200), games: ["game3"], stadium: "Citizens Bank Park" ), TripStop( stopNumber: 4, city: "Washington", state: "DC", coordinate: CLLocationCoordinate2D(latitude: 38.8730, longitude: -77.0074), arrivalDate: base.addingTimeInterval(259200), departureDate: base.addingTimeInterval(345600), games: ["game4"], stadium: "Nationals Park" ) ] } private static func westCoastStops() -> [TripStop] { let base = Date() return [ TripStop( stopNumber: 1, city: "Seattle", state: "WA", coordinate: CLLocationCoordinate2D(latitude: 47.5914, longitude: -122.3323), arrivalDate: base, departureDate: base.addingTimeInterval(86400), games: ["game1"], stadium: "T-Mobile Park" ), TripStop( stopNumber: 2, city: "San Francisco", state: "CA", coordinate: CLLocationCoordinate2D(latitude: 37.7680, longitude: -122.3877), arrivalDate: base.addingTimeInterval(86400), departureDate: base.addingTimeInterval(172800), games: ["game2"], stadium: "Chase Center" ), TripStop( stopNumber: 3, city: "San Francisco", state: "CA", coordinate: CLLocationCoordinate2D(latitude: 37.7786, longitude: -122.3893), arrivalDate: base.addingTimeInterval(172800), departureDate: base.addingTimeInterval(259200), games: ["game3"], stadium: "Oracle Park" ), TripStop( stopNumber: 4, city: "Los Angeles", state: "CA", coordinate: CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400), arrivalDate: base.addingTimeInterval(259200), departureDate: base.addingTimeInterval(345600), games: ["game4"], stadium: "Dodger Stadium" ) ] } private static func centralStops() -> [TripStop] { let base = Date() return [ TripStop( stopNumber: 1, city: "Chicago", state: "IL", coordinate: CLLocationCoordinate2D(latitude: 41.8299, longitude: -87.6338), arrivalDate: base, departureDate: base.addingTimeInterval(86400), games: ["game1"], stadium: "Guaranteed Rate Field" ), TripStop( stopNumber: 2, city: "Milwaukee", state: "WI", coordinate: CLLocationCoordinate2D(latitude: 43.0280, longitude: -87.9712), arrivalDate: base.addingTimeInterval(86400), departureDate: base.addingTimeInterval(172800), games: ["game2"], stadium: "American Family Field" ), TripStop( stopNumber: 3, city: "Minneapolis", state: "MN", coordinate: CLLocationCoordinate2D(latitude: 44.9817, longitude: -93.2776), arrivalDate: base.addingTimeInterval(172800), departureDate: base.addingTimeInterval(259200), games: ["game3"], stadium: "Target Field" ), TripStop( stopNumber: 4, city: "Detroit", state: "MI", coordinate: CLLocationCoordinate2D(latitude: 42.3390, longitude: -83.0485), arrivalDate: base.addingTimeInterval(259200), departureDate: base.addingTimeInterval(345600), games: ["game4"], stadium: "Comerica Park" ) ] } private static func crossCountryStops() -> [TripStop] { let base = Date() return [ TripStop( stopNumber: 1, city: "New York", state: "NY", coordinate: CLLocationCoordinate2D(latitude: 40.7571, longitude: -73.8458), arrivalDate: base, departureDate: base.addingTimeInterval(86400), games: ["game1"], stadium: "Citi Field" ), TripStop( stopNumber: 2, city: "Chicago", state: "IL", coordinate: CLLocationCoordinate2D(latitude: 41.9484, longitude: -87.6553), arrivalDate: base.addingTimeInterval(86400), departureDate: base.addingTimeInterval(172800), games: ["game2"], stadium: "Wrigley Field" ), TripStop( stopNumber: 3, city: "Denver", state: "CO", coordinate: CLLocationCoordinate2D(latitude: 39.7559, longitude: -104.9942), arrivalDate: base.addingTimeInterval(172800), departureDate: base.addingTimeInterval(259200), games: ["game3"], stadium: "Coors Field" ), TripStop( stopNumber: 4, city: "Los Angeles", state: "CA", coordinate: CLLocationCoordinate2D(latitude: 34.0739, longitude: -118.2400), arrivalDate: base.addingTimeInterval(259200), departureDate: base.addingTimeInterval(345600), games: ["game4"], stadium: "Dodger Stadium" ) ] } private static func buildDummyTrips() -> [Trip] { let prefs = TripPreferences(sports: [.mlb]) func makeTrip(name: String, stops: [TripStop]) -> Trip { var segments: [TravelSegment] = [] for i in 0..<(stops.count - 1) { segments.append(TravelSegment( fromLocation: LocationInput(name: stops[i].city, coordinate: stops[i].coordinate), toLocation: LocationInput(name: stops[i + 1].city, coordinate: stops[i + 1].coordinate), travelMode: .drive, distanceMeters: 400_000, durationSeconds: 14400 )) } return Trip( name: name, preferences: prefs, stops: stops, travelSegments: segments, totalGames: stops.count, totalDistanceMeters: Double(segments.count) * 400_000, totalDrivingSeconds: Double(segments.count) * 14400 ) } return [ makeTrip(name: "East Coast", stops: eastCoastStops()), makeTrip(name: "West Coast", stops: westCoastStops()), makeTrip(name: "Central", stops: centralStops()), makeTrip(name: "Cross Country", stops: crossCountryStops()) ] } enum ExportError: LocalizedError { case renderingFailed var errorDescription: String? { switch self { case .renderingFailed: return "Failed to render image to PNG" } } } } // MARK: - Debug Export Progress View struct DebugExportProgressView: View { @Bindable var exporter: DebugShareExporter @Environment(\.dismiss) private var dismiss @State private var copiedPath = false var body: some View { NavigationStack { VStack(spacing: 24) { Spacer() if exporter.isExporting { ProgressView(value: exporter.progress) { Text(exporter.currentStep) .font(.subheadline) .foregroundStyle(.secondary) } .padding(.horizontal) Text("\(exporter.exportedCount) of \(exporter.totalCount) exported") .font(.caption) .foregroundStyle(.tertiary) } else if let error = exporter.error { Image(systemName: "xmark.circle.fill") .font(.system(size: 48)) .foregroundStyle(.red) Text("Export Failed") .font(.headline) Text(error) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) } else if let path = exporter.exportPath { Image(systemName: "checkmark.circle.fill") .font(.system(size: 48)) .foregroundStyle(.green) Text("Export Complete!") .font(.headline) Text("\(exporter.exportedCount) images exported") .font(.subheadline) .foregroundStyle(.secondary) Text(path) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) .textSelection(.enabled) .padding(.horizontal) Button { UIPasteboard.general.string = path copiedPath = true } label: { Label( copiedPath ? "Copied!" : "Copy Path", systemImage: copiedPath ? "checkmark" : "doc.on.doc" ) } .buttonStyle(.borderedProminent) } else { Text("Ready to export") .foregroundStyle(.secondary) } Spacer() } .navigationTitle("Share Export") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } .disabled(exporter.isExporting) } } } .presentationDetents([.medium]) .interactiveDismissDisabled(exporter.isExporting) } } #endif