migration
This commit is contained in:
146
PediFoods/Components/CachedRemoteImage.swift
Normal file
146
PediFoods/Components/CachedRemoteImage.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
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<Placeholder: View>: 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<Void, Never>?
|
||||
|
||||
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[..<commaIndex]).lowercased()
|
||||
guard header.contains(";base64") else { return nil }
|
||||
|
||||
let payloadStart = source.index(after: commaIndex)
|
||||
let payload = String(source[payloadStart...])
|
||||
.replacingOccurrences(of: "\\/", with: "/")
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
|
||||
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
|
||||
return UIImage(data: data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user