Refactor offline sync to enforce Clean Architecture

Refactorings:
- Extract HTMLImageEmbedder and HTMLImageExtractor utilities
- Create UseCases for cached data access (GetCachedBookmarksUseCase, GetCachedArticleUseCase)
- Create CreateAnnotationUseCase to remove API dependency from ViewModel
- Simplify CachedAsyncImage by extracting helper methods
- Fix Kingfisher API compatibility (Source types, Result handling)
- Add documentation to OfflineCacheSyncUseCase
- Remove unused TestView from production code

Enforces Clean Architecture:
- ViewModels now only use UseCases, no direct Repository or API access
- All data layer access goes through Domain layer
This commit is contained in:
Ilyas Hallak 2025-11-30 19:12:51 +01:00
parent 305b8f733e
commit b9f8e11782
12 changed files with 716 additions and 171 deletions

View File

@ -0,0 +1,107 @@
//
// HTMLImageEmbedder.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
import Kingfisher
/// Utility for embedding images as Base64 data URIs in HTML
struct HTMLImageEmbedder {
private let imageExtractor = HTMLImageExtractor()
/// Embeds all images in HTML as Base64 data URIs for offline viewing
/// - Parameter html: The HTML string containing image tags
/// - Returns: Modified HTML with images embedded as Base64
func embedBase64Images(in html: String) async -> String {
Logger.sync.info("🔄 Starting Base64 image embedding for offline HTML")
var modifiedHTML = html
let imageURLs = imageExtractor.extract(from: html)
Logger.sync.info("📊 Found \(imageURLs.count) images to embed")
var stats = EmbedStatistics()
for (index, imageURL) in imageURLs.enumerated() {
Logger.sync.debug("Processing image \(index + 1)/\(imageURLs.count): \(imageURL)")
guard let url = URL(string: imageURL) else {
Logger.sync.warning("❌ Invalid URL: \(imageURL)")
stats.failedCount += 1
continue
}
// Try to get image from Kingfisher cache
guard let image = await retrieveImageFromCache(url: url) else {
Logger.sync.warning("❌ Image not found in cache: \(imageURL)")
stats.failedCount += 1
continue
}
// Convert to Base64 and embed
if let base64DataURI = convertToBase64DataURI(image: image) {
let beforeLength = modifiedHTML.count
modifiedHTML = modifiedHTML.replacingOccurrences(of: imageURL, with: base64DataURI)
let afterLength = modifiedHTML.count
if afterLength > beforeLength {
Logger.sync.debug("✅ Embedded image \(index + 1) as Base64")
stats.successCount += 1
} else {
Logger.sync.warning("⚠️ Image URL found but not replaced in HTML: \(imageURL)")
stats.failedCount += 1
}
} else {
Logger.sync.warning("❌ Failed to convert image to Base64: \(imageURL)")
stats.failedCount += 1
}
}
logEmbedResults(stats: stats, originalSize: html.utf8.count, finalSize: modifiedHTML.utf8.count)
return modifiedHTML
}
// MARK: - Private Helper Methods
private func retrieveImageFromCache(url: URL) async -> KFCrossPlatformImage? {
await withCheckedContinuation { continuation in
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image)
case .failure(let error):
Logger.sync.error("❌ Kingfisher cache retrieval error: \(error.localizedDescription)")
continuation.resume(returning: nil)
}
}
}
}
private func convertToBase64DataURI(image: KFCrossPlatformImage) -> String? {
guard let imageData = image.jpegData(compressionQuality: 0.85) else {
return nil
}
let base64String = imageData.base64EncodedString()
return "data:image/jpeg;base64,\(base64String)"
}
private func logEmbedResults(stats: EmbedStatistics, originalSize: Int, finalSize: Int) {
let total = stats.successCount + stats.failedCount
let growth = finalSize - originalSize
Logger.sync.info("✅ Base64 embedding complete: \(stats.successCount) succeeded, \(stats.failedCount) failed out of \(total) images")
Logger.sync.info("📈 HTML size: \(originalSize)\(finalSize) bytes (growth: \(growth) bytes)")
}
}
// MARK: - Helper Types
private struct EmbedStatistics {
var successCount = 0
var failedCount = 0
}

