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>
180 lines
5.2 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|