This refactor fixes the achievement system by using stable canonical string IDs (e.g., "stadium_mlb_fenway_park") instead of random UUIDs. This ensures stadium mappings for achievements are consistent across app launches and CloudKit sync operations. Changes: - Stadium, Team, Game: id property changed from UUID to String - Trip, TripStop, TripPreferences: updated to use String IDs for games/stadiums - CKModels: removed UUID parsing, use canonical IDs directly - AchievementEngine: now matches against canonical stadium IDs - All test files updated to use String IDs instead of UUID() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
179 lines
5.1 KiB
Swift
179 lines
5.1 KiB
Swift
//
|
|
// RemoteImageService.swift
|
|
// SportsTime
|
|
//
|
|
// Downloads and caches remote images for PDF export (team logos, stadium photos).
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
|
|
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 var imageCache: [URL: UIImage] = [:]
|
|
|
|
// 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[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[url] = scaledImage
|
|
|
|
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))
|
|
}
|
|
}
|
|
}
|