View File

@ -0,0 +1,63 @@
//
// HTMLImageExtractor.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
/// Utility for extracting image URLs from HTML content
struct HTMLImageExtractor {
/// Extracts all image URLs from HTML using regex
/// - Parameter html: The HTML string to parse
/// - Returns: Array of absolute image URLs (http/https only)
func extract(from html: String) -> [String] {
var imageURLs: [String] = []
// Simple regex pattern for img tags
let pattern = #"<img[^>]+src="([^"]+)""#
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
return imageURLs
}
let nsString = html as NSString
let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))
for result in results {
if result.numberOfRanges >= 2 {
let urlRange = result.range(at: 1)
if let url = nsString.substring(with: urlRange) as String?,
url.hasPrefix("http") { // Only include absolute URLs
imageURLs.append(url)
}
}
}
Logger.sync.debug("Extracted \(imageURLs.count) image URLs from HTML")
return imageURLs
}
/// Extracts image URLs from HTML and optionally prepends hero/thumbnail image
/// - Parameters:
/// - html: The HTML string to parse
/// - heroImageURL: Optional hero image URL to prepend
/// - thumbnailURL: Optional thumbnail URL to prepend if no hero image
/// - Returns: Array of image URLs with hero/thumbnail first if provided
func extract(from html: String, heroImageURL: String? = nil, thumbnailURL: String? = nil) -> [String] {
var imageURLs = extract(from: html)
// Prepend hero or thumbnail image if available
if let heroURL = heroImageURL {
imageURLs.insert(heroURL, at: 0)
Logger.sync.debug("Added hero image: \(heroURL)")
} else if let thumbURL = thumbnailURL {
imageURLs.insert(thumbURL, at: 0)
Logger.sync.debug("Added thumbnail image: \(thumbURL)")
}
return imageURLs
}
}

View File

