import Foundation import SwiftUI import UIKit enum ImageFitMode { /// Scale to cover the whole box, cropping whichever axis overflows. /// Default — matches historical behavior for every image in the app. case fill /// Scale so height always matches the box exactly; width follows the /// source's aspect ratio and gets cropped/gapped on the sides. Opt-in /// only — used by the Store Detail cover header. case heightFit } struct CachedRemoteImage: View { let imageURL: String? let ttl: TimeInterval let fitMode: ImageFitMode let placeholder: Placeholder @StateObject var loader = CachedRemoteImageLoader() init( imageURL: String?, ttl: TimeInterval = AppCacheTTL.twoHours, fitMode: ImageFitMode = .fill, @ViewBuilder placeholder: () -> Placeholder ) { self.imageURL = imageURL self.ttl = ttl self.fitMode = fitMode self.placeholder = placeholder() } var body: some View { GeometryReader { geometry in Group { if let uiImage = loader.uiImage { rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size) } else { placeholder } } .frame(width: geometry.size.width, height: geometry.size.height) .clipped() } .onAppear { loader.load(imageURL: imageURL, ttl: ttl) } .onChange(of: imageURL) { _, newValue in loader.load(imageURL: newValue, ttl: ttl) } } @ViewBuilder private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View { switch fitMode { case .fill: image .resizable() .scaledToFill() case .heightFit: let aspect = pixelSize.height > 0 ? pixelSize.width / pixelSize.height : 1 let renderWidth = containerSize.height * aspect image .resizable() .frame(width: renderWidth, height: containerSize.height) .frame(width: containerSize.width, height: containerSize.height) } } } @MainActor final class CachedRemoteImageLoader: ObservableObject { @Published var uiImage: UIImage? private var currentKey: String? private var task: Task? deinit { task?.cancel() } func load(imageURL: String?, ttl: TimeInterval) { let normalized = Self.normalizeImageSource(imageURL) let key = normalized ?? "" guard currentKey != key else { return } currentKey = key task?.cancel() uiImage = nil guard let normalized, normalized.isEmpty == false else { return } let dataCacheKey = Self.dataURLCacheKey(normalized) if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) { uiImage = cachedDataImage return } if let image = Self.imageFromDataURL(normalized) { AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl) uiImage = image return } guard let url = URL(string: normalized) else { return } task = Task { [weak self] in let image = await AppImageCache.shared.image(for: url, ttl: ttl) guard Task.isCancelled == false else { return } await MainActor.run { self?.uiImage = image } } } private static func normalizeImageSource(_ value: String?) -> String? { ImageSourceResolver.resolve(value) } private static func dataURLCacheKey(_ source: String) -> String { let head = String(source.prefix(48)) let tail = String(source.suffix(48)) return "data-image:\(source.count):\(head):\(tail)" } private static func imageFromDataURL(_ source: String) -> PlatformImage? { let lower = source.lowercased() guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil } let header = String(source[..