From 1a7ce78ae493a798b5885cd83472a74aa9370d28 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 9 Feb 2026 12:39:20 -0600 Subject: [PATCH] feat(debug): add bulk share image export and stadium visit tools Adds debug-only DebugShareExporter that bulk-exports ~298 shareable image variations (achievement cards, progress cards, trip cards, icons) to Documents/DebugExport/ for visual QA. Also adds a button to populate all stadium visits for testing the fully-unlocked app state. Co-Authored-By: Claude Opus 4.6 --- .../Settings/DebugShareExporter.swift | 622 ++++++++++++++++++ .../Settings/Views/SettingsView.swift | 20 + 2 files changed, 642 insertions(+) create mode 100644 SportsTime/Features/Settings/DebugShareExporter.swift diff --git a/SportsTime/Features/Settings/DebugShareExporter.swift b/SportsTime/Features/Settings/DebugShareExporter.swift new file mode 100644 index 0000000..2650f2c --- /dev/null +++ b/SportsTime/Features/Settings/DebugShareExporter.swift @@ -0,0 +1,622 @@ +// +// 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 diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 7e747c9..83fe6a1 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -15,6 +15,8 @@ struct SettingsView: View { #if DEBUG @State private var selectedSyncStatus: EntitySyncStatus? @State private var showSyncLogs = false + @State private var exporter = DebugShareExporter() + @State private var showExportProgress = false #endif var body: some View { @@ -348,12 +350,30 @@ struct SettingsView: View { } label: { Label("Reset Onboarding Flag", systemImage: "arrow.counterclockwise") } + + Button { + showExportProgress = true + Task { + await exporter.exportAll(modelContext: modelContext) + } + } label: { + Label("Export All Shareables", systemImage: "square.and.arrow.up.on.square") + } + + Button { + Task { await exporter.addAllStadiumVisits(modelContext: modelContext) } + } label: { + Label("Add All Stadium Visits", systemImage: "mappin.and.ellipse") + } } header: { Text("Debug") } footer: { Text("These options are only visible in debug builds.") } .listRowBackground(Theme.cardBackground(colorScheme)) + .sheet(isPresented: $showExportProgress) { + DebugExportProgressView(exporter: exporter) + } } private var syncStatusSection: some View {