@ -0,0 +1,192 @@
//
// KingfisherImagePrefetcher.swift
// readeck
//
// Created by Claude on 30.11.25.
//
import Foundation
import Kingfisher
/// Wrapper around Kingfisher for prefetching and caching images for offline use
class KingfisherImagePrefetcher {
// MARK: - Public Methods
/// Prefetches images and stores them in Kingfisher cache for offline access
/// - Parameter urls: Array of image URLs to prefetch
func prefetchImages(urls: [URL]) async {
guard !urls.isEmpty else { return }
Logger.sync.info("🔄 Starting Kingfisher prefetch for \(urls.count) images")
logPrefetchURLs(urls)
let options = buildOfflineCachingOptions()
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
let prefetcher = ImagePrefetcher(
urls: urls,
options: options,
progressBlock: { [weak self] skippedResources, failedResources, completedResources in
self?.logPrefetchProgress(
total: urls.count,
completed: completedResources.count,
failed: failedResources.count,
skipped: skippedResources.count
)
},
completionHandler: { [weak self] skippedResources, failedResources, completedResources in
self?.logPrefetchCompletion(
total: urls.count,
completed: completedResources.count,
failed: failedResources.count,
skipped: skippedResources.count
)
// Verify cache after prefetch
Task {
await self?.verifyPrefetchedImages(urls)
continuation.resume()
}
}
)
prefetcher.start()
}
}
/// Caches an image with a custom key for offline retrieval
/// - Parameters:
/// - url: The image URL to download
/// - key: Custom cache key
func cacheImageWithCustomKey(url: URL, key: String) async {
Logger.sync.debug("Caching image with custom key: \(key)")
// Check if already cached
if await isImageCached(forKey: key) {
Logger.sync.debug("Image already cached with key: \(key)")
return
}
// Download and cache with custom key
let image = await downloadImage(from: url)
if let image = image {
try? await ImageCache.default.store(image, forKey: key)
Logger.sync.info("✅ Cached image with custom key: \(key)")
} else {
Logger.sync.warning("❌ Failed to cache image with key: \(key)")
}
}
/// Clears cached images from Kingfisher cache
/// - Parameter urls: Array of image URLs to clear
func clearCachedImages(urls: [URL]) async {
guard !urls.isEmpty else { return }
Logger.sync.info("Clearing Kingfisher cache for \(urls.count) images")
await withTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask {
try? await KingfisherManager.shared.cache.removeImage(forKey: url.cacheKey)
}
}
}
Logger.sync.info("✅ Kingfisher cache cleared for \(urls.count) images")
}
/// Verifies that images are present in cache
/// - Parameter urls: Array of URLs to verify
func verifyPrefetchedImages(_ urls: [URL]) async {
Logger.sync.info("🔍 Verifying prefetched images in cache...")
var cachedCount = 0
var missingCount = 0
for url in urls {
let isCached = await isImageCached(forKey: url.cacheKey)
if isCached {
cachedCount += 1
Logger.sync.debug("✅ Verified in cache: \(url.absoluteString)")
} else {
missingCount += 1
Logger.sync.warning("❌ NOT in cache after prefetch: \(url.absoluteString)")
}
}
Logger.sync.info("📊 Cache verification: \(cachedCount) cached, \(missingCount) missing out of \(urls.count) total")
}
// MARK: - Private Helper Methods
private func buildOfflineCachingOptions() -> KingfisherOptionsInfo {
[
.cacheOriginalImage,
.diskCacheExpiration(.never), // Keep images as long as article is cached
.backgroundDecode,
]
}
private func logPrefetchURLs(_ urls: [URL]) {
for (index, url) in urls.enumerated() {
Logger.sync.debug("[\(index + 1)/\(urls.count)] Prefetching: \(url.absoluteString)")
Logger.sync.debug(" Cache key: \(url.cacheKey)")
}
}
private func logPrefetchProgress(
total: Int,
completed: Int,
failed: Int,
skipped: Int
) {
let progress = completed + failed + skipped
Logger.sync.debug("Prefetch progress: \(progress)/\(total) - completed: \(completed), failed: \(failed), skipped: \(skipped)")
}
private func logPrefetchCompletion(
total: Int,
completed: Int,
failed: Int,
skipped: Int
) {
Logger.sync.info("✅ Prefetch completed: \(completed)/\(total) images cached")
if failed > 0 {
Logger.sync.warning("❌ Failed to cache \(failed) images")
}
if skipped > 0 {
Logger.sync.info("⏭️ Skipped \(skipped) images (already cached)")
}
}
private func isImageCached(forKey key: String) async -> Bool {
await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: key) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
}
private func downloadImage(from url: URL) async -> KFCrossPlatformImage? {
await withCheckedContinuation { continuation in
KingfisherManager.shared.retrieveImage(with: url) { result in
switch result {
case .success(let imageResult):
continuation.resume(returning: imageResult.image)
case .failure(let error):
Logger.sync.error("Failed to download image: \(error.localizedDescription)")
continuation.resume(returning: nil)
}
}
}
}
}

View File

@ -0,0 +1,45 @@
//
// CreateAnnotationUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
protocol PCreateAnnotationUseCase {
func execute(
bookmarkId: String,
color: String,
startOffset: Int,
endOffset: Int,
startSelector: String,
endSelector: String
) async throws -> Annotation
}
class CreateAnnotationUseCase: PCreateAnnotationUseCase {
private let repository: PAnnotationsRepository
init(repository: PAnnotationsRepository) {
self.repository = repository
}
func execute(
bookmarkId: String,
color: String,
startOffset: Int,
endOffset: Int,
startSelector: String,
endSelector: String
) async throws -> Annotation {
return try await repository.createAnnotation(
bookmarkId: bookmarkId,
color: color,
startOffset: startOffset,
endOffset: endOffset,
startSelector: startSelector,
endSelector: endSelector
)
}
}

View File

@ -0,0 +1,24 @@
//
// GetCachedArticleUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
protocol PGetCachedArticleUseCase {
func execute(id: String) -> String?
}
class GetCachedArticleUseCase: PGetCachedArticleUseCase {
private let offlineCacheRepository: POfflineCacheRepository
init(offlineCacheRepository: POfflineCacheRepository) {
self.offlineCacheRepository = offlineCacheRepository
}
func execute(id: String) -> String? {
return offlineCacheRepository.getCachedArticle(id: id)
}
}

View File

