ReadKeep/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift
Ilyas Hallak fdc6b3a6b6 Add offline reading UI and app integration (Phase 4 & 5)
Phase 4 - Settings UI:
- Add OfflineSettingsViewModel with reactive bindings
- Add OfflineSettingsView with toggle, slider, sync button
- Integrate into SettingsContainerView
- Extend factories with offline dependencies
- Add debug button to simulate offline mode (DEBUG only)

Phase 5 - App Integration:
- AppViewModel: Auto-sync on app start with 4h check
- BookmarksViewModel: Offline fallback loading cached articles
- BookmarksView: Offline banner when network unavailable
- BookmarkDetailViewModel: Cache-first article loading
- Fix concurrency issues with CurrentValueSubject

Features:
- Background sync on app start (non-blocking)
- Cached bookmarks shown when offline
- Instant article loading from cache
- Visual offline indicator banner
- Full offline reading experience

All features compile and build successfully.
2025-11-18 17:44:43 +01:00

160 lines
5.4 KiB
Swift

//
// OfflineCacheSyncUseCase.swift
// readeck
//
// Created by Claude on 17.11.25.
//
import Foundation
import Combine
// MARK: - Protocol
protocol POfflineCacheSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> { get }
var syncProgress: AnyPublisher<String?, Never> { get }
func syncOfflineArticles(settings: OfflineSettings) async
func getCachedArticlesCount() -> Int
func getCacheSize() -> String
}
// MARK: - Implementation
final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Dependencies
private let offlineCacheRepository: POfflineCacheRepository
private let bookmarksRepository: PBookmarksRepository
private let settingsRepository: PSettingsRepository
// MARK: - Published State
private let _isSyncingSubject = CurrentValueSubject<Bool, Never>(false)
private let _syncProgressSubject = CurrentValueSubject<String?, Never>(nil)
var isSyncing: AnyPublisher<Bool, Never> {
_isSyncingSubject.eraseToAnyPublisher()
}
var syncProgress: AnyPublisher<String?, Never> {
_syncProgressSubject.eraseToAnyPublisher()
}
// MARK: - Initialization
init(
offlineCacheRepository: POfflineCacheRepository,
bookmarksRepository: PBookmarksRepository,
settingsRepository: PSettingsRepository
) {
self.offlineCacheRepository = offlineCacheRepository
self.bookmarksRepository = bookmarksRepository
self.settingsRepository = settingsRepository
}
// MARK: - Public Methods
@MainActor
func syncOfflineArticles(settings: OfflineSettings) async {
guard settings.enabled else {
Logger.sync.info("Offline sync skipped: disabled in settings")
return
}
_isSyncingSubject.send(true)
Logger.sync.info("🔄 Starting offline sync (max: \(settings.maxUnreadArticlesInt) articles, images: \(settings.saveImages))")
do {
// Fetch unread bookmarks from API
let page = try await bookmarksRepository.fetchBookmarks(
state: .unread,
limit: settings.maxUnreadArticlesInt,
offset: 0,
search: nil,
type: nil,
tag: nil
)
let bookmarks = page.bookmarks
Logger.sync.info("📚 Fetched \(bookmarks.count) unread bookmarks")
var successCount = 0
var skippedCount = 0
var errorCount = 0
// Process each bookmark
for (index, bookmark) in bookmarks.enumerated() {
let progress = "\(index + 1)/\(bookmarks.count)"
// Check cache status
if offlineCacheRepository.hasCachedArticle(id: bookmark.id) {
Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)")
skippedCount += 1
_syncProgressSubject.send("⏭️ Artikel \(progress) bereits gecacht...")
continue
}
// Update progress
let imagesSuffix = settings.saveImages ? " + Bilder" : ""
_syncProgressSubject.send("📥 Artikel \(progress)\(imagesSuffix)...")
Logger.sync.info("📥 Caching '\(bookmark.title)'")
do {
// Fetch article HTML from API
let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id)
// Cache with metadata
try await offlineCacheRepository.cacheBookmarkWithMetadata(
bookmark: bookmark,
html: html,
saveImages: settings.saveImages
)
successCount += 1
Logger.sync.info("✅ Cached '\(bookmark.title)'")
} catch {
errorCount += 1
Logger.sync.error("❌ Failed to cache '\(bookmark.title)': \(error.localizedDescription)")
}
}
// Cleanup old articles (FIFO)
try await offlineCacheRepository.cleanupOldestCachedArticles(keepCount: settings.maxUnreadArticlesInt)
// Update last sync date in settings
var updatedSettings = settings
updatedSettings.lastSyncDate = Date()
try await settingsRepository.saveOfflineSettings(updatedSettings)
// Final status
let statusMessage = "✅ Synchronisiert: \(successCount), Übersprungen: \(skippedCount), Fehler: \(errorCount)"
Logger.sync.info(statusMessage)
_syncProgressSubject.send(statusMessage)
// Clear progress message after 3 seconds
try? await Task.sleep(nanoseconds: 3_000_000_000)
_syncProgressSubject.send(nil)
} catch {
Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)")
_syncProgressSubject.send("❌ Synchronisierung fehlgeschlagen")
// Clear error message after 5 seconds
try? await Task.sleep(nanoseconds: 5_000_000_000)
_syncProgressSubject.send(nil)
}
_isSyncingSubject.send(false)
}
func getCachedArticlesCount() -> Int {
offlineCacheRepository.getCachedArticlesCount()
}
func getCacheSize() -> String {
offlineCacheRepository.getCacheSize()
}
}