ReadKeep/readeck/UI/Components/CachedAsyncImage.swift
Ilyas Hallak 305b8f733e Implement offline hero image caching with custom cache keys
Major improvements to offline reading functionality:

**Hero Image Offline Support:**
- Add heroImageURL field to BookmarkEntity for persistent storage
- Implement ImageCache-based caching with custom keys (bookmark-{id}-hero)
- Update CachedAsyncImage to support offline loading via cache keys
- Hero images now work offline without URL dependency

**Offline Bookmark Loading:**
- Add proactive offline detection before API calls
- Implement automatic fallback to cached bookmarks when offline
- Fix network status initialization race condition
- Network monitor now checks status synchronously on init

**Core Data Enhancements:**
- Persist hero image URLs in BookmarkEntity.heroImageURL
- Reconstruct ImageResource from cached URLs on offline load
- Add extensive logging for debugging persistence issues

**UI Updates:**
- Update BookmarkDetailView2 to use cache keys for hero images
- Update BookmarkCardView (all 3 layouts) with cache key support
- Improve BookmarksView offline state handling with task-based loading
- Add 50ms delay for network status propagation

**Technical Details:**
- NetworkMonitorRepository: Fix initial status from hardcoded true to actual network check
- BookmarksViewModel: Inject AppSettings for offline detection
- OfflineCacheRepository: Add verification logging for save/load operations
- BookmarkEntityMapper: Sync heroImageURL on save, restore on load

This enables full offline reading with hero images visible in bookmark lists
and detail views, even after app restart.
2025-11-28 23:01:20 +01:00

156 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 {
// Online mode: Normal behavior with caching
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.placeholder {
Color.gray.opacity(0.3)
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
} else {
// Offline mode: Only load from cache
if hasCheckedCache && !isImageCached {
// Image not in cache - show placeholder
placeholderWithWarning
} else if let cachedImage {
// Show cached image loaded via custom key
Image(uiImage: cachedImage)
.resizable()
.frame(maxWidth: .infinity)
} else {
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)
}
)
}
private func checkCache(for url: URL) async {
// If we have a custom cache key, try to load from cache using that key first
if let cacheKey = cacheKey {
let result = await withCheckedContinuation { (continuation: CheckedContinuation<UIImage?, Never>) in
ImageCache.default.retrieveImage(forKey: cacheKey) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image)
case .failure:
continuation.resume(returning: nil)
}
}
}
await MainActor.run {
if let image = result {
cachedImage = image
isImageCached = true
Logger.ui.debug("✅ Loaded image from cache using key: \(cacheKey)")
} else {
// Fallback to URL-based cache check
Logger.ui.debug("Image not found with cache key, trying URL-based cache")
}
hasCheckedCache = true
}
// If we found the image with cache key, we're done
if cachedImage != nil {
return
}
}
// Fallback: Check standard Kingfisher cache using URL
let isCached = 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)
}
}
}
await MainActor.run {
isImageCached = isCached
hasCheckedCache = true
if !appSettings.isNetworkConnected {
if isCached {
Logger.ui.debug("✅ Image is cached for offline use: \(url.absoluteString)")
} else {
Logger.ui.warning("❌ Image NOT cached for offline use: \(url.absoluteString)")
}
}
}
}
}