@ -0,0 +1,24 @@
//
// GetCachedBookmarksUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
protocol PGetCachedBookmarksUseCase {
func execute() async throws -> [Bookmark]
}
class GetCachedBookmarksUseCase: PGetCachedBookmarksUseCase {
private let offlineCacheRepository: POfflineCacheRepository
init(offlineCacheRepository: POfflineCacheRepository) {
self.offlineCacheRepository = offlineCacheRepository
}
func execute() async throws -> [Bookmark] {
return try await offlineCacheRepository.getCachedBookmarks()
}
}

View File

@ -10,6 +10,8 @@ import Combine
// MARK: - Protocol
/// Use case for syncing articles for offline reading
/// Handles downloading article content and images based on user settings
protocol POfflineCacheSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> { get }
var syncProgress: AnyPublisher<String?, Never> { get }
@ -21,6 +23,11 @@ protocol POfflineCacheSyncUseCase {
// MARK: - Implementation
/// Orchestrates offline article caching with retry logic and progress reporting
/// - Downloads unread bookmarks based on user settings
/// - Prefetches images if enabled
/// - Implements retry logic for temporary server errors (502, 503, 504)
/// - Cleans up old cached articles (FIFO) to respect maxArticles limit
final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Dependencies
@ -56,6 +63,11 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Public Methods
/// Syncs offline articles based on provided settings
/// - Fetches unread bookmarks from API
/// - Caches article HTML and optionally images
/// - Implements retry logic for temporary failures
/// - Updates last sync date in settings
@MainActor
func syncOfflineArticles(settings: OfflineSettings) async {
guard settings.enabled else {
@ -83,6 +95,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
var successCount = 0
var skippedCount = 0
var errorCount = 0
var retryCount = 0
// Process each bookmark
for (index, bookmark) in bookmarks.enumerated() {
@ -101,29 +114,48 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
_syncProgressSubject.send("📥 Article \(progress)\(imagesSuffix)...")
Logger.sync.info("📥 Caching '\(bookmark.title)'")
do {
// Fetch article HTML from API
let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id)
// Retry logic for temporary server errors
var lastError: Error?
let maxRetries = 2
// Cache with metadata
try await offlineCacheRepository.cacheBookmarkWithMetadata(
bookmark: bookmark,
html: html,
saveImages: settings.saveImages
)
for attempt in 0...maxRetries {
do {
if attempt > 0 {
let delay = Double(attempt) * 2.0 // 2s, 4s backoff
Logger.sync.info("⏳ Retry \(attempt)/\(maxRetries) after \(delay)s delay...")
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
retryCount += 1
}
successCount += 1
Logger.sync.info("✅ Cached '\(bookmark.title)'")
} catch {
errorCount += 1
// Fetch article HTML from API
let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id)
// Detailed error logging
if let urlError = error as? URLError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Network error: \(urlError.code.rawValue) (\(urlError.localizedDescription))")
} else if let decodingError = error as? DecodingError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Decoding error: \(decodingError)")
} else {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Error: \(error.localizedDescription) (Type: \(type(of: error)))")
// Cache with metadata
try await offlineCacheRepository.cacheBookmarkWithMetadata(
bookmark: bookmark,
html: html,
saveImages: settings.saveImages
)
successCount += 1
Logger.sync.info("✅ Cached '\(bookmark.title)'\(attempt > 0 ? " (after \(attempt) retries)" : "")")
lastError = nil
break // Success - exit retry loop
} catch {
lastError = error
// Check if error is retryable
let shouldRetry = isRetryableError(error)
if !shouldRetry || attempt == maxRetries {
// Log final error
logCacheError(error: error, bookmark: bookmark, attempt: attempt)
errorCount += 1
break // Give up
} else {
Logger.sync.warning("⚠️ Temporary error, will retry: \(error.localizedDescription)")
}
}
}
}
@ -137,7 +169,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
try await settingsRepository.saveOfflineSettings(updatedSettings)
// Final status
let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)"
let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)\(retryCount > 0 ? ", Retries: \(retryCount)" : "")"
Logger.sync.info(statusMessage)
_syncProgressSubject.send(statusMessage)
@ -164,4 +196,52 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
func getCacheSize() -> String {
offlineCacheRepository.getCacheSize()
}
// MARK: - Private Helper Methods
/// Determines if an error is temporary and should be retried
/// - Retries on: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
/// - Retries on: Network timeouts and connection losses
private func isRetryableError(_ error: Error) -> Bool {
// Retry on temporary server errors
if let apiError = error as? APIError {
switch apiError {
case .serverError(let statusCode):
// Retry on: 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
return statusCode == 502 || statusCode == 503 || statusCode == 504
case .invalidURL, .invalidResponse:
return false // Don't retry on permanent errors
}
}
// Retry on network timeouts
if let urlError = error as? URLError {
return urlError.code == .timedOut || urlError.code == .networkConnectionLost
}
return false
}
private func logCacheError(error: Error, bookmark: Bookmark, attempt: Int) {
let retryInfo = attempt > 0 ? " (after \(attempt) failed attempts)" : ""
if let urlError = error as? URLError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Network error: \(urlError.code.rawValue) (\(urlError.localizedDescription))")
} else if let decodingError = error as? DecodingError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Decoding error: \(decodingError)")
} else if let apiError = error as? APIError {
switch apiError {
case .invalidURL:
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Invalid URL for bookmark ID '\(bookmark.id)'")
case .invalidResponse:
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Invalid server response (nicht 200 OK)")
case .serverError(let statusCode):
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - APIError: Server error HTTP \(statusCode)")
}
Logger.sync.error(" Bookmark ID: \(bookmark.id)")
Logger.sync.error(" URL: \(bookmark.url)")
} else {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)'\(retryInfo) - Error: \(error.localizedDescription) (Type: \(type(of: error)))")
}
}
}

