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.
This commit is contained in:
Ilyas Hallak 2025-11-18 17:44:43 +01:00
parent f5dab38038
commit fdc6b3a6b6
11 changed files with 689 additions and 295 deletions

View File

@ -11,9 +11,9 @@
### 1.1 Logging-Kategorie erweitern
**Datei**: `readeck/Utils/Logger.swift`
- [ ] `LogCategory.sync` zur enum hinzufügen
- [ ] `Logger.sync` zur Extension hinzufügen
- [ ] Testen: Logging funktioniert
- [x] `LogCategory.sync` zur enum hinzufügen
- [x] `Logger.sync` zur Extension hinzufügen
- [x] Testen: Logging funktioniert
**Code-Änderungen**:
```swift
@ -33,49 +33,44 @@ extension Logger {
### 1.2 Domain Model: OfflineSettings
**Neue Datei**: `readeck/Domain/Model/OfflineSettings.swift`
- [ ] Struct OfflineSettings erstellen
- [ ] Properties hinzufügen:
- [ ] `enabled: Bool = true`
- [ ] `maxUnreadArticles: Double = 20`
- [ ] `saveImages: Bool = false`
- [ ] `lastSyncDate: Date?`
- [ ] Computed Property `maxUnreadArticlesInt` implementieren
- [ ] Computed Property `shouldSyncOnAppStart` implementieren (4-Stunden-Check)
- [ ] Codable Conformance
- [ ] Testen: shouldSyncOnAppStart Logic
- [x] Struct OfflineSettings erstellen
- [x] Properties hinzufügen:
- [x] `enabled: Bool = true`
- [x] `maxUnreadArticles: Double = 20`
- [x] `saveImages: Bool = false`
- [x] `lastSyncDate: Date?`
- [x] Computed Property `maxUnreadArticlesInt` implementieren
- [x] Computed Property `shouldSyncOnAppStart` implementieren (4-Stunden-Check)
- [x] Codable Conformance
- [x] Testen: shouldSyncOnAppStart Logic
**Checklist**:
- [ ] File erstellt
- [ ] Alle Properties vorhanden
- [ ] 4-Stunden-Check funktioniert
- [ ] Kompiliert ohne Fehler
- [x] File erstellt
- [x] Alle Properties vorhanden
- [x] 4-Stunden-Check funktioniert
- [x] Kompiliert ohne Fehler
---
### 1.3 CoreData Entity: CachedArticle
### 1.3 CoreData Entity: BookmarkEntity erweitert
**Datei**: `readeck.xcdatamodeld`
- [ ] Neue Model-Version erstellen
- [ ] CachedArticle Entity hinzufügen
- [ ] Attributes definieren:
- [ ] `id` (String, indexed)
- [ ] `bookmarkId` (String, indexed, unique constraint)
- [ ] `bookmarkJSON` (String)
- [ ] `htmlContent` (String)
- [ ] `cachedDate` (Date, indexed)
- [ ] `lastAccessDate` (Date)
- [ ] `size` (Integer 64)
- [ ] `imageURLs` (String, optional)
- [ ] Current Model Version setzen
- [ ] Lightweight Migration in CoreDataManager aktivieren
- [ ] Testen: App startet ohne Crash, keine Migration-Fehler
- [x] BookmarkEntity mit Cache-Feldern erweitern
- [x] Attributes definieren:
- [x] `htmlContent` (String)
- [x] `cachedDate` (Date, indexed)
- [x] `lastAccessDate` (Date)
- [x] `cacheSize` (Integer 64)
- [x] `imageURLs` (String, optional)
- [x] Lightweight Migration
- [x] Testen: App startet ohne Crash, keine Migration-Fehler
**Checklist**:
- [ ] Entity erstellt
- [ ] Alle Attributes vorhanden
- [ ] Indexes gesetzt
- [ ] Migration funktioniert
- [ ] App startet erfolgreich
- [x] Cache-Felder hinzugefügt
- [x] Alle Attributes vorhanden
- [x] Indexes gesetzt
- [x] Migration funktioniert
- [x] App startet erfolgreich
---
@ -84,123 +79,117 @@ extension Logger {
### 2.1 Settings Repository Protocol
**Neue Datei**: `readeck/Domain/Protocols/PSettingsRepository.swift`
- [ ] Protocol `PSettingsRepository` erstellen
- [ ] Methode `loadOfflineSettings()` definieren
- [ ] Methode `saveOfflineSettings(_ settings: OfflineSettings)` definieren
- [x] Protocol `PSettingsRepository` erweitern
- [x] Methode `loadOfflineSettings()` definieren
- [x] Methode `saveOfflineSettings(_ settings: OfflineSettings)` definieren
**Checklist**:
- [ ] File erstellt
- [ ] Protocol definiert
- [ ] Methoden deklariert
- [ ] Kompiliert ohne Fehler
- [x] Protocol erweitert
- [x] Methoden deklariert
- [x] Kompiliert ohne Fehler
---
### 2.2 Settings Repository Implementation
**Neue Datei**: `readeck/Data/Repository/SettingsRepository.swift`
**Datei**: `readeck/Data/Repository/SettingsRepository.swift`
- [ ] Class `SettingsRepository` erstellen
- [ ] `PSettingsRepository` implementieren
- [ ] `loadOfflineSettings()` implementieren:
- [ ] UserDefaults laden
- [ ] JSON dekodieren
- [ ] Default-Settings zurückgeben bei Fehler
- [ ] Logger.data für Erfolgsmeldung
- [ ] `saveOfflineSettings()` implementieren:
- [ ] JSON enkodieren
- [ ] UserDefaults speichern
- [ ] Logger.data für Erfolgsmeldung
- [ ] Testen: Save & Load funktioniert
- [x] Class `SettingsRepository` erweitert
- [x] `PSettingsRepository` implementiert
- [x] `loadOfflineSettings()` implementiert:
- [x] UserDefaults laden
- [x] JSON dekodieren
- [x] Default-Settings zurückgeben bei Fehler
- [x] Logger.data für Erfolgsmeldung
- [x] `saveOfflineSettings()` implementiert:
- [x] JSON enkodieren
- [x] UserDefaults speichern
- [x] Logger.data für Erfolgsmeldung
- [x] Kompiliert ohne Fehler
**Checklist**:
- [ ] File erstellt
- [ ] loadOfflineSettings() implementiert
- [ ] saveOfflineSettings() implementiert
- [ ] Logging integriert
- [ ] Manueller Test erfolgreich
- [x] File erweitert (Zeilen 274-296)
- [x] loadOfflineSettings() implementiert
- [x] saveOfflineSettings() implementiert
- [x] Logging integriert
- [x] Kompiliert erfolgreich
---
### 2.3 BookmarksRepository Protocol erweitern
**Datei**: `readeck/Domain/Protocols/PBookmarksRepository.swift`
### 2.3 OfflineCacheRepository Protocol (ARCHITEKTUR-ÄNDERUNG)
**Neue Datei**: `readeck/Domain/Protocols/POfflineCacheRepository.swift`
- [ ] Neue Methoden zum Protocol hinzufügen:
- [ ] `cacheBookmarkWithMetadata(bookmark:html:saveImages:) async throws`
- [ ] `getCachedArticle(id:) -> String?`
- [ ] `hasCachedArticle(id:) -> Bool`
- [ ] `getCachedBookmarks() async throws -> [Bookmark]`
- [ ] `getCachedArticlesCount() -> Int`
- [ ] `getCacheSize() -> String`
- [ ] `clearCache() async throws`
- [ ] `cleanupOldestCachedArticles(keepCount:) async throws`
**HINWEIS:** Anstatt `PBookmarksRepository` zu erweitern, wurde ein separates `POfflineCacheRepository` erstellt für **Clean Architecture** und Separation of Concerns.
- [x] Protocol `POfflineCacheRepository` erstellen
- [x] Neue Methoden zum Protocol hinzufügen:
- [x] `cacheBookmarkWithMetadata(bookmark:html:saveImages:) async throws`
- [x] `getCachedArticle(id:) -> String?`
- [x] `hasCachedArticle(id:) -> Bool`
- [x] `getCachedBookmarks() async throws -> [Bookmark]`
- [x] `getCachedArticlesCount() -> Int`
- [x] `getCacheSize() -> String`
- [x] `clearCache() async throws`
- [x] `cleanupOldestCachedArticles(keepCount:) async throws`
**Checklist**:
- [ ] Alle Methoden deklariert
- [ ] Async/throws korrekt gesetzt
- [ ] Kompiliert ohne Fehler
- [x] Protocol erstellt (Zeilen 1-24)
- [x] Alle Methoden deklariert
- [x] Async/throws korrekt gesetzt
- [x] Kompiliert ohne Fehler
---
### 2.4 BookmarksRepository Implementation
**Datei**: `readeck/Data/Repository/BookmarksRepository.swift`
### 2.4 OfflineCacheRepository Implementation (ARCHITEKTUR-ÄNDERUNG)
**Neue Datei**: `readeck/Data/Repository/OfflineCacheRepository.swift`
- [ ] `import Kingfisher` hinzufügen
- [ ] `cacheBookmarkWithMetadata()` implementieren:
- [ ] Prüfen ob bereits gecacht
- [ ] Bookmark als JSON enkodieren
- [ ] CoreData CachedArticle erstellen
- [ ] Speichern in CoreData
- [ ] Logger.sync.info
- [ ] Bei saveImages: extractImageURLsFromHTML() aufrufen
- [ ] Bei saveImages: prefetchImagesWithKingfisher() aufrufen
- [ ] `extractImageURLsFromHTML()` implementieren:
- [ ] Regex für `<img src="...">` Tags
- [ ] URLs extrahieren
- [ ] Nur absolute URLs (http/https)
- [ ] Logger.sync.debug
- [ ] `prefetchImagesWithKingfisher()` implementieren:
- [ ] URLs zu URL-Array konvertieren
- [ ] ImagePrefetcher erstellen
- [ ] Optional: downloadPriority(.low) setzen
- [ ] prefetcher.start()
- [ ] Logger.sync.info
- [ ] `getCachedArticle()` implementieren:
- [ ] NSFetchRequest mit predicate
- [ ] lastAccessDate updaten
- [ ] htmlContent zurückgeben
- [ ] `hasCachedArticle()` implementieren
- [ ] `getCachedBookmarks()` implementieren:
- [ ] Fetch alle CachedArticle
- [ ] Sort by cachedDate descending
- [ ] JSON zu Bookmark dekodieren
- [ ] Array zurückgeben
- [ ] `getCachedArticlesCount()` implementieren
- [ ] `getCacheSize()` implementieren:
- [ ] Alle sizes summieren
- [ ] ByteCountFormatter nutzen
- [ ] `clearCache()` implementieren:
- [ ] NSBatchDeleteRequest
- [ ] Logger.sync.info
- [ ] `cleanupOldestCachedArticles()` implementieren:
- [ ] Sort by cachedDate ascending
- [ ] Älteste löschen wenn > keepCount
- [ ] Logger.sync.info
- [ ] Testen: Alle Methoden funktionieren
- [x] `import Kingfisher` hinzugefügt
- [x] `cacheBookmarkWithMetadata()` implementiert:
- [x] Prüfen ob bereits gecacht
- [x] Bookmark in CoreData speichern via BookmarkEntity
- [x] CoreData BookmarkEntity erweitern
- [x] Speichern in CoreData
- [x] Logger.sync.info
- [x] Bei saveImages: extractImageURLsFromHTML() aufrufen
- [x] Bei saveImages: prefetchImagesWithKingfisher() aufrufen
- [x] `extractImageURLsFromHTML()` implementiert:
- [x] Regex für `<img src="...">` Tags
- [x] URLs extrahieren
- [x] Nur absolute URLs (http/https)
- [x] Logger.sync.debug
- [x] `prefetchImagesWithKingfisher()` implementiert:
- [x] URLs zu URL-Array konvertieren
- [x] ImagePrefetcher erstellt
- [x] Callback mit Logging
- [x] prefetcher.start()
- [x] Logger.sync.info
- [x] `getCachedArticle()` implementiert:
- [x] NSFetchRequest mit predicate
- [x] lastAccessDate updaten
- [x] htmlContent zurückgeben
- [x] `hasCachedArticle()` implementiert
- [x] `getCachedBookmarks()` implementiert:
- [x] Fetch alle BookmarkEntity mit htmlContent
- [x] Sort by cachedDate descending
- [x] toDomain() mapper nutzen
- [x] Array zurückgeben
- [x] `getCachedArticlesCount()` implementiert
- [x] `getCacheSize()` implementiert:
- [x] Alle sizes summieren
- [x] ByteCountFormatter nutzen
- [x] `clearCache()` implementiert:
- [x] Cache-Felder auf nil setzen
- [x] Logger.sync.info
- [x] `cleanupOldestCachedArticles()` implementiert:
- [x] Sort by cachedDate ascending
- [x] Älteste löschen wenn > keepCount
- [x] Logger.sync.info
**Checklist**:
- [ ] Kingfisher import
- [ ] cacheBookmarkWithMetadata() fertig
- [ ] extractImageURLsFromHTML() fertig
- [ ] prefetchImagesWithKingfisher() fertig
- [ ] getCachedArticle() fertig
- [ ] hasCachedArticle() fertig
- [ ] getCachedBookmarks() fertig
- [ ] getCachedArticlesCount() fertig
- [ ] getCacheSize() fertig
- [ ] clearCache() fertig
- [ ] cleanupOldestCachedArticles() fertig
- [ ] Logging überall integriert
- [ ] Manuelle Tests erfolgreich
- [x] File erstellt (272 Zeilen)
- [x] Kingfisher import
- [x] Alle Methoden implementiert
- [x] Logging überall integriert
- [x] Kompiliert ohne Fehler
---
@ -209,73 +198,73 @@ extension Logger {
### 3.1 OfflineCacheSyncUseCase Protocol
**Neue Datei**: `readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift`
- [ ] Protocol `POfflineCacheSyncUseCase` erstellen
- [ ] Published Properties definieren:
- [ ] `var isSyncing: AnyPublisher<Bool, Never>`
- [ ] `var syncProgress: AnyPublisher<String?, Never>`
- [ ] Methoden deklarieren:
- [ ] `func syncOfflineArticles(settings:) async`
- [ ] `func getCachedArticlesCount() -> Int`
- [ ] `func getCacheSize() -> String`
- [x] Protocol `POfflineCacheSyncUseCase` erstellen
- [x] Published Properties definieren:
- [x] `var isSyncing: AnyPublisher<Bool, Never>`
- [x] `var syncProgress: AnyPublisher<String?, Never>`
- [x] Methoden deklarieren:
- [x] `func syncOfflineArticles(settings:) async`
- [x] `func getCachedArticlesCount() -> Int`
- [x] `func getCacheSize() -> String`
**Checklist**:
- [ ] File erstellt
- [ ] Protocol definiert
- [ ] Methoden deklariert
- [ ] Kompiliert ohne Fehler
- [x] File erstellt
- [x] Protocol definiert (Zeilen 11-20)
- [x] Methoden deklariert
- [x] Kompiliert ohne Fehler
---
### 3.2 OfflineCacheSyncUseCase Implementation
**Datei**: `readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift` (im selben File)
- [ ] Class `OfflineCacheSyncUseCase` erstellen
- [ ] Dependencies:
- [ ] `bookmarksRepository: PBookmarksRepository`
- [ ] `settingsRepository: PSettingsRepository`
- [ ] @Published Properties:
- [ ] `_isSyncing = false`
- [ ] `_syncProgress: String?`
- [ ] Publishers als computed properties
- [ ] `syncOfflineArticles()` implementieren:
- [ ] Guard enabled check
- [ ] Logger.sync.info("Starting sync")
- [ ] Set isSyncing = true
- [ ] Fetch bookmarks (state: .unread, limit: settings.maxUnreadArticlesInt)
- [ ] Logger.sync.info("Fetched X bookmarks")
- [ ] Loop durch Bookmarks:
- [ ] Skip wenn bereits gecacht (Logger.sync.debug)
- [ ] syncProgress updaten: "Artikel X/Y..."
- [ ] fetchBookmarkArticle() aufrufen
- [ ] cacheBookmarkWithMetadata() aufrufen
- [ ] successCount++
- [ ] Bei saveImages: syncProgress "...+ Bilder"
- [ ] Catch: errorCount++, Logger.sync.error
- [ ] cleanupOldestCachedArticles() aufrufen
- [ ] lastSyncDate updaten
- [ ] Logger.sync.info("✅ Synced X, skipped Y, failed Z")
- [ ] Set isSyncing = false
- [ ] syncProgress = Status-Message
- [ ] Sleep 3s, dann syncProgress = nil
- [ ] `getCachedArticlesCount()` implementieren
- [ ] `getCacheSize()` implementieren
- [ ] Error-Handling:
- [ ] Catch block für Haupt-Try
- [ ] Logger.sync.error
- [ ] syncProgress = Error-Message
- [ ] Testen: Sync-Flow komplett durchlaufen
- [x] Class `OfflineCacheSyncUseCase` erstellen
- [x] Dependencies:
- [x] `offlineCacheRepository: POfflineCacheRepository`
- [x] `bookmarksRepository: PBookmarksRepository`
- [x] `settingsRepository: PSettingsRepository`
- [x] CurrentValueSubject für State (statt @Published):
- [x] `_isSyncingSubject = CurrentValueSubject<Bool, Never>(false)`
- [x] `_syncProgressSubject = CurrentValueSubject<String?, Never>(nil)`
- [x] Publishers als computed properties
- [x] `syncOfflineArticles()` implementieren:
- [x] Guard enabled check
- [x] Logger.sync.info("Starting sync")
- [x] Set isSyncing = true
- [x] Fetch bookmarks (state: .unread, limit: settings.maxUnreadArticlesInt)
- [x] Logger.sync.info("Fetched X bookmarks")
- [x] Loop durch Bookmarks:
- [x] Skip wenn bereits gecacht (Logger.sync.debug)
- [x] syncProgress updaten: "Artikel X/Y..."
- [x] fetchBookmarkArticle() aufrufen
- [x] cacheBookmarkWithMetadata() aufrufen
- [x] successCount++
- [x] Bei saveImages: syncProgress "...+ Bilder"
- [x] Catch: errorCount++, Logger.sync.error
- [x] cleanupOldestCachedArticles() aufrufen
- [x] lastSyncDate updaten
- [x] Logger.sync.info("✅ Synced X, skipped Y, failed Z")
- [x] Set isSyncing = false
- [x] syncProgress = Status-Message
- [x] Sleep 3s, dann syncProgress = nil
- [x] `getCachedArticlesCount()` implementieren
- [x] `getCacheSize()` implementieren
- [x] Error-Handling:
- [x] Catch block für Haupt-Try
- [x] Logger.sync.error
- [x] syncProgress = Error-Message
**Checklist**:
- [ ] Class erstellt
- [ ] Dependencies injiziert
- [ ] syncOfflineArticles() komplett
- [ ] Success/Skip/Error Tracking
- [ ] Logging an allen wichtigen Stellen
- [ ] Progress-Updates
- [ ] Error-Handling
- [ ] getCachedArticlesCount() fertig
- [ ] getCacheSize() fertig
- [ ] Test: Sync läuft durch
- [x] Class erstellt (159 Zeilen)
- [x] Dependencies injiziert (3 repositories)
- [x] syncOfflineArticles() komplett mit @MainActor
- [x] Success/Skip/Error Tracking
- [x] Logging an allen wichtigen Stellen
- [x] Progress-Updates mit Emojis
- [x] Error-Handling
- [x] getCachedArticlesCount() fertig
- [x] getCacheSize() fertig
- [x] Kompiliert ohne Fehler
---
@ -284,110 +273,104 @@ extension Logger {
### 4.1 OfflineSettingsViewModel
**Neue Datei**: `readeck/UI/Settings/OfflineSettingsViewModel.swift`
- [ ] Class mit @MainActor und @Observable
- [ ] @Published Properties:
- [ ] `offlineSettings: OfflineSettings`
- [ ] `isSyncing = false`
- [ ] `syncProgress: String?`
- [ ] `cachedArticlesCount = 0`
- [ ] `cacheSize = "0 KB"`
- [ ] Dependencies:
- [ ] `settingsRepository: PSettingsRepository`
- [ ] `offlineCacheSyncUseCase: POfflineCacheSyncUseCase`
- [ ] Init mit Dependencies
- [ ] `setupBindings()` implementieren:
- [ ] isSyncing Publisher binden
- [ ] syncProgress Publisher binden
- [ ] Auto-save bei offlineSettings change (debounce 0.5s)
- [ ] `loadSettings()` implementieren
- [ ] `syncNow()` implementieren:
- [ ] Kommentar: Manual sync mit höherer Priority
- [ ] await offlineCacheSyncUseCase.syncOfflineArticles()
- [ ] updateCacheStats()
- [ ] `updateCacheStats()` implementieren
- [ ] Testen: ViewModel funktioniert
- [x] Class mit @Observable (ohne @MainActor auf Klassenebene)
- [x] Properties:
- [x] `offlineSettings: OfflineSettings`
- [x] `isSyncing = false`
- [x] `syncProgress: String?`
- [x] `cachedArticlesCount = 0`
- [x] `cacheSize = "0 KB"`
- [x] Dependencies:
- [x] `settingsRepository: PSettingsRepository`
- [x] `offlineCacheSyncUseCase: POfflineCacheSyncUseCase`
- [x] Init mit Factory
- [x] `setupBindings()` implementiert:
- [x] isSyncing Publisher binden
- [x] syncProgress Publisher binden
- [x] `loadSettings()` implementiert mit @MainActor
- [x] `saveSettings()` implementiert mit @MainActor
- [x] `syncNow()` implementiert mit @MainActor:
- [x] await offlineCacheSyncUseCase.syncOfflineArticles()
- [x] updateCacheStats()
- [x] `updateCacheStats()` implementiert mit @MainActor
**Checklist**:
- [ ] File erstellt
- [ ] Properties definiert
- [ ] Dependencies injiziert
- [ ] setupBindings() fertig
- [ ] loadSettings() fertig
- [ ] syncNow() fertig
- [ ] updateCacheStats() fertig
- [ ] Test: ViewModel lädt Settings
- [x] File erstellt (89 Zeilen)
- [x] Properties definiert
- [x] Dependencies via Factory injiziert
- [x] setupBindings() mit Combine
- [x] Alle Methoden mit @MainActor markiert
- [x] Kompiliert ohne Fehler
---
### 4.2 OfflineSettingsView
**Neue Datei**: `readeck/UI/Settings/OfflineSettingsView.swift`
- [ ] Struct `OfflineSettingsView: View`
- [ ] @StateObject viewModel
- [ ] Body implementieren:
- [ ] Form mit Section
- [ ] Toggle: "Offline-Reading" gebunden an enabled
- [ ] If enabled:
- [ ] VStack: Erklärungstext (caption, secondary)
- [ ] VStack: Slider "Max. Artikel offline" (0-100, step 10)
- [ ] HStack: Anzeige aktueller Wert
- [ ] Toggle: "Bilder speichern"
- [ ] Button: "Jetzt synchronisieren" mit ProgressView
- [ ] If syncProgress: Text anzeigen (caption)
- [ ] If lastSyncDate: Text "Zuletzt synchronisiert: relative"
- [ ] If cachedArticlesCount > 0: HStack mit Stats
- [ ] Section header: "Offline-Reading"
- [ ] navigationTitle("Offline-Reading")
- [ ] onAppear: updateCacheStats()
- [ ] Testen: UI wird korrekt angezeigt
- [x] Struct `OfflineSettingsView: View`
- [x] @State viewModel
- [x] Body implementiert:
- [x] Section mit "Offline-Reading" header
- [x] Toggle: "Offline-Reading aktivieren" gebunden an enabled
- [x] If enabled:
- [x] VStack: Erklärungstext (caption, secondary)
- [x] VStack: Slider "Max. Artikel offline" (0-100, step 10)
- [x] HStack: Anzeige aktueller Wert
- [x] Toggle: "Bilder speichern" mit Erklärung
- [x] Button: "Jetzt synchronisieren" mit ProgressView
- [x] If syncProgress: Text anzeigen (caption)
- [x] If lastSyncDate: Text "Zuletzt: relative"
- [x] If cachedArticlesCount > 0: HStack mit Stats
- [x] task: loadSettings() bei Erscheinen
- [x] onChange Handler für alle Settings (auto-save)
**Checklist**:
- [ ] File erstellt
- [ ] Form Structure erstellt
- [ ] Toggle für enabled
- [ ] Slider für maxUnreadArticles
- [ ] Toggle für saveImages
- [ ] Sync-Button mit Progress
- [ ] Stats-Anzeige
- [ ] UI-Preview funktioniert
- [ ] Test: Settings werden angezeigt
- [x] File erstellt (145 Zeilen)
- [x] Form Structure mit Section
- [x] Toggle für enabled mit Erklärung
- [x] Slider für maxUnreadArticles mit Wert-Anzeige
- [x] Toggle für saveImages
- [x] Sync-Button mit Progress und Icon
- [x] Stats-Anzeige (Artikel + Größe)
- [x] Preview mit MockFactory
- [x] Kompiliert ohne Fehler
---
### 4.3 SettingsContainerView Integration
**Datei**: `readeck/UI/Settings/SettingsContainerView.swift`
- [ ] NavigationLink zu OfflineSettingsView hinzufügen
- [ ] Label mit "Offline-Reading" und Icon "arrow.down.circle"
- [ ] In bestehende Section (z.B. "Allgemein") einfügen
- [ ] Testen: Navigation funktioniert
- [x] OfflineSettingsView direkt eingebettet (kein NavigationLink)
- [x] Nach SyncSettingsView platziert
- [x] Konsistent mit anderen Settings-Sections
**Checklist**:
- [ ] NavigationLink hinzugefügt
- [ ] Icon korrekt
- [ ] Navigation funktioniert
- [x] OfflineSettingsView() hinzugefügt (Zeile 28)
- [x] Korrekte Platzierung in der Liste
- [x] Kompiliert ohne Fehler
---
### 4.4 Factory erweitern
**Datei**: `readeck/UI/Factory/DefaultUseCaseFactory.swift`
**Dateien**: `readeck/UI/Factory/DefaultUseCaseFactory.swift` + `MockUseCaseFactory.swift`
- [ ] `makeOfflineSettingsViewModel()` implementieren:
- [ ] settingsRepository injecten
- [ ] offlineCacheSyncUseCase injecten
- [ ] OfflineSettingsViewModel instanziieren
- [ ] `makeSettingsRepository()` implementieren (private):
- [ ] SettingsRepository instanziieren
- [ ] `makeOfflineCacheSyncUseCase()` implementieren (private):
- [ ] bookmarksRepository injecten
- [ ] settingsRepository injecten
- [ ] OfflineCacheSyncUseCase instanziieren
- [ ] Testen: Dependencies werden korrekt aufgelöst
- [x] Protocol `UseCaseFactory` erweitert:
- [x] `makeSettingsRepository() -> PSettingsRepository`
- [x] `makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase`
- [x] `DefaultUseCaseFactory` implementiert:
- [x] `offlineCacheRepository` als lazy property
- [x] `makeSettingsRepository()` gibt settingsRepository zurück
- [x] `makeOfflineCacheSyncUseCase()` erstellt UseCase mit 3 Dependencies
- [x] `MockUseCaseFactory` implementiert:
- [x] `MockSettingsRepository` mit allen Methoden
- [x] `MockOfflineCacheSyncUseCase` mit Publishers
**Checklist**:
- [ ] makeOfflineSettingsViewModel() fertig
- [ ] makeSettingsRepository() fertig
- [ ] makeOfflineCacheSyncUseCase() fertig
- [x] Protocol erweitert (2 neue Methoden)
- [x] DefaultUseCaseFactory: beide Methoden implementiert
- [x] MockUseCaseFactory: Mock-Klassen erstellt
- [x] ViewModel nutzt Factory korrekt
- [x] Kompiliert ohne Fehler
- [ ] Test: ViewModel wird erstellt ohne Crash
---
@ -395,10 +378,12 @@ extension Logger {
### 4.5 MockUseCaseFactory erweitern (optional)
**Datei**: `readeck/UI/Factory/MockUseCaseFactory.swift`
- [ ] Mock-Implementierungen für Tests hinzufügen (falls nötig)
- [x] Mock-Implementierungen für Tests hinzugefügt
**Checklist**:
- [ ] Mocks erstellt (falls nötig)
- [x] MockSettingsRepository mit allen Protokoll-Methoden
- [x] MockOfflineCacheSyncUseCase mit Publishers
- [x] Kompiliert ohne Fehler
---

View File

@ -21,7 +21,6 @@ protocol POfflineCacheSyncUseCase {
// MARK: - Implementation
@MainActor
final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Dependencies
@ -32,15 +31,15 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Published State
@Published private var _isSyncing = false
@Published private var _syncProgress: String?
private let _isSyncingSubject = CurrentValueSubject<Bool, Never>(false)
private let _syncProgressSubject = CurrentValueSubject<String?, Never>(nil)
var isSyncing: AnyPublisher<Bool, Never> {
$_isSyncing.eraseToAnyPublisher()
_isSyncingSubject.eraseToAnyPublisher()
}
var syncProgress: AnyPublisher<String?, Never> {
$_syncProgress.eraseToAnyPublisher()
_syncProgressSubject.eraseToAnyPublisher()
}
// MARK: - Initialization
@ -57,13 +56,14 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Public Methods
@MainActor
func syncOfflineArticles(settings: OfflineSettings) async {
guard settings.enabled else {
Logger.sync.info("Offline sync skipped: disabled in settings")
return
}
_isSyncing = true
_isSyncingSubject.send(true)
Logger.sync.info("🔄 Starting offline sync (max: \(settings.maxUnreadArticlesInt) articles, images: \(settings.saveImages))")
do {
@ -92,13 +92,13 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
if offlineCacheRepository.hasCachedArticle(id: bookmark.id) {
Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)")
skippedCount += 1
_syncProgress = "⏭️ Artikel \(progress) bereits gecacht..."
_syncProgressSubject.send("⏭️ Artikel \(progress) bereits gecacht...")
continue
}
// Update progress
let imagesSuffix = settings.saveImages ? " + Bilder" : ""
_syncProgress = "📥 Artikel \(progress)\(imagesSuffix)..."
_syncProgressSubject.send("📥 Artikel \(progress)\(imagesSuffix)...")
Logger.sync.info("📥 Caching '\(bookmark.title)'")
do {
@ -131,22 +131,22 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// Final status
let statusMessage = "✅ Synchronisiert: \(successCount), Übersprungen: \(skippedCount), Fehler: \(errorCount)"
Logger.sync.info(statusMessage)
_syncProgress = statusMessage
_syncProgressSubject.send(statusMessage)
// Clear progress message after 3 seconds
try? await Task.sleep(nanoseconds: 3_000_000_000)
_syncProgress = nil
_syncProgressSubject.send(nil)
} catch {
Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)")
_syncProgress = "❌ Synchronisierung fehlgeschlagen"
_syncProgressSubject.send("❌ Synchronisierung fehlgeschlagen")
// Clear error message after 5 seconds
try? await Task.sleep(nanoseconds: 5_000_000_000)
_syncProgress = nil
_syncProgressSubject.send(nil)
}
_isSyncing = false
_isSyncingSubject.send(false)
}
func getCachedArticlesCount() -> Int {

View File

@ -70,6 +70,7 @@ class AppViewModel {
func onAppResume() async {
await checkServerReachability()
await syncTagsOnAppStart()
syncOfflineArticlesIfNeeded()
}
private func checkServerReachability() async {
@ -92,6 +93,28 @@ class AppViewModel {
lastAppStartTagSyncTime = now
}
private func syncOfflineArticlesIfNeeded() {
// Run offline sync in background without blocking app start
Task.detached(priority: .background) { [weak self] in
guard let self = self else { return }
do {
let settings = try await self.settingsRepository.loadOfflineSettings()
guard settings.shouldSyncOnAppStart else {
Logger.sync.debug("Offline sync not needed (disabled or synced recently)")
return
}
Logger.sync.info("Auto-sync triggered on app start")
let offlineCacheSyncUseCase = self.factory.makeOfflineCacheSyncUseCase()
await offlineCacheSyncUseCase.syncOfflineArticles(settings: settings)
} catch {
Logger.sync.error("Failed to load offline settings for auto-sync: \(error.localizedDescription)")
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}

View File

@ -9,6 +9,7 @@ class BookmarkDetailViewModel {
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private var addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase?
private let api: PAPI
private let offlineCacheRepository: POfflineCacheRepository
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -33,6 +34,7 @@ class BookmarkDetailViewModel {
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.api = API()
self.factory = factory
self.offlineCacheRepository = OfflineCacheRepository()
readProgressSubject
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
@ -72,6 +74,16 @@ class BookmarkDetailViewModel {
func loadArticleContent(id: String) async {
isLoadingArticle = true
// First, try to load from cache
if let cachedHTML = offlineCacheRepository.getCachedArticle(id: id) {
articleContent = cachedHTML
processArticleContent()
isLoadingArticle = false
Logger.viewModel.info("📱 Loaded article \(id) from cache")
return
}
// If not cached, fetch from server
do {
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
processArticleContent()

View File

@ -36,14 +36,22 @@ struct BookmarksView: View {
var body: some View {
ZStack {
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
skeletonLoadingView
} else if shouldShowCenteredState {
centeredStateView
} else {
bookmarksList
VStack(spacing: 0) {
// Offline banner
if viewModel.isNetworkError && (viewModel.bookmarks?.bookmarks.isEmpty == false) {
offlineBanner
}
// Main content
if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) {
skeletonLoadingView
} else if shouldShowCenteredState {
centeredStateView
} else {
bookmarksList
}
}
// FAB Button - only show for "Unread" and when not in error/loading state
if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading {
fabButton
@ -86,11 +94,12 @@ struct BookmarksView: View {
}
// MARK: - Computed Properties
private var shouldShowCenteredState: Bool {
let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
let hasError = viewModel.errorMessage != nil
return (isEmpty && viewModel.isLoading) || hasError
// Only show centered state when empty AND error (not just error)
return isEmpty && hasError
}
// MARK: - View Components
@ -276,6 +285,30 @@ struct BookmarksView: View {
}
}
@ViewBuilder
private var offlineBanner: some View {
HStack(spacing: 12) {
Image(systemName: "wifi.slash")
.font(.body)
.foregroundColor(.secondary)
Text("Offline-Modus Zeige gespeicherte Artikel")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemGray6))
.overlay(
Rectangle()
.frame(height: 0.5)
.foregroundColor(Color(.separator)),
alignment: .bottom
)
}
@ViewBuilder
private var fabButton: some View {
VStack {

View File

@ -8,6 +8,7 @@ class BookmarksViewModel {
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let offlineCacheRepository: POfflineCacheRepository
var bookmarks: BookmarksPage?
var isLoading = false
@ -47,9 +48,10 @@ class BookmarksViewModel {
updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase()
loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
offlineCacheRepository = OfflineCacheRepository()
setupNotificationObserver()
Task {
await loadCardLayout()
}
@ -139,6 +141,8 @@ class BookmarksViewModel {
case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
isNetworkError = true
errorMessage = "No internet connection"
// Try to load cached bookmarks
await loadCachedBookmarks()
default:
isNetworkError = false
errorMessage = "Error loading bookmarks"
@ -153,6 +157,28 @@ class BookmarksViewModel {
isLoading = false
isInitialLoading = false
}
@MainActor
private func loadCachedBookmarks() async {
do {
let cachedBookmarks = try await offlineCacheRepository.getCachedBookmarks()
if !cachedBookmarks.isEmpty {
// Create a BookmarksPage from cached bookmarks
bookmarks = BookmarksPage(
bookmarks: cachedBookmarks,
currentPage: 1,
totalCount: cachedBookmarks.count,
totalPages: 1,
links: nil
)
hasMoreData = false
Logger.viewModel.info("📱 Loaded \(cachedBookmarks.count) cached bookmarks for offline mode")
}
} catch {
Logger.viewModel.error("Failed to load cached bookmarks: \(error.localizedDescription)")
}
}
@MainActor
func loadMoreBookmarks() async {

View File

@ -25,6 +25,8 @@ protocol UseCaseFactory {
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
func makeSettingsRepository() -> PSettingsRepository
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase
}
@ -38,6 +40,7 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider)
private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient)
private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api)
private let offlineCacheRepository: POfflineCacheRepository = OfflineCacheRepository()
static let shared = DefaultUseCaseFactory()
@ -144,4 +147,16 @@ class DefaultUseCaseFactory: UseCaseFactory {
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
return DeleteAnnotationUseCase(repository: annotationsRepository)
}
func makeSettingsRepository() -> PSettingsRepository {
return settingsRepository
}
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase {
return OfflineCacheSyncUseCase(
offlineCacheRepository: offlineCacheRepository,
bookmarksRepository: bookmarksRepository,
settingsRepository: settingsRepository
)
}
}

View File

@ -104,6 +104,14 @@ class MockUseCaseFactory: UseCaseFactory {
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase {
MockDeleteAnnotationUseCase()
}
func makeSettingsRepository() -> PSettingsRepository {
MockSettingsRepository()
}
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase {
MockOfflineCacheSyncUseCase()
}
}
@ -280,6 +288,49 @@ class MockDeleteAnnotationUseCase: PDeleteAnnotationUseCase {
}
}
class MockSettingsRepository: PSettingsRepository {
var hasFinishedSetup: Bool = true
func saveSettings(_ settings: Settings) async throws {}
func loadSettings() async throws -> Settings? {
return Settings(endpoint: "mock-endpoint", username: "mock-user", password: "mock-pw", token: "mock-token", fontFamily: .system, fontSize: .medium, hasFinishedSetup: true)
}
func clearSettings() async throws {}
func saveToken(_ token: String) async throws {}
func saveUsername(_ username: String) async throws {}
func savePassword(_ password: String) async throws {}
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws {}
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws {}
func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws {}
func loadCardLayoutStyle() async throws -> CardLayoutStyle { return .magazine }
func saveTagSortOrder(_ tagSortOrder: TagSortOrder) async throws {}
func loadTagSortOrder() async throws -> TagSortOrder { return .byCount }
func loadOfflineSettings() async throws -> OfflineSettings {
return OfflineSettings()
}
func saveOfflineSettings(_ settings: OfflineSettings) async throws {}
}
class MockOfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> {
Just(false).eraseToAnyPublisher()
}
var syncProgress: AnyPublisher<String?, Never> {
Just(nil).eraseToAnyPublisher()
}
func syncOfflineArticles(settings: OfflineSettings) async {}
func getCachedArticlesCount() -> Int {
return 0
}
func getCacheSize() -> String {
return "0 KB"
}
}
extension Bookmark {
static let mock: Bookmark = .init(
id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil)

View File

@ -0,0 +1,158 @@
//
// OfflineSettingsView.swift
// readeck
//
// Created by Claude on 17.11.25.
//
import SwiftUI
struct OfflineSettingsView: View {
@State private var viewModel = OfflineSettingsViewModel()
var body: some View {
Group {
Section {
VStack(alignment: .leading, spacing: 4) {
Toggle("Offline-Reading aktivieren", isOn: $viewModel.offlineSettings.enabled)
.onChange(of: viewModel.offlineSettings.enabled) {
Task {
await viewModel.saveSettings()
}
}
Text("Lade automatisch Artikel für die Offline-Nutzung herunter.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
if viewModel.offlineSettings.enabled {
// Max articles slider
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Maximale Artikel")
Spacer()
Text("\(viewModel.offlineSettings.maxUnreadArticlesInt)")
.font(.caption)
.foregroundColor(.secondary)
}
Slider(
value: $viewModel.offlineSettings.maxUnreadArticles,
in: 0...100,
step: 10
) {
Text("Max. Artikel offline")
}
.onChange(of: viewModel.offlineSettings.maxUnreadArticles) {
Task {
await viewModel.saveSettings()
}
}
}
// Save images toggle
VStack(alignment: .leading, spacing: 4) {
Toggle("Bilder speichern", isOn: $viewModel.offlineSettings.saveImages)
.onChange(of: viewModel.offlineSettings.saveImages) {
Task {
await viewModel.saveSettings()
}
}
Text("Lädt auch Bilder für die Offline-Nutzung herunter.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
}
// Sync button
Button(action: {
Task {
await viewModel.syncNow()
}
}) {
HStack {
if viewModel.isSyncing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 2) {
Text("Jetzt synchronisieren")
.foregroundColor(viewModel.isSyncing ? .secondary : .blue)
if let progress = viewModel.syncProgress {
Text(progress)
.font(.caption)
.foregroundColor(.secondary)
} else if let lastSync = viewModel.offlineSettings.lastSyncDate {
Text("Zuletzt: \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
.disabled(viewModel.isSyncing)
// Cache stats
if viewModel.cachedArticlesCount > 0 {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Gespeicherte Artikel")
Text("\(viewModel.cachedArticlesCount) Artikel (\(viewModel.cacheSize))")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
#if DEBUG
// Debug: Force offline mode
Button(action: {
simulateOfflineMode()
}) {
HStack {
Image(systemName: "airplane")
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Offline-Modus simulieren")
.foregroundColor(.orange)
Text("DEBUG: Netzwerk temporär deaktivieren")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
#endif
}
} header: {
Text("Offline-Reading")
}
}
.task {
await viewModel.loadSettings()
}
}
#if DEBUG
private func simulateOfflineMode() {
// Post notification to simulate offline mode
NotificationCenter.default.post(
name: Notification.Name("SimulateOfflineMode"),
object: nil
)
}
#endif
}

View File

@ -0,0 +1,89 @@
//
// OfflineSettingsViewModel.swift
// readeck
//
// Created by Claude on 17.11.25.
//
import Foundation
import Observation
import Combine
@Observable
class OfflineSettingsViewModel {
// MARK: - Dependencies
private let settingsRepository: PSettingsRepository
private let offlineCacheSyncUseCase: POfflineCacheSyncUseCase
private var cancellables = Set<AnyCancellable>()
// MARK: - Published State
var offlineSettings: OfflineSettings = OfflineSettings()
var isSyncing: Bool = false
var syncProgress: String?
var cachedArticlesCount: Int = 0
var cacheSize: String = "0 KB"
// MARK: - Initialization
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.settingsRepository = factory.makeSettingsRepository()
self.offlineCacheSyncUseCase = factory.makeOfflineCacheSyncUseCase()
setupBindings()
}
// MARK: - Setup
private func setupBindings() {
// Bind isSyncing from UseCase
offlineCacheSyncUseCase.isSyncing
.receive(on: DispatchQueue.main)
.assign(to: \.isSyncing, on: self)
.store(in: &cancellables)
// Bind syncProgress from UseCase
offlineCacheSyncUseCase.syncProgress
.receive(on: DispatchQueue.main)
.assign(to: \.syncProgress, on: self)
.store(in: &cancellables)
}
// MARK: - Public Methods
@MainActor
func loadSettings() async {
do {
offlineSettings = try await settingsRepository.loadOfflineSettings()
updateCacheStats()
Logger.viewModel.debug("Loaded offline settings: enabled=\(offlineSettings.enabled)")
} catch {
Logger.viewModel.error("Failed to load offline settings: \(error.localizedDescription)")
}
}
@MainActor
func saveSettings() async {
do {
try await settingsRepository.saveOfflineSettings(offlineSettings)
Logger.viewModel.debug("Saved offline settings")
} catch {
Logger.viewModel.error("Failed to save offline settings: \(error.localizedDescription)")
}
}
@MainActor
func syncNow() async {
Logger.viewModel.info("Manual sync triggered")
await offlineCacheSyncUseCase.syncOfflineArticles(settings: offlineSettings)
updateCacheStats()
}
@MainActor
func updateCacheStats() {
cachedArticlesCount = offlineCacheSyncUseCase.getCachedArticlesCount()
cacheSize = offlineCacheSyncUseCase.getCacheSize()
}
}

View File

@ -25,6 +25,8 @@ struct SettingsContainerView: View {
SyncSettingsView()
OfflineSettingsView()
SettingsServerView()
LegalPrivacySettingsView()