From 305b8f733e5a4815476ec6ec219d0f84f0a5ee58 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Fri, 28 Nov 2025 23:01:20 +0100 Subject: [PATCH] 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. --- .../Data/Mappers/BookmarkEntityMapper.swift | 14 +- .../Repository/NetworkMonitorRepository.swift | 12 +- .../Repository/OfflineCacheRepository.swift | 364 ++++++++++++++++-- .../BookmarkDetail/BookmarkDetailView2.swift | 10 +- readeck/UI/Bookmarks/BookmarkCardView.swift | 15 +- readeck/UI/Bookmarks/BookmarksView.swift | 136 ++++--- readeck/UI/Bookmarks/BookmarksViewModel.swift | 34 +- readeck/UI/Components/CachedAsyncImage.swift | 141 ++++++- .../readeck.xcdatamodel/contents | 1 + 9 files changed, 616 insertions(+), 111 deletions(-) diff --git a/readeck/Data/Mappers/BookmarkEntityMapper.swift b/readeck/Data/Mappers/BookmarkEntityMapper.swift index 33f0a4e..0c64378 100644 --- a/readeck/Data/Mappers/BookmarkEntityMapper.swift +++ b/readeck/Data/Mappers/BookmarkEntityMapper.swift @@ -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 + } } } diff --git a/readeck/Data/Repository/NetworkMonitorRepository.swift b/readeck/Data/Repository/NetworkMonitorRepository.swift index 315e400..91d66e7 100644 --- a/readeck/Data/Repository/NetworkMonitorRepository.swift +++ b/readeck/Data/Repository/NetworkMonitorRepository.swift @@ -27,7 +27,7 @@ final class NetworkMonitorRepository: PNetworkMonitorRepository { private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "com.readeck.networkmonitor") - private let _isConnectedSubject = CurrentValueSubject(true) + private let _isConnectedSubject: CurrentValueSubject 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(initialStatus) + hasPathConnection = initialStatus + + Logger.network.info("🌐 Initial network status: \(initialStatus ? "Connected" : "Offline")") } deinit { diff --git a/readeck/Data/Repository/OfflineCacheRepository.swift b/readeck/Data/Repository/OfflineCacheRepository.swift index 93d38f4..709da47 100644 --- a/readeck/Data/Repository/OfflineCacheRepository.swift +++ b/readeck/Data/Repository/OfflineCacheRepository.swift @@ -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.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.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) 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) 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) 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)") + } + } } diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift index a3eae94..d197416 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView2.swift @@ -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) diff --git a/readeck/UI/Bookmarks/BookmarkCardView.swift b/readeck/UI/Bookmarks/BookmarkCardView.swift index 7c2303f..e7e973c 100644 --- a/readeck/UI/Bookmarks/BookmarkCardView.swift +++ b/readeck/UI/Bookmarks/BookmarkCardView.swift @@ -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() diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index dfc12b3..330f067 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -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, tag: String? = nil) { + + init(state: BookmarkState, type: [BookmarkType], selectedBookmark: Binding, 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) -} diff --git a/readeck/UI/Bookmarks/BookmarksViewModel.swift b/readeck/UI/Bookmarks/BookmarksViewModel.swift index 2e21209..81aba20 100644 --- a/readeck/UI/Bookmarks/BookmarksViewModel.swift +++ b/readeck/UI/Bookmarks/BookmarksViewModel.swift @@ -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)") diff --git a/readeck/UI/Components/CachedAsyncImage.swift b/readeck/UI/Components/CachedAsyncImage.swift index 3d287d1..c3f2bf4 100644 --- a/readeck/UI/Components/CachedAsyncImage.swift +++ b/readeck/UI/Components/CachedAsyncImage.swift @@ -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) 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)") + } + } } } } diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index 8034741..ad900e5 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -15,6 +15,7 @@ +