Files
Sportstime/SportsTime/Export/Services/MapSnapshotService.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

296 lines
9.0 KiB
Swift

//
// MapSnapshotService.swift
// SportsTime
//
// Generates static map images for PDF export using MKMapSnapshotter.
//
import Foundation
import MapKit
import UIKit
actor MapSnapshotService {
// MARK: - Errors
enum MapSnapshotError: Error, LocalizedError {
case noStops
case snapshotFailed(String)
case invalidCoordinates
var errorDescription: String? {
switch self {
case .noStops:
return "No stops provided for map generation"
case .snapshotFailed(let reason):
return "Map snapshot failed: \(reason)"
case .invalidCoordinates:
return "Invalid coordinates for map region"
}
}
}
// MARK: - Route Map
/// Generate a full route map showing all stops with route line
func generateRouteMap(
stops: [TripStop],
size: CGSize,
routeColor: UIColor = UIColor(red: 0.12, green: 0.23, blue: 0.54, alpha: 1.0)
) async throws -> UIImage {
guard !stops.isEmpty else {
throw MapSnapshotError.noStops
}
let coordinates = stops.compactMap { $0.coordinate }
guard !coordinates.isEmpty else {
throw MapSnapshotError.invalidCoordinates
}
// Calculate region to fit all stops with padding
let region = regionToFit(coordinates: coordinates, paddingPercent: 0.2)
// Configure snapshotter
let options = MKMapSnapshotter.Options()
options.region = region
options.size = size
options.mapType = .standard
options.showsBuildings = false
options.pointOfInterestFilter = .excludingAll
// Generate snapshot
let snapshotter = MKMapSnapshotter(options: options)
let snapshot = try await snapshotter.start()
// Draw route and markers on snapshot (UIKit drawing must run on main thread)
let image = await MainActor.run {
drawRouteOverlay(
on: snapshot,
coordinates: coordinates,
stops: stops,
routeColor: routeColor,
size: size
)
}
return image
}
// MARK: - City Map
/// Generate a city-level map showing stadium location
func generateCityMap(
stop: TripStop,
size: CGSize,
radiusMeters: Double = 5000
) async throws -> UIImage {
guard let coordinate = stop.coordinate else {
throw MapSnapshotError.invalidCoordinates
}
// Create region centered on stadium
let region = MKCoordinateRegion(
center: coordinate,
latitudinalMeters: radiusMeters * 2,
longitudinalMeters: radiusMeters * 2
)
// Configure snapshotter
let options = MKMapSnapshotter.Options()
options.region = region
options.size = size
options.mapType = .standard
options.showsBuildings = true
options.pointOfInterestFilter = .includingAll
// Generate snapshot
let snapshotter = MKMapSnapshotter(options: options)
let snapshot = try await snapshotter.start()
// Draw stadium marker (UIKit drawing must run on main thread)
let image = await MainActor.run {
drawStadiumMarker(
on: snapshot,
coordinate: coordinate,
cityName: stop.city,
size: size
)
}
return image
}
// MARK: - Helper Methods
/// Calculate a region that fits all coordinates with padding
private func regionToFit(coordinates: [CLLocationCoordinate2D], paddingPercent: Double) -> MKCoordinateRegion {
var minLat = coordinates[0].latitude
var maxLat = coordinates[0].latitude
var minLon = coordinates[0].longitude
var maxLon = coordinates[0].longitude
for coord in coordinates {
minLat = min(minLat, coord.latitude)
maxLat = max(maxLat, coord.latitude)
minLon = min(minLon, coord.longitude)
maxLon = max(maxLon, coord.longitude)
}
let latSpan = (maxLat - minLat) * (1 + paddingPercent)
let lonSpan = (maxLon - minLon) * (1 + paddingPercent)
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
let span = MKCoordinateSpan(
latitudeDelta: max(latSpan, 0.5),
longitudeDelta: max(lonSpan, 0.5)
)
return MKCoordinateRegion(center: center, span: span)
}
/// Draw route line and numbered markers on snapshot
private nonisolated func drawRouteOverlay(
on snapshot: MKMapSnapshotter.Snapshot,
coordinates: [CLLocationCoordinate2D],
stops: [TripStop],
routeColor: UIColor,
size: CGSize
) -> UIImage {
UIGraphicsBeginImageContextWithOptions(size, true, 0)
defer { UIGraphicsEndImageContext() }
// Draw base map
snapshot.image.draw(at: .zero)
guard let context = UIGraphicsGetCurrentContext() else {
return snapshot.image
}
// Draw route line connecting stops
if coordinates.count > 1 {
context.setStrokeColor(routeColor.cgColor)
context.setLineWidth(3.0)
context.setLineCap(.round)
context.setLineJoin(.round)
let points = coordinates.map { snapshot.point(for: $0) }
context.move(to: points[0])
for i in 1..<points.count {
context.addLine(to: points[i])
}
context.strokePath()
}
// Draw numbered markers for each stop
for (index, coordinate) in coordinates.enumerated() {
let point = snapshot.point(for: coordinate)
drawNumberedMarker(
at: point,
number: index + 1,
color: routeColor,
in: context
)
}
return UIGraphicsGetImageFromCurrentImageContext() ?? snapshot.image
}
/// Draw stadium marker on city map
private nonisolated func drawStadiumMarker(
on snapshot: MKMapSnapshotter.Snapshot,
coordinate: CLLocationCoordinate2D,
cityName: String,
size: CGSize
) -> UIImage {
UIGraphicsBeginImageContextWithOptions(size, true, 0)
defer { UIGraphicsEndImageContext() }
// Draw base map
snapshot.image.draw(at: .zero)
guard let context = UIGraphicsGetCurrentContext() else {
return snapshot.image
}
let point = snapshot.point(for: coordinate)
// Draw stadium pin
let pinSize: CGFloat = 30
let pinRect = CGRect(
x: point.x - pinSize / 2,
y: point.y - pinSize,
width: pinSize,
height: pinSize
)
// Draw pin shadow
context.saveGState()
context.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor)
// Draw pin body (red marker)
context.setFillColor(UIColor.systemRed.cgColor)
context.fillEllipse(in: pinRect.insetBy(dx: 2, dy: 2))
// Draw pin center (white dot)
context.setFillColor(UIColor.white.cgColor)
let centerRect = CGRect(
x: point.x - 5,
y: point.y - pinSize / 2 - 5,
width: 10,
height: 10
)
context.fillEllipse(in: centerRect)
context.restoreGState()
return UIGraphicsGetImageFromCurrentImageContext() ?? snapshot.image
}
/// Draw a numbered circular marker
private nonisolated func drawNumberedMarker(
at point: CGPoint,
number: Int,
color: UIColor,
in context: CGContext
) {
let radius: CGFloat = 14
let rect = CGRect(
x: point.x - radius,
y: point.y - radius,
width: radius * 2,
height: radius * 2
)
// Draw white outline
context.saveGState()
context.setShadow(offset: CGSize(width: 0, height: 1), blur: 2, color: UIColor.black.withAlphaComponent(0.3).cgColor)
context.setFillColor(UIColor.white.cgColor)
context.fillEllipse(in: rect.insetBy(dx: -2, dy: -2))
context.restoreGState()
// Draw colored circle
context.setFillColor(color.cgColor)
context.fillEllipse(in: rect)
// Draw number text
let numberString = "\(number)" as NSString
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 12),
.foregroundColor: UIColor.white
]
let textSize = numberString.size(withAttributes: attributes)
let textRect = CGRect(
x: point.x - textSize.width / 2,
y: point.y - textSize.height / 2,
width: textSize.width,
height: textSize.height
)
numberString.draw(in: textRect, withAttributes: attributes)
}
}