View File

@ -8,8 +8,8 @@ class BookmarkDetailViewModel {
private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
private let api: PAPI
private let offlineCacheRepository: POfflineCacheRepository
private let getCachedArticleUseCase: PGetCachedArticleUseCase
private let createAnnotationUseCase: PCreateAnnotationUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -32,9 +32,9 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.api = API()
self.getCachedArticleUseCase = factory.makeGetCachedArticleUseCase()
self.createAnnotationUseCase = factory.makeCreateAnnotationUseCase()
self.factory = factory
self.offlineCacheRepository = OfflineCacheRepository()
readProgressSubject
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
@ -75,25 +75,38 @@ class BookmarkDetailViewModel {
isLoadingArticle = true
// First, try to load from cache
if let cachedHTML = offlineCacheRepository.getCachedArticle(id: id) {
if let cachedHTML = getCachedArticleUseCase.execute(id: id) {
articleContent = cachedHTML
processArticleContent()
isLoadingArticle = false
Logger.viewModel.info("📱 Loaded article \(id) from cache")
Logger.viewModel.info("📱 Loaded article \(id) from cache (\(cachedHTML.utf8.count) bytes)")
// Debug: Check for Base64 images
let base64Count = countOccurrences(in: cachedHTML, of: "data:image/")
let httpCount = countOccurrences(in: cachedHTML, of: "src=\"http")
Logger.viewModel.info(" Images in cached HTML: \(base64Count) Base64, \(httpCount) HTTP")
return
}
// If not cached, fetch from server
Logger.viewModel.info("📡 Fetching article \(id) from server (not in cache)")
do {
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
processArticleContent()
Logger.viewModel.info("✅ Fetched article from server (\(articleContent.utf8.count) bytes)")
} catch {
errorMessage = "Error loading article"
Logger.viewModel.error("❌ Failed to load article: \(error.localizedDescription)")
}
isLoadingArticle = false
}
private func countOccurrences(in text: String, of substring: String) -> Int {
return text.components(separatedBy: substring).count - 1
}
private func processArticleContent() {
let paragraphs = articleContent
.components(separatedBy: .newlines)
@ -160,7 +173,7 @@ class BookmarkDetailViewModel {
@MainActor
func createAnnotation(bookmarkId: String, color: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async {
do {
let annotation = try await api.createAnnotation(
let annotation = try await createAnnotationUseCase.execute(
bookmarkId: bookmarkId,
color: color,
startOffset: startOffset,
@ -168,9 +181,9 @@ class BookmarkDetailViewModel {
startSelector: startSelector,
endSelector: endSelector
)
print("✅ Annotation created: \(annotation.id)")
Logger.viewModel.info("✅ Annotation created: \(annotation.id)")
} catch {
print("❌ Failed to create annotation: \(error)")
Logger.viewModel.error("❌ Failed to create annotation: \(error.localizedDescription)")
errorMessage = "Error creating annotation"
}
}

View File

@ -8,7 +8,7 @@ class BookmarksViewModel {
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let offlineCacheRepository: POfflineCacheRepository
private let getCachedBookmarksUseCase: PGetCachedBookmarksUseCase
weak var appSettings: AppSettings?
var bookmarks: BookmarksPage?
@ -48,7 +48,7 @@ class BookmarksViewModel {
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
offlineCacheRepository = OfflineCacheRepository()
getCachedBookmarksUseCase = factory.makeGetCachedBookmarksUseCase()
setupNotificationObserver()
@ -186,8 +186,8 @@ class BookmarksViewModel {
}
do {
Logger.viewModel.info("📱 Fetching cached bookmarks from repository...")
let cachedBookmarks = try await offlineCacheRepository.getCachedBookmarks()
Logger.viewModel.info("📱 Fetching cached bookmarks from use case...")
let cachedBookmarks = try await getCachedBookmarksUseCase.execute()
Logger.viewModel.info("📱 Retrieved \(cachedBookmarks.count) cached bookmarks")
if !cachedBookmarks.isEmpty {

View File

@ -28,48 +28,61 @@ struct CachedAsyncImage: View {
@ViewBuilder
private func imageView(for url: URL) -> some View {
if appSettings.isNetworkConnected {
// Online mode: Normal behavior with caching
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.placeholder {
Color.gray.opacity(0.3)
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
onlineImageView(url: url)
} else {
// Offline mode: Only load from cache
if hasCheckedCache && !isImageCached {
// Image not in cache - show placeholder
placeholderWithWarning
} else if let cachedImage {
// Show cached image loaded via custom key
Image(uiImage: cachedImage)
.resizable()
.frame(maxWidth: .infinity)
} else {
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.loadDiskFileSynchronously()
.onlyFromCache(true)
.placeholder {
Color.gray.opacity(0.3)
}
.onSuccess { _ in
Logger.ui.debug("✅ Loaded image from cache: \(url.absoluteString)")
}
.onFailure { error in
Logger.ui.warning("❌ Failed to load cached image: \(url.absoluteString) - \(error.localizedDescription)")
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
}
offlineImageView(url: url)
}
}
// MARK: - Online Mode
private func onlineImageView(url: URL) -> some View {
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.placeholder { Color.gray.opacity(0.3) }
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
}
// MARK: - Offline Mode
@ViewBuilder
private func offlineImageView(url: URL) -> some View {
if hasCheckedCache && !isImageCached {
placeholderWithWarning
} else if let cachedImage {
cachedImageView(image: cachedImage)
} else {
kingfisherCacheOnlyView(url: url)
}
}
private func cachedImageView(image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.frame(maxWidth: .infinity)
}
private func kingfisherCacheOnlyView(url: URL) -> some View {
KFImage(url)
.cacheOriginalImage()
.diskCacheExpiration(.never)
.loadDiskFileSynchronously()
.onlyFromCache(true)
.placeholder { Color.gray.opacity(0.3) }
.onSuccess { _ in
Logger.ui.debug("✅ Loaded image from cache: \(url.absoluteString)")
}
.onFailure { error in
Logger.ui.warning("❌ Failed to load cached image: \(url.absoluteString) - \(error.localizedDescription)")
}
.fade(duration: 0.25)
.resizable()
.frame(maxWidth: .infinity)
}
private var placeholderImage: some View {
Color.gray.opacity(0.3)
.frame(maxWidth: .infinity)
@ -95,40 +108,64 @@ struct CachedAsyncImage: View {
)
}
// MARK: - Cache Checking
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
}
// Try custom cache key first, then fallback to URL-based cache
if let cacheKey = cacheKey, await tryLoadFromCustomKey(cacheKey) {
return
}
// Fallback: Check standard Kingfisher cache using URL
let isCached = await withCheckedContinuation { continuation in
await checkStandardCache(for: url)
}
private func tryLoadFromCustomKey(_ key: String) async -> Bool {
let image = await retrieveImageFromCache(key: key)
await MainActor.run {
if let image {
cachedImage = image
isImageCached = true
Logger.ui.debug("✅ Loaded image from cache using key: \(key)")
} else {
Logger.ui.debug("Image not found with cache key, trying URL-based cache")
}
hasCheckedCache = true
}
return image != nil
}
private func checkStandardCache(for url: URL) async {
let isCached = await isImageInCache(url: url)
await MainActor.run {
isImageCached = isCached
hasCheckedCache = true
if !appSettings.isNetworkConnected {
Logger.ui.debug(isCached
? "✅ Image is cached for offline use: \(url.absoluteString)"
: "❌ Image NOT cached for offline use: \(url.absoluteString)")
}
}
}
private func retrieveImageFromCache(key: String) async -> UIImage? {
await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: key) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image)
case .failure:
continuation.resume(returning: nil)
}
}
}
}
private func isImageInCache(url: URL) async -> Bool {
await withCheckedContinuation { continuation in
KingfisherManager.shared.cache.retrieveImage(forKey: url.cacheKey) { result in
switch result {
case .success(let cacheResult):
@ -138,18 +175,5 @@ struct CachedAsyncImage: View {
}
}
}
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

@ -28,6 +28,9 @@ protocol UseCaseFactory {
func makeSettingsRepository() -> PSettingsRepository
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase
func makeGetCachedBookmarksUseCase() -> PGetCachedBookmarksUseCase
func makeGetCachedArticleUseCase() -> PGetCachedArticleUseCase
func makeCreateAnnotationUseCase() -> PCreateAnnotationUseCase
}
@ -165,4 +168,16 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase {
return NetworkMonitorUseCase(repository: networkMonitorRepository)
}
func makeGetCachedBookmarksUseCase() -> PGetCachedBookmarksUseCase {
return GetCachedBookmarksUseCase(offlineCacheRepository: offlineCacheRepository)
}
func makeGetCachedArticleUseCase() -> PGetCachedArticleUseCase {
return GetCachedArticleUseCase(offlineCacheRepository: offlineCacheRepository)
}
func makeCreateAnnotationUseCase() -> PCreateAnnotationUseCase {
return CreateAnnotationUseCase(repository: annotationsRepository)
}
}

View File

@ -14,6 +14,10 @@ struct readeckApp: App {
@StateObject private var appSettings = AppSettings()
@Environment(\.scenePhase) private var scenePhase
#if DEBUG
@State private var showDebugMenu = false
#endif
var body: some Scene {
WindowGroup {
Group {
@ -27,6 +31,15 @@ struct readeckApp: App {
.environmentObject(appSettings)
.environment(\.managedObjectContext, CoreDataManager.shared.context)
.preferredColorScheme(appSettings.theme.colorScheme)
#if DEBUG
.onShake {
showDebugMenu = true
}
.sheet(isPresented: $showDebugMenu) {
DebugMenuView()
.environmentObject(appSettings)
}
#endif
.onAppear {
#if DEBUG
NFX.sharedInstance().start()
@ -59,58 +72,3 @@ struct readeckApp: App {
}
}
}
struct TestView: View {
var body: some View {
if #available(iOS 26.0, *) {
Text("hello")
.toolbar {
ToolbarSpacer(.flexible)
ToolbarItem {
Button {
} label: {
Label("Favorite", systemImage: "share")
.symbolVariant(.none)
}
}
ToolbarSpacer(.fixed)
ToolbarItemGroup {
Button {
} label: {
Label("Favorite", systemImage: "heart")
.symbolVariant(.none)
}
Button("Info", systemImage: "info") {
}
}
ToolbarItemGroup(placement: .bottomBar) {
Spacer()
Button {
} label: {
Label("Favorite", systemImage: "heart")
.symbolVariant(.none)
}
Button("Info", systemImage: "info") {
}
}
}
.toolbar(removing: .title)
.ignoresSafeArea(edges: .top)
} else {
Text("hello1")
}
}
}