Payments
This commit is contained in:
162
pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift
Normal file
162
pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct CachedRemoteImage<Placeholder: View>: View {
|
||||
let imageURL: String?
|
||||
let ttl: TimeInterval
|
||||
let placeholder: Placeholder
|
||||
|
||||
@StateObject var loader = CachedRemoteImageLoader()
|
||||
|
||||
init(
|
||||
imageURL: String?,
|
||||
ttl: TimeInterval = 6 * 60 * 60,
|
||||
@ViewBuilder placeholder: () -> Placeholder
|
||||
) {
|
||||
self.imageURL = imageURL
|
||||
self.ttl = ttl
|
||||
self.placeholder = placeholder()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if canImport(UIKit)
|
||||
if let uiImage = loader.uiImage {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
#elseif canImport(AppKit)
|
||||
if let nsImage = loader.nsImage {
|
||||
Image(nsImage: nsImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
#else
|
||||
if let imageURL,
|
||||
let url = URL(string: imageURL) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
loader.load(imageURL: imageURL, ttl: ttl)
|
||||
}
|
||||
.onChange(of: imageURL) { _, newValue in
|
||||
loader.load(imageURL: newValue, ttl: ttl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CachedRemoteImageLoader: ObservableObject {
|
||||
#if canImport(UIKit)
|
||||
@Published var uiImage: UIImage?
|
||||
#elseif canImport(AppKit)
|
||||
@Published var nsImage: NSImage?
|
||||
#endif
|
||||
|
||||
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()
|
||||
|
||||
#if canImport(UIKit)
|
||||
uiImage = nil
|
||||
#elseif canImport(AppKit)
|
||||
nsImage = nil
|
||||
#endif
|
||||
|
||||
guard let normalized, normalized.isEmpty == false else { return }
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
if let image = Self.imageFromDataURL(normalized) {
|
||||
#if canImport(UIKit)
|
||||
uiImage = image
|
||||
#elseif canImport(AppKit)
|
||||
nsImage = image
|
||||
#endif
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
guard let url = URL(string: normalized) else { return }
|
||||
|
||||
task = Task { [weak self] in
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
let image = await AppImageCache.shared.image(for: url, ttl: ttl)
|
||||
guard Task.isCancelled == false else { return }
|
||||
await MainActor.run {
|
||||
#if canImport(UIKit)
|
||||
self?.uiImage = image
|
||||
#elseif canImport(AppKit)
|
||||
self?.nsImage = image
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeImageSource(_ value: String?) -> String? {
|
||||
guard var normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
normalized.isEmpty == false else { return nil }
|
||||
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
|
||||
return normalized
|
||||
}
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
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 }
|
||||
#if canImport(UIKit)
|
||||
return UIImage(data: data)
|
||||
#elseif canImport(AppKit)
|
||||
return NSImage(data: data)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -37,22 +37,28 @@ struct FeaturedStoreCard: View {
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color(hex: "#F5B335"))
|
||||
Text(String(format: "%.1f", store.rating))
|
||||
.font(.caption)
|
||||
if store.isOpen {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color(hex: "#F5B335"))
|
||||
Text(String(format: "%.1f", store.rating))
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("(\(store.reviews))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text("·")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(store.distance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
} else {
|
||||
Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("(\(store.reviews))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text("·")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(store.distance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
Text(store.category)
|
||||
@@ -62,6 +68,8 @@ struct FeaturedStoreCard: View {
|
||||
.padding(14)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
.saturation(store.isOpen ? 1 : 0)
|
||||
.opacity(store.isOpen ? 1 : 0.9)
|
||||
}
|
||||
|
||||
private var mediaBlock: some View {
|
||||
@@ -77,23 +85,7 @@ struct FeaturedStoreCard: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaImage: some View {
|
||||
if let imageURL = store.imageURL,
|
||||
let url = URL(string: imageURL) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
storeIconPlaceholder
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
} else {
|
||||
storeIconPlaceholder
|
||||
}
|
||||
AsyncStoreImage(imageURL: store.imageURL)
|
||||
}
|
||||
|
||||
private var storeIconPlaceholder: some View {
|
||||
@@ -130,6 +122,8 @@ struct FeaturedStoreCardModel: Identifiable {
|
||||
let imageURL: String?
|
||||
let logoURL: String?
|
||||
let coverURL: String?
|
||||
let isOpen: Bool
|
||||
let statusLabel: String?
|
||||
}
|
||||
|
||||
struct SpecialOfferCard: View {
|
||||
|
||||
Reference in New Issue
Block a user