ReadKeep/readeck/UI/Components/CachedAsyncImage.swift
Ilyas Hallak b9f8e11782 Refactor offline sync to enforce Clean Architecture
Refactorings:
- Extract HTMLImageEmbedder and HTMLImageExtractor utilities
- Create UseCases for cached data access (GetCachedBookmarksUseCase, GetCachedArticleUseCase)
- Create CreateAnnotationUseCase to remove API dependency from ViewModel
- Simplify CachedAsyncImage by extracting helper methods
- Fix Kingfisher API compatibility (Source types, Result handling)
- Add documentation to OfflineCacheSyncUseCase
- Remove unused TestView from production code

Enforces Clean Architecture:
- ViewModels now only use UseCases, no direct Repository or API access
- All data layer access goes through Domain layer
2025-11-30 19:12:51 +01:00

180 lines
5.4 KiB
Swift

import SwiftUI
import Kingfisher
struct CachedAsyncImage: View {
let url: URL?
let cacheKey: String?
@EnvironmentObject private var appSettings: AppSettings
@State private var isImageCached = false
@State private var hasCheckedCache = false
@State private var cachedImage: UIImage?
init(url: URL?, cacheKey: String? = nil) {
self.url = url
self.cacheKey = cacheKey
}
var body: some View {
if let url {
imageView(for: url)
.task {
await checkCache(for: url)
}
} else {
placeholderImage
}
}
@ViewBuilder
private func imageView(for url: URL) -> some View {
if appSettings.isNetworkConnected {
onlineImageView(url: url)
} else {
offlineImageView(url: url)
}
}
// MARK: - Online Mode
private func onlineImageView(url: URL) -> some View {
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.placeholder { Color.gray.opacity(0.3) }
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
}
// MARK: - Offline Mode
@ViewBuilder
private func offlineImageView(url: URL) -> some View {
if hasCheckedCache && !isImageCached {
placeholderWithWarning
} else if let cachedImage {
cachedImageView(image: cachedImage)
} else {
kingfisherCacheOnlyView(url: url)
}
}
private func cachedImageView(image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.frame(maxWidth: .infinity)
}
private func kingfisherCacheOnlyView(url: URL) -> some View {
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.loadDiskFileSynchronously()
.onlyFromCache(true)
.placeholder { Color.gray.opacity(0.3) }
.onSuccess { _ in
Logger.ui.debug("✅ Loaded image from cache: \(url.absoluteString)")
}
.onFailure { error in
Logger.ui.warning("❌ Failed to load cached image: \(url.absoluteString) - \(error.localizedDescription)")
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
}
private var placeholderImage: some View {
Color.gray.opacity(0.3)
.frame(maxWidth: .infinity)
.overlay(
Image(systemName: "photo")
.foregroundColor(.gray)
.font(.largeTitle)
)
}
private var placeholderWithWarning: some View {
Color.gray.opacity(0.3)
.frame(maxWidth: .infinity)
.overlay(
VStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundColor(.gray)
.font(.title)
Text("Offline - Image not cached")
.font(.caption)
.foregroundColor(.secondary)
}
)
}
// MARK: - Cache Checking
private func checkCache(for url: URL) async {
// Try custom cache key first, then fallback to URL-based cache
if let cacheKey = cacheKey, await tryLoadFromCustomKey(cacheKey) {
return
}
await checkStandardCache(for: url)
}
private func tryLoadFromCustomKey(_ key: String) async -> Bool {
let image = await retrieveImageFromCache(key: key)
await MainActor.run {
if let image {
cachedImage = image
isImageCached = true
Logger.ui.debug("✅ Loaded image from cache using key: \(key)")
} else {
Logger.ui.debug("Image not found with cache key, trying URL-based cache")
}
hasCheckedCache = true
}
return image != nil
}
private func checkStandardCache(for url: URL) async {
let isCached = await isImageInCache(url: url)
await MainActor.run {
isImageCached = isCached
hasCheckedCache = true
if !appSettings.isNetworkConnected {
Logger.ui.debug(isCached
? "✅ Image is cached for offline use: \(url.absoluteString)"
: "❌ Image NOT cached for offline use: \(url.absoluteString)")
}
}
}
private func retrieveImageFromCache(key: String) async -> UIImage? {
await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: key) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image)
case .failure:
continuation.resume(returning: nil)
}
}
}
}
private func isImageInCache(url: URL) async -> Bool {
await withCheckedContinuation { continuation in
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
}
}