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:
Ilyas Hallak 2025-11-28 23:01:20 +01:00
parent c3ac7cc6a8
commit 305b8f733e
9 changed files with 616 additions and 111 deletions

View File

@ -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
}
}
}

View File

@ -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 {

View File

@ -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)")
}
}
}

View File

@ -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)

View File

@ -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()

View File

@ -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)
}

View File

@ -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)")

View File

@ -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)")
}
}
}
}
}

View File

@ -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"/>