diff --git a/documentation/Offline-Stufe1-Implementation-Tracking.md b/documentation/Offline-Stufe1-Implementation-Tracking.md
index 9743015..6246719 100644
--- a/documentation/Offline-Stufe1-Implementation-Tracking.md
+++ b/documentation/Offline-Stufe1-Implementation-Tracking.md
@@ -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 `
` 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 `
` 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`
- - [ ] `var syncProgress: AnyPublisher`
-- [ ] Methoden deklarieren:
- - [ ] `func syncOfflineArticles(settings:) async`
- - [ ] `func getCachedArticlesCount() -> Int`
- - [ ] `func getCacheSize() -> String`
+- [x] Protocol `POfflineCacheSyncUseCase` erstellen
+- [x] Published Properties definieren:
+ - [x] `var isSyncing: AnyPublisher`
+ - [x] `var syncProgress: AnyPublisher`
+- [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(false)`
+ - [x] `_syncProgressSubject = CurrentValueSubject(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
---
diff --git a/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift b/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift
index 49699c2..8d46809 100644
--- a/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift
+++ b/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift
@@ -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(false)
+ private let _syncProgressSubject = CurrentValueSubject(nil)
var isSyncing: AnyPublisher {
- $_isSyncing.eraseToAnyPublisher()
+ _isSyncingSubject.eraseToAnyPublisher()
}
var syncProgress: AnyPublisher {
- $_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 {
diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift
index 43f3277..ba677b3 100644
--- a/readeck/UI/AppViewModel.swift
+++ b/readeck/UI/AppViewModel.swift
@@ -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)
}
diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift
index 23d70f4..02337e1 100644
--- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift
+++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift
@@ -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()
diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift
index f92b1ce..c036a99 100644
--- a/readeck/UI/Bookmarks/BookmarksView.swift
+++ b/readeck/UI/Bookmarks/BookmarksView.swift
@@ -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 {
diff --git a/readeck/UI/Bookmarks/BookmarksViewModel.swift b/readeck/UI/Bookmarks/BookmarksViewModel.swift
index f2c569f..4f8ef20 100644
--- a/readeck/UI/Bookmarks/BookmarksViewModel.swift
+++ b/readeck/UI/Bookmarks/BookmarksViewModel.swift
@@ -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 {
diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift
index 7ed78fe..b68ac12 100644
--- a/readeck/UI/Factory/DefaultUseCaseFactory.swift
+++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift
@@ -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
+ )
+ }
}
diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift
index a6659fc..3e6232f 100644
--- a/readeck/UI/Factory/MockUseCaseFactory.swift
+++ b/readeck/UI/Factory/MockUseCaseFactory.swift
@@ -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 {
+ Just(false).eraseToAnyPublisher()
+ }
+
+ var syncProgress: AnyPublisher {
+ 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)
diff --git a/readeck/UI/Settings/OfflineSettingsView.swift b/readeck/UI/Settings/OfflineSettingsView.swift
new file mode 100644
index 0000000..75129a8
--- /dev/null
+++ b/readeck/UI/Settings/OfflineSettingsView.swift
@@ -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
+}
diff --git a/readeck/UI/Settings/OfflineSettingsViewModel.swift b/readeck/UI/Settings/OfflineSettingsViewModel.swift
new file mode 100644
index 0000000..4439b98
--- /dev/null
+++ b/readeck/UI/Settings/OfflineSettingsViewModel.swift
@@ -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()
+
+ // 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()
+ }
+}
diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift
index f466ee8..ae74d88 100644
--- a/readeck/UI/Settings/SettingsContainerView.swift
+++ b/readeck/UI/Settings/SettingsContainerView.swift
@@ -25,6 +25,8 @@ struct SettingsContainerView: View {
SyncSettingsView()
+ OfflineSettingsView()
+
SettingsServerView()
LegalPrivacySettingsView()