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.
160 lines
5.4 KiB
Swift
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()
|
|
}
|
|
}
|