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:
622
SportsTime/Features/Settings/DebugShareExporter.swift
Normal file
622
SportsTime/Features/Settings/DebugShareExporter.swift
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user