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.
This commit is contained in:
parent
c3ac7cc6a8
commit
305b8f733e
@ -89,10 +89,15 @@ extension BookmarkEntity {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reconstruct hero image from cached URL for offline access
|
||||
let heroImage: ImageResource? = self.heroImageURL.flatMap { urlString in
|
||||
ImageResource(src: urlString, height: 0, width: 0)
|
||||
}
|
||||
|
||||
let resources = BookmarkResources(
|
||||
article: nil,
|
||||
icon: nil,
|
||||
image: nil,
|
||||
image: heroImage,
|
||||
log: nil,
|
||||
props: nil,
|
||||
thumbnail: nil
|
||||
@ -177,6 +182,13 @@ private extension BookmarkEntity {
|
||||
self.textDirection = bookmark.textDirection
|
||||
self.type = bookmark.type
|
||||
self.state = Int16(bookmark.state)
|
||||
|
||||
// Save hero image URL for offline access
|
||||
if let heroImageUrl = bookmark.resources.image?.src {
|
||||
self.heroImageURL = heroImageUrl
|
||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
||||
self.heroImageURL = thumbnailUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ final class NetworkMonitorRepository: PNetworkMonitorRepository {
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.readeck.networkmonitor")
|
||||
private let _isConnectedSubject = CurrentValueSubject<Bool, Never>(true)
|
||||
private let _isConnectedSubject: CurrentValueSubject<Bool, Never>
|
||||
private var hasPathConnection = true
|
||||
private var hasRealConnection = true
|
||||
|
||||
@ -38,7 +38,15 @@ final class NetworkMonitorRepository: PNetworkMonitorRepository {
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
// Repository just manages the monitor, doesn't start it automatically
|
||||
// Check current network status synchronously before starting monitor
|
||||
let currentPath = monitor.currentPath
|
||||
let hasInterfaces = currentPath.availableInterfaces.count > 0
|
||||
let initialStatus = currentPath.status == .satisfied && hasInterfaces
|
||||
|
||||
_isConnectedSubject = CurrentValueSubject<Bool, Never>(initialStatus)
|
||||
hasPathConnection = initialStatus
|
||||
|
||||
Logger.network.info("🌐 Initial network status: \(initialStatus ? "Connected" : "Offline")")
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
||||
@ -24,11 +24,38 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
return
|
||||
}
|
||||
|
||||
try await saveBookmarkToCache(bookmark: bookmark, html: html, saveImages: saveImages)
|
||||
|
||||
// First prefetch images into Kingfisher cache
|
||||
if saveImages {
|
||||
await prefetchImagesForBookmark(id: bookmark.id)
|
||||
var imageURLs = extractImageURLsFromHTML(html: html)
|
||||
|
||||
// Add hero/thumbnail image if available and cache it with custom key
|
||||
if let heroImageUrl = bookmark.resources.image?.src {
|
||||
imageURLs.insert(heroImageUrl, at: 0)
|
||||
logger.debug("Added hero image: \(heroImageUrl)")
|
||||
|
||||
// Cache hero image with custom key for offline access
|
||||
if let heroURL = URL(string: heroImageUrl) {
|
||||
await cacheHeroImage(url: heroURL, bookmarkId: bookmark.id)
|
||||
}
|
||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
||||
imageURLs.insert(thumbnailUrl, at: 0)
|
||||
logger.debug("Added thumbnail image: \(thumbnailUrl)")
|
||||
|
||||
// Cache thumbnail with custom key
|
||||
if let thumbURL = URL(string: thumbnailUrl) {
|
||||
await cacheHeroImage(url: thumbURL, bookmarkId: bookmark.id)
|
||||
}
|
||||
}
|
||||
|
||||
let urls = imageURLs.compactMap { URL(string: $0) }
|
||||
await prefetchImagesWithKingfisher(imageURLs: urls)
|
||||
}
|
||||
|
||||
// Then embed images as Base64 in HTML
|
||||
let processedHTML = saveImages ? await embedImagesAsBase64(html: html) : html
|
||||
|
||||
// Save bookmark with embedded images
|
||||
try await saveBookmarkToCache(bookmark: bookmark, html: processedHTML, saveImages: saveImages)
|
||||
}
|
||||
|
||||
func getCachedArticle(id: String) -> String? {
|
||||
@ -63,11 +90,25 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
|
||||
let context = coreDataManager.context
|
||||
return try await context.perform {
|
||||
// First check total bookmarks
|
||||
let allRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||
let totalCount = try? context.count(for: allRequest)
|
||||
self.logger.info("📊 Total bookmarks in Core Data: \(totalCount ?? 0)")
|
||||
|
||||
let entities = try context.fetch(fetchRequest)
|
||||
self.logger.debug("Found \(entities.count) cached bookmarks")
|
||||
self.logger.info("📊 getCachedBookmarks: Found \(entities.count) bookmarks with htmlContent != nil")
|
||||
|
||||
if entities.count > 0 {
|
||||
// Log details of first cached bookmark
|
||||
if let first = entities.first {
|
||||
self.logger.info(" First cached: id=\(first.id ?? "nil"), title=\(first.title ?? "nil"), cachedDate=\(first.cachedDate?.description ?? "nil")")
|
||||
}
|
||||
}
|
||||
|
||||
// Convert entities to Bookmark domain objects using mapper
|
||||
return entities.compactMap { $0.toDomain() }
|
||||
let bookmarks = entities.compactMap { $0.toDomain() }
|
||||
self.logger.info("📊 Successfully mapped \(bookmarks.count) bookmarks to domain objects")
|
||||
return bookmarks
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,6 +148,19 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
fetchRequest.predicate = NSPredicate(format: "htmlContent != nil")
|
||||
|
||||
let context = coreDataManager.context
|
||||
|
||||
// Collect image URLs before clearing
|
||||
let imageURLsToDelete = try await context.perform {
|
||||
let entities = try context.fetch(fetchRequest)
|
||||
return entities.compactMap { entity -> [URL]? in
|
||||
guard let imageURLsString = entity.imageURLs else { return nil }
|
||||
return imageURLsString
|
||||
.split(separator: ",")
|
||||
.compactMap { URL(string: String($0)) }
|
||||
}.flatMap { $0 }
|
||||
}
|
||||
|
||||
// Clear Core Data cache
|
||||
try await context.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
@ -123,9 +177,16 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
self.logger.info("Cleared cache for \(entities.count) articles")
|
||||
}
|
||||
|
||||
// Optional: Also clear Kingfisher cache
|
||||
// KingfisherManager.shared.cache.clearDiskCache()
|
||||
// KingfisherManager.shared.cache.clearMemoryCache()
|
||||
// Clear Kingfisher cache for these images
|
||||
logger.info("Clearing Kingfisher cache for \(imageURLsToDelete.count) images")
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for url in imageURLsToDelete {
|
||||
group.addTask {
|
||||
try? await KingfisherManager.shared.cache.removeImage(forKey: url.cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info("✅ Kingfisher cache cleared for offline images")
|
||||
}
|
||||
|
||||
func cleanupOldestCachedArticles(keepCount: Int) async throws {
|
||||
@ -134,6 +195,23 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: true)]
|
||||
|
||||
let context = coreDataManager.context
|
||||
|
||||
// 1. Collect image URLs from articles that will be deleted
|
||||
let imageURLsToDelete = try await context.perform {
|
||||
let allEntities = try context.fetch(fetchRequest)
|
||||
if allEntities.count > keepCount {
|
||||
let entitiesToDelete = allEntities.prefix(allEntities.count - keepCount)
|
||||
return entitiesToDelete.compactMap { entity -> [URL]? in
|
||||
guard let imageURLsString = entity.imageURLs else { return nil }
|
||||
return imageURLsString
|
||||
.split(separator: ",")
|
||||
.compactMap { URL(string: String($0)) }
|
||||
}.flatMap { $0 }
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 2. Clear Core Data cache
|
||||
try await context.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
@ -154,6 +232,18 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
self.logger.info("Cleaned up \(entitiesToDelete.count) oldest cached articles (keeping \(keepCount))")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Clear Kingfisher cache for deleted images
|
||||
if !imageURLsToDelete.isEmpty {
|
||||
logger.info("Clearing Kingfisher cache for \(imageURLsToDelete.count) images from cleanup")
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for url in imageURLsToDelete {
|
||||
group.addTask {
|
||||
try? await KingfisherManager.shared.cache.removeImage(forKey: url.cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helper Methods
|
||||
@ -166,10 +256,17 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
|
||||
let entity = try self.findOrCreateEntity(for: bookmark.id, in: context)
|
||||
bookmark.updateEntity(entity)
|
||||
self.updateEntityWithCacheData(entity: entity, html: html, saveImages: saveImages)
|
||||
self.updateEntityWithCacheData(entity: entity, bookmark: bookmark, html: html, saveImages: saveImages)
|
||||
|
||||
try context.save()
|
||||
self.logger.info("Cached bookmark \(bookmark.id) with HTML (\(html.utf8.count) bytes)")
|
||||
self.logger.info("💾 Saved bookmark \(bookmark.id) to Core Data with HTML (\(html.utf8.count) bytes)")
|
||||
|
||||
// Verify it was saved
|
||||
let verifyRequest: NSFetchRequest<BookmarkEntity> = BookmarkEntity.fetchRequest()
|
||||
verifyRequest.predicate = NSPredicate(format: "id == %@ AND htmlContent != nil", bookmark.id)
|
||||
if let count = try? context.count(for: verifyRequest) {
|
||||
self.logger.info("✅ Verification: \(count) bookmark(s) with id '\(bookmark.id)' found in Core Data after save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,36 +279,32 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
return existingEntities.first ?? BookmarkEntity(context: context)
|
||||
}
|
||||
|
||||
private func updateEntityWithCacheData(entity: BookmarkEntity, html: String, saveImages: Bool) {
|
||||
private func updateEntityWithCacheData(entity: BookmarkEntity, bookmark: Bookmark, html: String, saveImages: Bool) {
|
||||
entity.htmlContent = html
|
||||
entity.cachedDate = Date()
|
||||
entity.lastAccessDate = Date()
|
||||
entity.cacheSize = Int64(html.utf8.count)
|
||||
|
||||
// Note: imageURLs are now embedded in HTML as Base64, so we don't store them separately
|
||||
// We still track hero/thumbnail URLs for cleanup purposes
|
||||
if saveImages {
|
||||
let imageURLs = extractImageURLsFromHTML(html: html)
|
||||
var imageURLs: [String] = []
|
||||
|
||||
// Add hero/thumbnail image if available
|
||||
if let heroImageUrl = bookmark.resources.image?.src {
|
||||
imageURLs.append(heroImageUrl)
|
||||
logger.debug("Tracking hero image for cleanup: \(heroImageUrl)")
|
||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
||||
imageURLs.append(thumbnailUrl)
|
||||
logger.debug("Tracking thumbnail image for cleanup: \(thumbnailUrl)")
|
||||
}
|
||||
|
||||
if !imageURLs.isEmpty {
|
||||
entity.imageURLs = imageURLs.joined(separator: ",")
|
||||
logger.debug("Found \(imageURLs.count) images for bookmark \(entity.id ?? "unknown")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prefetchImagesForBookmark(id: String) async {
|
||||
guard let entity = try? await getCachedEntity(id: id),
|
||||
let imageURLsString = entity.imageURLs else {
|
||||
return
|
||||
}
|
||||
|
||||
let imageURLs = imageURLsString
|
||||
.split(separator: ",")
|
||||
.compactMap { URL(string: String($0)) }
|
||||
|
||||
if !imageURLs.isEmpty {
|
||||
await prefetchImagesWithKingfisher(imageURLs: imageURLs)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractImageURLsFromHTML(html: String) -> [String] {
|
||||
var imageURLs: [String] = []
|
||||
|
||||
@ -239,23 +332,172 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
return imageURLs
|
||||
}
|
||||
|
||||
private func prefetchImagesWithKingfisher(imageURLs: [URL]) async {
|
||||
guard !imageURLs.isEmpty else { return }
|
||||
private func embedImagesAsBase64(html: String) async -> String {
|
||||
logger.info("🔄 Starting Base64 image embedding for offline HTML")
|
||||
|
||||
logger.info("Starting Kingfisher prefetch for \(imageURLs.count) images")
|
||||
var modifiedHTML = html
|
||||
let imageURLs = extractImageURLsFromHTML(html: html)
|
||||
|
||||
// Use Kingfisher's prefetcher with low priority
|
||||
let prefetcher = ImagePrefetcher(urls: imageURLs) { [weak self] skippedResources, failedResources, completedResources in
|
||||
self?.logger.info("Prefetch completed: \(completedResources.count)/\(imageURLs.count) images cached")
|
||||
if !failedResources.isEmpty {
|
||||
self?.logger.warning("Failed to cache \(failedResources.count) images")
|
||||
logger.info("📊 Found \(imageURLs.count) images to embed")
|
||||
|
||||
var successCount = 0
|
||||
var failedCount = 0
|
||||
|
||||
for (index, imageURL) in imageURLs.enumerated() {
|
||||
logger.debug("Processing image \(index + 1)/\(imageURLs.count): \(imageURL)")
|
||||
|
||||
guard let url = URL(string: imageURL) else {
|
||||
logger.warning("❌ Invalid URL: \(imageURL)")
|
||||
failedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to get image from Kingfisher cache
|
||||
let result = await withCheckedContinuation { (continuation: CheckedContinuation<KFCrossPlatformImage?, Never>) in
|
||||
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
|
||||
switch result {
|
||||
case .success(let cacheResult):
|
||||
if let image = cacheResult.image {
|
||||
continuation.resume(returning: image)
|
||||
} else {
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
case .failure(let error):
|
||||
print("❌ Kingfisher cache retrieval error: \(error)")
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let image = result else {
|
||||
logger.warning("❌ Image not found in Kingfisher cache: \(imageURL)")
|
||||
logger.warning(" Cache key: \(url.cacheKey)")
|
||||
failedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert image to Base64
|
||||
guard let imageData = image.jpegData(compressionQuality: 0.85) else {
|
||||
logger.warning("❌ Failed to convert image to JPEG: \(imageURL)")
|
||||
failedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let base64String = imageData.base64EncodedString()
|
||||
let dataURI = "data:image/jpeg;base64,\(base64String)"
|
||||
|
||||
// Replace URL with Base64 data URI
|
||||
let beforeLength = modifiedHTML.count
|
||||
modifiedHTML = modifiedHTML.replacingOccurrences(of: imageURL, with: dataURI)
|
||||
let afterLength = modifiedHTML.count
|
||||
|
||||
if afterLength > beforeLength {
|
||||
logger.debug("✅ Embedded image \(index + 1) as Base64: \(imageURL)")
|
||||
logger.debug(" Size: \(imageData.count) bytes, Base64: \(base64String.count) chars")
|
||||
logger.debug(" HTML grew by: \(afterLength - beforeLength) chars")
|
||||
successCount += 1
|
||||
} else {
|
||||
logger.warning("⚠️ Image URL found but not replaced in HTML: \(imageURL)")
|
||||
failedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Set download priority to low for background downloads
|
||||
// prefetcher.options = [.downloadPriority(.low)]
|
||||
logger.info("✅ Base64 embedding complete: \(successCount) succeeded, \(failedCount) failed out of \(imageURLs.count) images")
|
||||
logger.info("📈 HTML size: \(html.utf8.count) → \(modifiedHTML.utf8.count) bytes (growth: \(modifiedHTML.utf8.count - html.utf8.count) bytes)")
|
||||
|
||||
prefetcher.start()
|
||||
return modifiedHTML
|
||||
}
|
||||
|
||||
private func prefetchImagesWithKingfisher(imageURLs: [URL]) async {
|
||||
guard !imageURLs.isEmpty else { return }
|
||||
|
||||
logger.info("🔄 Starting Kingfisher prefetch for \(imageURLs.count) images")
|
||||
|
||||
// Log all URLs that will be prefetched
|
||||
for (index, url) in imageURLs.enumerated() {
|
||||
logger.debug("[\(index + 1)/\(imageURLs.count)] Prefetching: \(url.absoluteString)")
|
||||
logger.debug(" Cache key: \(url.cacheKey)")
|
||||
}
|
||||
|
||||
// Configure Kingfisher options for offline caching
|
||||
let options: KingfisherOptionsInfo = [
|
||||
.cacheOriginalImage,
|
||||
.diskCacheExpiration(.never), // Keep images as long as article is cached
|
||||
.backgroundDecode,
|
||||
]
|
||||
|
||||
// Use Kingfisher's prefetcher with offline-friendly options
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
let prefetcher = ImagePrefetcher(
|
||||
urls: imageURLs,
|
||||
options: options,
|
||||
progressBlock: { [weak self] skippedResources, failedResources, completedResources in
|
||||
let progress = completedResources.count + failedResources.count + skippedResources.count
|
||||
self?.logger.debug("Prefetch progress: \(progress)/\(imageURLs.count)")
|
||||
|
||||
// Log failures immediately as they happen
|
||||
if !failedResources.isEmpty {
|
||||
for failure in failedResources {
|
||||
self?.logger.error("❌ Image prefetch failed: \(failure.downloadURL.absoluteString)")
|
||||
}
|
||||
}
|
||||
},
|
||||
completionHandler: { [weak self] skippedResources, failedResources, completedResources in
|
||||
self?.logger.info("✅ Prefetch completed: \(completedResources.count)/\(imageURLs.count) images cached")
|
||||
|
||||
if !failedResources.isEmpty {
|
||||
self?.logger.warning("❌ Failed to cache \(failedResources.count) images:")
|
||||
for resource in failedResources {
|
||||
self?.logger.warning(" - \(resource.downloadURL.absoluteString)")
|
||||
}
|
||||
}
|
||||
|
||||
if !skippedResources.isEmpty {
|
||||
self?.logger.info("⏭️ Skipped \(skippedResources.count) images (already cached):")
|
||||
for resource in skippedResources {
|
||||
self?.logger.debug(" - \(resource.downloadURL.absoluteString)")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify cache after prefetch
|
||||
Task { [weak self] in
|
||||
await self?.verifyPrefetchedImages(imageURLs)
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
)
|
||||
prefetcher.start()
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyPrefetchedImages(_ imageURLs: [URL]) async {
|
||||
logger.info("🔍 Verifying prefetched images in cache...")
|
||||
|
||||
var cachedCount = 0
|
||||
var missingCount = 0
|
||||
|
||||
for url in imageURLs {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isCached {
|
||||
cachedCount += 1
|
||||
logger.debug("✅ Verified in cache: \(url.absoluteString)")
|
||||
} else {
|
||||
missingCount += 1
|
||||
logger.warning("❌ NOT in cache after prefetch: \(url.absoluteString)")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("📊 Cache verification: \(cachedCount) cached, \(missingCount) missing out of \(imageURLs.count) total")
|
||||
}
|
||||
|
||||
private func getCachedEntity(id: String) async throws -> BookmarkEntity? {
|
||||
@ -269,4 +511,48 @@ class OfflineCacheRepository: POfflineCacheRepository {
|
||||
return results.first
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches hero/thumbnail image with a custom key for offline retrieval
|
||||
private func cacheHeroImage(url: URL, bookmarkId: String) async {
|
||||
let cacheKey = "bookmark-\(bookmarkId)-hero"
|
||||
logger.debug("Caching hero image with key: \(cacheKey)")
|
||||
|
||||
// First check if already cached with custom key
|
||||
let isAlreadyCached = await withCheckedContinuation { continuation in
|
||||
ImageCache.default.retrieveImage(forKey: cacheKey) { result in
|
||||
switch result {
|
||||
case .success(let cacheResult):
|
||||
continuation.resume(returning: cacheResult.image != nil)
|
||||
case .failure:
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isAlreadyCached {
|
||||
logger.debug("Hero image already cached with key: \(cacheKey)")
|
||||
return
|
||||
}
|
||||
|
||||
// Download and cache image with custom key
|
||||
let result = await withCheckedContinuation { (continuation: CheckedContinuation<KFCrossPlatformImage?, Never>) in
|
||||
KingfisherManager.shared.retrieveImage(with: url) { result in
|
||||
switch result {
|
||||
case .success(let imageResult):
|
||||
continuation.resume(returning: imageResult.image)
|
||||
case .failure(let error):
|
||||
self.logger.error("Failed to download hero image: \(error.localizedDescription)")
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let image = result {
|
||||
// Store with custom key for offline access
|
||||
try? await ImageCache.default.store(image, forKey: cacheKey)
|
||||
logger.info("✅ Cached hero image with key: \(cacheKey)")
|
||||
} else {
|
||||
logger.warning("❌ Failed to cache hero image for bookmark: \(bookmarkId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -316,14 +316,20 @@ struct BookmarkDetailView2: View {
|
||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
// Background blur for images that don't fill
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
CachedAsyncImage(
|
||||
url: URL(string: viewModel.bookmarkDetail.imageUrl),
|
||||
cacheKey: "bookmark-\(viewModel.bookmarkDetail.id)-hero"
|
||||
)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: width, height: headerHeight)
|
||||
.blur(radius: 30)
|
||||
.clipped()
|
||||
|
||||
// Main image with fit
|
||||
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
|
||||
CachedAsyncImage(
|
||||
url: URL(string: viewModel.bookmarkDetail.imageUrl),
|
||||
cacheKey: "bookmark-\(viewModel.bookmarkDetail.id)-hero"
|
||||
)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: width, height: headerHeight)
|
||||
|
||||
|
||||
@ -144,7 +144,10 @@ struct BookmarkCardView: View {
|
||||
|
||||
private var compactLayoutView: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
CachedAsyncImage(url: imageURL)
|
||||
CachedAsyncImage(
|
||||
url: imageURL,
|
||||
cacheKey: "bookmark-\(bookmark.id)-hero"
|
||||
)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
@ -195,7 +198,10 @@ struct BookmarkCardView: View {
|
||||
private var magazineLayoutView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CachedAsyncImage(url: imageURL)
|
||||
CachedAsyncImage(
|
||||
url: imageURL,
|
||||
cacheKey: "bookmark-\(bookmark.id)-hero"
|
||||
)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 140)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
@ -275,7 +281,10 @@ struct BookmarkCardView: View {
|
||||
private var naturalLayoutView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CachedAsyncImage(url: imageURL)
|
||||
CachedAsyncImage(
|
||||
url: imageURL,
|
||||
cacheKey: "bookmark-\(bookmark.id)-hero"
|
||||
)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: UIScreen.main.bounds.width - 32)
|
||||
.clipped()
|
||||
|
||||
@ -6,7 +6,7 @@ struct BookmarksView: View {
|
||||
|
||||
// MARK: States
|
||||
|
||||
@State private var viewModel: BookmarksViewModel
|
||||
@State private var viewModel = BookmarksViewModel()
|
||||
@State private var showingAddBookmark = false
|
||||
@State private var selectedBookmarkId: String?
|
||||
@State private var showingAddBookmarkFromShare = false
|
||||
@ -24,25 +24,19 @@ struct BookmarksView: View {
|
||||
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||||
|
||||
|
||||
// MARK: Initializer
|
||||
|
||||
init(viewModel: BookmarksViewModel = .init(), state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
|
||||
|
||||
init(state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding<Bookmark?>, tag: String? = nil) {
|
||||
self.state = state
|
||||
self.type = type
|
||||
self._selectedBookmark = selectedBookmark
|
||||
self.tag = tag
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
#if DEBUG
|
||||
// Debug: Network status indicator
|
||||
debugNetworkStatusBanner
|
||||
#endif
|
||||
|
||||
// Offline banner
|
||||
if !appSettings.isNetworkConnected && (viewModel.bookmarks?.bookmarks.isEmpty == false) {
|
||||
offlineBanner
|
||||
@ -81,10 +75,17 @@ struct BookmarksView: View {
|
||||
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
Task {
|
||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||
}
|
||||
.task {
|
||||
// Set appSettings reference
|
||||
viewModel.appSettings = appSettings
|
||||
|
||||
// Wait briefly for initial network status to be set
|
||||
// NetworkMonitor checks status synchronously in init, but the publisher
|
||||
// might not have propagated to appSettings yet
|
||||
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
||||
|
||||
Logger.ui.info("📲 BookmarksView.task - Loading bookmarks, isNetworkConnected: \(appSettings.isNetworkConnected)")
|
||||
await viewModel.loadBookmarks(state: state, type: type, tag: tag)
|
||||
}
|
||||
.onChange(of: showingAddBookmark) { oldValue, newValue in
|
||||
// Refresh bookmarks when sheet is dismissed
|
||||
@ -118,8 +119,12 @@ struct BookmarksView: View {
|
||||
private var shouldShowCenteredState: Bool {
|
||||
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
|
||||
let hasError = viewModel.errorMessage != nil
|
||||
// Only show centered state when empty AND error (not just error)
|
||||
return isEmpty && hasError
|
||||
let isOfflineNonUnread = !appSettings.isNetworkConnected && state != .unread
|
||||
|
||||
// Show centered state when:
|
||||
// 1. Empty AND has error, OR
|
||||
// 2. Offline mode in non-Unread tabs (Archive/Starred/All)
|
||||
return (isEmpty && hasError) || isOfflineNonUnread
|
||||
}
|
||||
|
||||
// MARK: - View Components
|
||||
@ -128,13 +133,15 @@ struct BookmarksView: View {
|
||||
private var centeredStateView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
|
||||
if viewModel.isLoading {
|
||||
|
||||
if !appSettings.isNetworkConnected && state != .unread {
|
||||
offlineUnavailableView
|
||||
} else if viewModel.isLoading {
|
||||
loadingView
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
errorView(message: errorMessage)
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@ -162,24 +169,75 @@ struct BookmarksView: View {
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var offlineUnavailableView: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Icon stack
|
||||
ZStack {
|
||||
Image(systemName: "cloud.slash")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary.opacity(0.3))
|
||||
.offset(x: -8, y: 8)
|
||||
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Offline Mode")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("\(state.displayName) Not Available")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Only unread articles are cached for offline reading")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// Hint to switch to Unread tab
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.left")
|
||||
.font(.caption)
|
||||
Text("Switch to Unread to view cached articles")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.accentColor.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func errorView(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: viewModel.isNetworkError ? "wifi.slash" : "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.orange)
|
||||
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(viewModel.isNetworkError ? "No internet connection" : "Unable to load bookmarks")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
|
||||
Text(viewModel.isNetworkError ? "Please check your internet connection and try again" : message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
|
||||
Button("Try Again") {
|
||||
Task {
|
||||
await viewModel.retryLoading()
|
||||
@ -305,31 +363,6 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var debugNetworkStatusBanner: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: appSettings.isNetworkConnected ? "wifi" : "wifi.slash")
|
||||
.font(.body)
|
||||
.foregroundColor(appSettings.isNetworkConnected ? .green : .red)
|
||||
|
||||
Text("DEBUG: Network \(appSettings.isNetworkConnected ? "Connected ✓" : "Disconnected ✗")")
|
||||
.font(.caption)
|
||||
.foregroundColor(appSettings.isNetworkConnected ? .green : .red)
|
||||
.bold()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(appSettings.isNetworkConnected ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundColor(appSettings.isNetworkConnected ? Color.green : Color.red),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var offlineBanner: some View {
|
||||
HStack(spacing: 12) {
|
||||
@ -379,12 +412,3 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
BookmarksView(
|
||||
viewModel: .init(MockUseCaseFactory()),
|
||||
state: .archived,
|
||||
type: [.article],
|
||||
selectedBookmark: .constant(nil),
|
||||
tag: nil)
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ class BookmarksViewModel {
|
||||
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||
private let offlineCacheRepository: POfflineCacheRepository
|
||||
weak var appSettings: AppSettings?
|
||||
|
||||
var bookmarks: BookmarksPage?
|
||||
var isLoading = false
|
||||
@ -108,7 +109,10 @@ class BookmarksViewModel {
|
||||
|
||||
@MainActor
|
||||
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
|
||||
guard !isUpdating else { return }
|
||||
guard !isUpdating else {
|
||||
Logger.viewModel.debug("⏭️ Skipping loadBookmarks - already updating")
|
||||
return
|
||||
}
|
||||
isUpdating = true
|
||||
defer { isUpdating = false }
|
||||
|
||||
@ -121,6 +125,19 @@ class BookmarksViewModel {
|
||||
offset = 0
|
||||
hasMoreData = true
|
||||
|
||||
// Check if offline BEFORE making API call
|
||||
Logger.viewModel.info("🔍 Checking network status - appSettings: \(appSettings != nil), isNetworkConnected: \(appSettings?.isNetworkConnected ?? false)")
|
||||
if let appSettings, !appSettings.isNetworkConnected {
|
||||
Logger.viewModel.info("📱 Device is offline - loading cached bookmarks")
|
||||
isNetworkError = true
|
||||
errorMessage = "No internet connection"
|
||||
await loadCachedBookmarks()
|
||||
isLoading = false
|
||||
isInitialLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
Logger.viewModel.info("🌐 Device appears online - making API request")
|
||||
do {
|
||||
let newBookmarks = try await getBooksmarksUseCase.execute(
|
||||
state: state,
|
||||
@ -159,8 +176,19 @@ class BookmarksViewModel {
|
||||
|
||||
@MainActor
|
||||
private func loadCachedBookmarks() async {
|
||||
Logger.viewModel.info("📱 loadCachedBookmarks called for state: \(currentState.displayName)")
|
||||
|
||||
// Only load cached bookmarks for "Unread" tab
|
||||
// Other tabs (Archive, Starred, All) should show "offline unavailable" message
|
||||
guard currentState == .unread else {
|
||||
Logger.viewModel.info("📱 Skipping cache load for '\(currentState.displayName)' tab - only Unread is cached")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
Logger.viewModel.info("📱 Fetching cached bookmarks from repository...")
|
||||
let cachedBookmarks = try await offlineCacheRepository.getCachedBookmarks()
|
||||
Logger.viewModel.info("📱 Retrieved \(cachedBookmarks.count) cached bookmarks")
|
||||
|
||||
if !cachedBookmarks.isEmpty {
|
||||
// Create a BookmarksPage from cached bookmarks
|
||||
@ -172,7 +200,9 @@ class BookmarksViewModel {
|
||||
links: nil
|
||||
)
|
||||
hasMoreData = false
|
||||
Logger.viewModel.info("📱 Loaded \(cachedBookmarks.count) cached bookmarks for offline mode")
|
||||
Logger.viewModel.info("✅ Loaded \(cachedBookmarks.count) cached bookmarks for offline mode")
|
||||
} else {
|
||||
Logger.viewModel.warning("⚠️ No cached bookmarks found")
|
||||
}
|
||||
} catch {
|
||||
Logger.viewModel.error("Failed to load cached bookmarks: \(error.localizedDescription)")
|
||||
|
||||
@ -3,14 +3,35 @@ import Kingfisher
|
||||
|
||||
struct CachedAsyncImage: View {
|
||||
let url: URL?
|
||||
|
||||
init(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)
|
||||
}
|
||||
@ -18,9 +39,117 @@ struct CachedAsyncImage: View {
|
||||
.resizable()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Image("placeholder")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
<attribute name="documentType" optional="YES" attributeType="String"/>
|
||||
<attribute name="hasArticle" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="hasDeleted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="heroImageURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="href" optional="YES" attributeType="String"/>
|
||||
<attribute name="htmlContent" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user