// // 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 = 1 * achievements, collection ~5, progress 12, trips 4, icons 1 totalCount = achievementCount + 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 cards currentStep = "Exporting spotlight cards..." let spotlightTheme = ShareThemePreferences.theme(for: .achievementSpotlight) let spotlightDir = exportDir.appendingPathComponent("achievements/spotlight") for definition in AchievementRegistry.all { let achievement = AchievementProgress( definition: definition, currentProgress: totalRequired(for: definition), totalRequired: totalRequired(for: definition), hasStoredAchievement: true, earnedAt: Date() ) 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() } // 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 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 7: 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 8: 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: - Export Achievement Samples func exportAchievementSamples() async { guard !isExporting else { return } isExporting = true error = nil exportPath = nil exportedCount = 0 do { currentStep = "Creating export directory..." 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/samples_\(timestamp)") try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) // Pick a few representative achievements across sports let defs = AchievementRegistry.all let sampleDefs = [ defs.first { $0.sport == .mlb } ?? defs[0], defs.first { $0.sport == .nba } ?? defs[1], defs.first { $0.sport == .nhl } ?? defs[2], defs.first { $0.name.lowercased().contains("complete") } ?? defs[3], defs.first { $0.category == .journey } ?? defs[min(4, defs.count - 1)] ] totalCount = sampleDefs.count let spotlightTheme = ShareThemePreferences.theme(for: .achievementSpotlight) for def in sampleDefs { let achievement = AchievementProgress( definition: def, currentProgress: totalRequired(for: def), totalRequired: totalRequired(for: def), hasStoredAchievement: true, earnedAt: Date() ) let safeName = def.id.replacingOccurrences(of: " ", with: "_") currentStep = "Spotlight: \(def.name)" let spotlightContent = AchievementSpotlightContent(achievement: achievement) let spotlightImage = try await spotlightContent.render(theme: spotlightTheme) try savePNG(spotlightImage, to: exportDir.appendingPathComponent("spotlight_\(safeName).png")) exportedCount += 1 updateProgress() } exportPath = exportDir.path currentStep = "Export complete!" print("DEBUG SAMPLES: \(exportDir.path)") } catch { self.error = error.localizedDescription currentStep = "Export failed" print("DEBUG SAMPLE ERROR: \(error)") } isExporting = false } // MARK: - Export Progress Samples func exportProgressSamples() async { guard !isExporting else { return } isExporting = true error = nil exportPath = nil exportedCount = 0 do { currentStep = "Creating export directory..." 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/progress_samples_\(timestamp)") try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) let allStadiums = AppDataProvider.shared.stadiums let theme = ShareThemePreferences.theme(for: .stadiumProgress) // Render real progress cards at different percentages per sport let sports: [Sport] = [.mlb, .nba, .nhl] let percentages = [25, 50, 75, 100] totalCount = sports.count * percentages.count for sport in sports { let sportStadiums = allStadiums.filter { $0.sport == sport } let total = sportStadiums.count for pct in percentages { 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 currentStep = "Progress: \(sport.rawValue) \(pct)%" let content = ProgressShareContent( progress: leagueProgress, tripCount: tripCount ) let image = try await content.render(theme: theme) try savePNG(image, to: exportDir.appendingPathComponent("\(sport.rawValue)_\(pct).png")) exportedCount += 1 updateProgress() } } exportPath = exportDir.path currentStep = "Export complete!" print("DEBUG PROGRESS SAMPLES: \(exportDir.path)") } catch { self.error = error.localizedDescription currentStep = "Export failed" print("DEBUG PROGRESS SAMPLE ERROR: \(error)") } isExporting = false } // MARK: - Export Trip Samples func exportTripSamples() async { guard !isExporting else { return } isExporting = true error = nil exportPath = nil exportedCount = 0 do { currentStep = "Creating export directory..." 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/trip_samples_\(timestamp)") try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) let theme = ShareThemePreferences.theme(for: .tripSummary) let dummyTrips = Self.buildDummyTrips() totalCount = dummyTrips.count for trip in dummyTrips { currentStep = "Trip: \(trip.name)" let content = TripShareContent(trip: trip) let image = try await content.render(theme: theme) let safeName = trip.name.replacingOccurrences(of: " ", with: "_") try savePNG(image, to: exportDir.appendingPathComponent("\(safeName).png")) exportedCount += 1 updateProgress() } exportPath = exportDir.path currentStep = "Export complete!" print("DEBUG TRIP SAMPLES: \(exportDir.path)") } catch { self.error = error.localizedDescription currentStep = "Export failed" print("DEBUG TRIP SAMPLE 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/collection", "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