Files
Sportstime/SportsTime/Features/Settings/DebugShareExporter.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

815 lines
32 KiB
Swift

//
// 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
guard !defs.isEmpty else {
exportPath = exportDir.path
currentStep = "Export complete! (no achievements found)"
isExporting = false
return
}
let fallback = defs[0]
var sampleDefs: [AchievementDefinition] = [
defs.first { $0.sport == .mlb } ?? fallback,
defs.first { $0.sport == .nba } ?? fallback,
defs.first { $0.sport == .nhl } ?? fallback,
defs.first { $0.name.lowercased().contains("complete") } ?? fallback,
defs.first { $0.category == .journey } ?? fallback
]
// Deduplicate in case multiple fallbacks resolved to the same definition
var seen = Set<String>()
sampleDefs = sampleDefs.filter { seen.insert($0.id).inserted }
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: - Save Sample Trips
func saveSampleTrips(modelContext: ModelContext) {
let trips = Self.buildDummyTrips()
var savedCount = 0
for trip in trips {
if let savedTrip = SavedTrip.from(trip, status: .planned) {
modelContext.insert(savedTrip)
savedCount += 1
}
}
do {
try modelContext.save()
print("DEBUG: Saved \(savedCount) sample trips")
} catch {
print("DEBUG: Failed to save sample trips: \(error)")
}
}
// MARK: - Build Sample Poll
static func buildSamplePoll() -> TripPoll {
let trips = buildDummyTrips()
let sampleVotes = [
PollVote(pollId: UUID(), voterId: "voter1", rankings: [0, 2, 1, 3]),
PollVote(pollId: UUID(), voterId: "voter2", rankings: [2, 0, 3, 1]),
PollVote(pollId: UUID(), voterId: "voter3", rankings: [0, 1, 2, 3]),
]
_ = sampleVotes // votes are shown via PollResults, we pass them separately
return TripPoll(
title: "Summer 2026 Road Trip",
ownerId: "debug-user",
tripSnapshots: trips
)
}
static func buildSampleVotes(for poll: TripPoll) -> [PollVote] {
[
PollVote(pollId: poll.id, voterId: "voter-alex", rankings: [0, 2, 1, 3]),
PollVote(pollId: poll.id, voterId: "voter-sam", rankings: [2, 0, 3, 1]),
PollVote(pollId: poll.id, voterId: "voter-jordan", rankings: [0, 1, 2, 3]),
]
}
// 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