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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-09 12:39:20 -06:00
parent e6ed766ccd
commit 1a7ce78ae4
2 changed files with 642 additions and 0 deletions

View File

@@ -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

View File

@@ -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 {