Files
Sportstime/SportsTime/Export/Services/RemoteImageService.swift
Trey t d0cbf75fc4 fix: 14 audit fixes — concurrency, memory, performance, accessibility
Second audit round addressing data races, task stacking, unbounded
caches, and VoiceOver gaps across 7 files.

Concurrency:
- Move NSItemProvider @State access into MainActor block (3 drop handlers)
- Cancel stale ScheduleViewModel tasks on rapid filter changes

Memory:
- Replace unbounded image dict with LRUCache(countLimit: 50)
- Replace demo-mode asyncAfter with cancellable Task

Performance:
- Wrap debug NBA print() in #if DEBUG
- Cache visitsById via @State + onChange instead of per-render computed
- Pre-compute sportProgressFractions in ProgressViewModel
- Replace allGames computed property with hasGames bool check
- Cache sortedTrips via @State + onChange in SavedTripsListView

Accessibility:
- Add combined VoiceOver label to progress ring
- Combine away/home team text into single readable phrase
- Hide decorative StadiumDetailSheet icon from VoiceOver
- Add explicit accessibilityLabel to SportFilterChip
- Add combined accessibilityLabel to GameRowView

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:30:30 -06:00

180 lines
5.2 KiB
Swift

//
// RemoteImageService.swift
// SportsTime
//
// Downloads and caches remote images for PDF export (team logos, stadium photos).
//
import Foundation
import UIKit
import LRUCache
actor RemoteImageService {
// MARK: - Errors
enum ImageFetchError: Error, LocalizedError {
case invalidURL
case downloadFailed(String)
case invalidImageData
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid image URL"
case .downloadFailed(let reason):
return "Image download failed: \(reason)"
case .invalidImageData:
return "Downloaded data is not a valid image"
}
}
}
// MARK: - Properties
private let urlSession: URLSession
private let imageCache = LRUCache<URL, UIImage>(countLimit: 50)
// MARK: - Init
init() {
// Configure URLSession with caching
let config = URLSessionConfiguration.default
config.requestCachePolicy = .returnCacheDataElseLoad
config.urlCache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50 MB memory
diskCapacity: 100 * 1024 * 1024, // 100 MB disk
diskPath: "ImageCache"
)
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
self.urlSession = URLSession(configuration: config)
}
// MARK: - Public Methods
/// Fetch a single image from URL
func fetchImage(from url: URL) async throws -> UIImage {
// Check cache first
if let cached = imageCache.value(forKey: url) {
return cached
}
// Download image
let (data, response) = try await urlSession.data(from: url)
// Validate response
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
throw ImageFetchError.downloadFailed("HTTP \(statusCode)")
}
// Create image
guard let image = UIImage(data: data) else {
throw ImageFetchError.invalidImageData
}
// Scale image for PDF (max 400pt width to keep file size reasonable)
let scaledImage = scaleImage(image, maxWidth: 400)
// Cache it
imageCache.setValue(scaledImage, forKey: url)
return scaledImage
}
/// Batch fetch multiple images in parallel
func fetchImages(from urls: [URL]) async -> [URL: UIImage] {
var results: [URL: UIImage] = [:]
await withTaskGroup(of: (URL, UIImage?).self) { group in
for url in urls {
group.addTask {
do {
let image = try await self.fetchImage(from: url)
return (url, image)
} catch {
return (url, nil)
}
}
}
for await (url, image) in group {
if let image = image {
results[url] = image
}
}
}
return results
}
/// Fetch team logos by team ID
func fetchTeamLogos(teams: [Team]) async -> [String: UIImage] {
let urlToTeam: [URL: String] = Dictionary(
uniqueKeysWithValues: teams.compactMap { team in
guard let logoURL = team.logoURL else { return nil }
return (logoURL, team.id)
}
)
let images = await fetchImages(from: Array(urlToTeam.keys))
var result: [String: UIImage] = [:]
for (url, image) in images {
if let teamId = urlToTeam[url] {
result[teamId] = image
}
}
return result
}
/// Fetch stadium photos by stadium ID
func fetchStadiumPhotos(stadiums: [Stadium]) async -> [String: UIImage] {
let urlToStadium: [URL: String] = Dictionary(
uniqueKeysWithValues: stadiums.compactMap { stadium in
guard let imageURL = stadium.imageURL else { return nil }
return (imageURL, stadium.id)
}
)
let images = await fetchImages(from: Array(urlToStadium.keys))
var result: [String: UIImage] = [:]
for (url, image) in images {
if let stadiumId = urlToStadium[url] {
result[stadiumId] = image
}
}
return result
}
/// Clear the in-memory cache
func clearCache() {
imageCache.removeAll()
}
// MARK: - Private Methods
/// Scale image to max width while maintaining aspect ratio
private func scaleImage(_ image: UIImage, maxWidth: CGFloat) -> UIImage {
let currentWidth = image.size.width
guard currentWidth > maxWidth else { return image }
let scale = maxWidth / currentWidth
let newSize = CGSize(
width: image.size.width * scale,
height: image.size.height * scale
)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}