ReadKeep/docs/Offline-Stufe1-Implementierung.md

46 KiB
Raw Permalink Blame History

Offline Stufe 1: Smart Cache für Unread Items - Implementierungsplan

Übersicht

Implementierung eines intelligenten Cache-Systems für ungelesene Artikel mit konfigurierbarer Anzahl (max. 100 Artikel). Die App lädt automatisch Artikel im Hintergrund herunter und macht sie offline verfügbar.

Wichtigste Änderungen

Offline-Modus Verhalten

  • Keine gecachten Artikel anzeigen per Icon: Im Online-Modus gibt es KEINE Indikatoren für gecachte Artikel
  • Offline-Modus automatisch: Wenn keine Netzwerkverbindung besteht, werden automatisch nur die gecachten Artikel angezeigt
  • Unaufdringlicher Banner: Kleiner Banner über der Liste zeigt "Offline-Modus Zeige gespeicherte Artikel"
  • Alle Tabs navigierbar: User kann weiterhin durch alle Tabs navigieren (kein Full-Screen Error)
  • Intelligenter Sync: Nur alle 4 Stunden beim App-Start (verhindert unnötige Syncs)
  • Background Sync: Läuft mit niedriger Priorität (.background), keine Performance-Einbuße

Technische Details

  • 🔹 Standard API-Call: Nutzt getBookmarks(state: .unread, limit: X) für Sync
  • 🔹 CoreData mit JSON: Speichert komplettes Bookmark-Objekt + HTML-Content
  • 🔹 Kingfisher für Bilder: Bilder werden via Kingfisher gecacht (bereits im Projekt konfiguriert)
  • 🔹 FIFO Cleanup: Automatisches Löschen ältester Artikel bei Überschreitung des Limits
  • 🔹 Default: 20 Artikel: Reduziert initiale Sync-Last (21 API-Calls ohne Bilder)
  • 🔹 Background Priority: Sync läuft mit .background oder .utility QoS (Quality of Service)

Features

Automatisches Caching

  • Beim App-Start: Artikel werden automatisch heruntergeladen, wenn mehr als 4 Stunden seit letztem Sync vergangen sind
  • Intelligentes Timing: Verhindert unnötige Syncs bei häufigem App-Öffnen
  • Background-Task mit niedriger Priorität: Läuft mit .background oder .utility Priority
  • Nicht-blockierend: User kann App normal nutzen während Sync läuft
  • Keine UI-Blockierung: Sync läuft komplett im Hintergrund ohne Performance-Impact
  • Standard API-Call: Nutzt den normalen getBookmarks-Endpoint mit entsprechenden Parametern

Konfigurierbare Einstellungen

  • Toggle für Offline-Reading (Ein/Aus, Default: true)
  • Slider für Anzahl der zu cachenden Artikel (0-100, Default: 20)
  • Toggle für Speichern von Bildern (Default: false)
  • Manueller Sync-Button
  • Anzeige des letzten Sync-Zeitpunkts

Warum Default 20 Artikel?

  • Reduziert Netzwerk-Last beim initialen Sync
  • Schnellerer erster Sync für bessere User Experience
  • Bei 20 Artikeln: ~20 API-Calls für HTML + ggf. Bild-Downloads
  • User kann bei Bedarf auf bis zu 100 erhöhen

Offline-Modus Detection

  • Automatische Erkennung: App erkennt automatisch, wenn keine Netzwerkverbindung besteht
  • Alle Tabs verfügbar: User kann weiterhin durch alle Tabs navigieren
  • Unaufdringlicher Banner: Kleiner Banner über den Unread-Artikeln zeigt Offline-Status
  • Gecachte Artikel anzeigen: Nur gecachte Artikel werden im Offline-Modus angezeigt
  • Bestehende Error-Logik erweitern: Nutzt vorhandene isNetworkError und zeigt gecachte Artikel statt Fehlermeldung

Automatische Verwaltung

  • FIFO-Prinzip: Älteste Artikel werden automatisch gelöscht, wenn neue hinzukommen
  • Cache bleibt innerhalb der konfigurierten Grenzen
  • Cleanup bei Logout oder Deaktivierung

UI im Offline-Modus

  • Kein Full-Screen Error: Stattdessen werden gecachte Artikel angezeigt
  • Offline-Banner: Kleiner, unaufdringlicher Banner über der Liste
  • Alle Tabs navigierbar: Keine Einschränkung der Navigation
  • Nur gecachte Inhalte: Nur offline verfügbare Artikel werden angezeigt

UI/UX Design - Settings

Neue Settings-Sektion: "Offline-Reading"

Section {
    // Toggle für Offline-Reading
    Toggle("Offline-Reading", isOn: $offlineSettings.enabled)

    if offlineSettings.enabled {
        // Erklärungstext
        Text("Ungelesene Artikel werden automatisch heruntergeladen und sind offline verfügbar. Änderungen werden synchronisiert, sobald du wieder online bist.")
            .font(.caption)
            .foregroundColor(.secondary)
            .padding(.vertical, 4)

        // Anzahl Max Unread Articles
        VStack(alignment: .leading, spacing: 4) {
            HStack {
                Text("Max. Artikel offline")
                Spacer()
                Text("\(Int(offlineSettings.maxUnreadArticles))")
                    .foregroundColor(.secondary)
            }
            Slider(
                value: $offlineSettings.maxUnreadArticles,
                in: 0...100,
                step: 10
            )
        }

        // Bilder speichern
        Toggle("Bilder speichern", isOn: $offlineSettings.saveImages)

        // Manual Sync Button
        Button(action: {
            Task {
                await offlineCacheManager.syncOfflineArticles()
            }
        }) {
            HStack {
                Text("Jetzt synchronisieren")
                Spacer()
                if offlineCacheManager.isSyncing {
                    ProgressView()
                }
            }
        }
        .disabled(offlineCacheManager.isSyncing)

        // Last Sync Date
        if let lastSync = offlineSettings.lastSyncDate {
            Text("Zuletzt synchronisiert: \(lastSync, formatter: relativeDateFormatter)")
                .font(.caption)
                .foregroundColor(.secondary)
        }

        // Cache-Größe
        if offlineCacheManager.cachedArticlesCount > 0 {
            HStack {
                Text("Gespeicherte Artikel")
                Spacer()
                Text("\(offlineCacheManager.cachedArticlesCount) Artikel, \(offlineCacheManager.cacheSize)")
                    .foregroundColor(.secondary)
            }
            .font(.caption)
        }
    }
} header: {
    Text("Offline-Reading")
}

Netzwerk-Flow beim Sync

Der Sync-Prozess läuft in mehreren Schritten ab, um Netzwerk-Last zu minimieren und Fehler besser zu behandeln:

Schritt 1: Lade Bookmark-Liste

GET /api/bookmarks?is_archived=false&is_marked=false&limit=20
  • Lädt die ersten N ungelesenen Artikel (Default: 20)
  • Nutzt den bestehenden getBookmarks-Call mit entsprechenden Parametern
  • Enthält alle Bookmark-Metadaten (Titel, URL, Datum, etc.)

Schritt 2: Lade HTML für jeden Artikel

Für jeden Bookmark:
  GET /api/bookmarks/{id}/article
  • Sequenziell für jeden Artikel (nicht parallel, um Server nicht zu überlasten)
  • Lädt den kompletten HTML-Content des Artikels
  • Bei Fehler: Artikel wird übersprungen, Sync läuft weiter

Schritt 3: Extrahiere Bild-URLs (Optional)

Falls saveImages = true:
  - Parse HTML mit Regex nach <img src="...">
  - Extrahiere alle absolute URLs
  - Speichere URLs in CachedArticle.imageURLs
  • Keine zusätzlichen API-Calls nötig
  • URLs werden für Schritt 4 vorbereitet

Schritt 4: Lade Bilder mit Kingfisher (Optional)

Falls saveImages = true:
  - Kingfisher ImagePrefetcher mit allen URLs
  - Lädt Bilder im Hintergrund
  - Fehler bei einzelnen Bildern stoppen Sync nicht
  • Parallel durch Kingfisher (effizient)
  • Nutzt bestehende Kingfisher-Cache-Konfiguration
  • Automatisches Retry bei temporären Fehlern

Schritt 5: Speichere in CoreData

Für jeden erfolgreichen Download:
  - Speichere Bookmark-JSON + HTML in CachedArticle
  - Speichere Metadaten (cachedDate, size, etc.)

Schritt 6: Cleanup

- Lösche älteste Artikel wenn Limit überschritten
- Update lastSyncDate in Settings

Beispiel-Rechnung für 20 Artikel:

Ohne Bilder:

  • 1x API-Call für Bookmark-Liste
  • 20x API-Calls für HTML (sequenziell)
  • Total: 21 API-Calls
  • Dauer: ~5-10 Sekunden (je nach Server-Geschwindigkeit)

Mit Bildern (Ø 5 Bilder pro Artikel):

  • 1x API-Call für Bookmark-Liste
  • 20x API-Calls für HTML (sequenziell)
  • ~100x Image-Downloads (parallel durch Kingfisher)
  • Total: 121 Downloads
  • Dauer: ~15-30 Sekunden (je nach Bildgröße und Netzwerk)

Fehlerbehandlung:

  • API-Fehler bei einzelnem Artikel: Überspringen, nächsten versuchen
  • Netzwerk komplett weg: Sync abbrechen, Fehlermeldung zeigen
  • Speicher voll: Sync stoppen, User informieren
  • Partial Success: Zeige "X von Y Artikeln synchronisiert"

Background Task Priority

Um die App-Performance nicht zu beeinträchtigen, läuft der Sync mit niedriger Priorität:

// Sync mit Background Priority starten
Task.detached(priority: .background) {
    await offlineCacheSyncUseCase.syncOfflineArticles(settings: settings)
}

Quality of Service (QoS) Optionen:

  • .background (Empfohlen): Niedrigste Priorität, läuft nur wenn System idle ist
  • .utility: Niedrige Priorität, für länger laufende Tasks mit Fortschrittsanzeige
  • .userInitiated: Nur für manuellen Sync-Button (User wartet aktiv)

Vorteile:

  • Keine Blockierung der Main-Thread
  • Keine spürbare Performance-Einbuße
  • System kann Task pausieren bei Ressourcen-Knappheit
  • Batterie-schonend durch intelligentes Scheduling

Implementation Details:

  • Auto-Sync bei App-Start: .background Priority
  • Manueller Sync-Button: .utility Priority (mit Progress-UI)
  • Kingfisher Prefetch: Automatisch mit niedriger Priority

Technische Implementierung

1. Datenmodelle

OfflineSettings Model

Datei: readeck/Domain/Model/OfflineSettings.swift

import Foundation

struct OfflineSettings: Codable {
    var enabled: Bool = true
    var maxUnreadArticles: Double = 20 // Double für Slider (Default: 20 Artikel)
    var saveImages: Bool = false
    var lastSyncDate: Date?

    var maxUnreadArticlesInt: Int {
        Int(maxUnreadArticles)
    }

    var shouldSyncOnAppStart: Bool {
        guard enabled else { return false }

        // Sync if never synced before
        guard let lastSync = lastSyncDate else { return true }

        // Sync if more than 4 hours since last sync
        let fourHoursAgo = Date().addingTimeInterval(-4 * 60 * 60)
        return lastSync < fourHoursAgo
    }
}

CachedArticle Entity (CoreData)

Datei: readeck.xcdatamodeld (CoreData Schema)

WICHTIG:

  • Wir speichern sowohl den HTML-Content als auch die kompletten Bookmark-Metadaten als JSON, damit wir im Offline-Modus die Liste vollständig anzeigen können.
  • Bilder werden NICHT in CoreData gespeichert, sondern über Kingfisher gecacht (bereits im Projekt vorhanden und konfiguriert).
entity CachedArticle {
    id: String (indexed, primary key)
    bookmarkId: String (indexed, unique)
    bookmarkJSON: String // Komplettes Bookmark-Objekt als JSON
    htmlContent: String // Artikel-HTML
    cachedDate: Date (indexed)
    lastAccessDate: Date
    size: Int64 // in Bytes (nur HTML, nicht Bilder)
    imageURLs: String? // Komma-separierte Liste der Bild-URLs (für Kingfisher Prefetch)
}

Keine separate CachedImage Entity nötig - Kingfisher verwaltet den Image-Cache automatisch mit den bestehenden Settings in CacheSettingsView.swift!

Mapping Helpers:

extension CachedArticle {
    func toBookmark() throws -> Bookmark {
        guard let json = bookmarkJSON,
              let data = json.data(using: .utf8) else {
            throw NSError(domain: "CachedArticle", code: 1, userInfo: nil)
        }

        return try JSONDecoder().decode(Bookmark.self, from: data)
    }

    static func from(bookmark: Bookmark, html: String, imageURLs: [String] = []) throws -> CachedArticle {
        let cached = CachedArticle()
        cached.id = UUID().uuidString
        cached.bookmarkId = bookmark.id

        let encoder = JSONEncoder()
        let jsonData = try encoder.encode(bookmark)
        cached.bookmarkJSON = String(data: jsonData, encoding: .utf8)

        cached.htmlContent = html
        cached.cachedDate = Date()
        cached.lastAccessDate = Date()
        cached.size = Int64(html.utf8.count)

        // Store image URLs for Kingfisher prefetch
        if !imageURLs.isEmpty {
            cached.imageURLs = imageURLs.joined(separator: ",")
        }

        return cached
    }

    func getImageURLs() -> [URL] {
        guard let imageURLs = imageURLs else { return [] }
        return imageURLs.split(separator: ",")
            .compactMap { URL(string: String($0)) }
    }
}

2. Repository Layer

PBookmarksRepository erweitern

Datei: readeck/Domain/Protocols/PBookmarksRepository.swift

protocol PBookmarksRepository {
    // ... existing methods

    // Offline Cache Methods
    func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws
    func getCachedArticle(id: String) -> String?
    func hasCachedArticle(id: String) -> Bool
    func getCachedArticlesCount() -> Int
    func getCacheSize() -> String
    func getCachedBookmarks() async throws -> [Bookmark]
    func clearCache() async throws
    func cleanupOldestCachedArticles(keepCount: Int) async throws
}

BookmarksRepository Implementation

Datei: readeck/Data/Repository/BookmarksRepository.swift

WICHTIG: Für das Caching der Artikel-Metadaten müssen wir das Bookmark-Objekt mit speichern, damit wir im Offline-Modus die komplette Liste anzeigen können.

Erweitern mit:

func cacheBookmarkWithMetadata(bookmark: Bookmark, html: String, saveImages: Bool) async throws {
    // 1. Prüfen ob bereits gecacht
    if hasCachedArticle(id: bookmark.id) {
        return
    }

    // 2. Bookmark + HTML speichern in CoreData
    let context = CoreDataManager.shared.context
    try await context.perform {
        let cachedArticle = CachedArticle(context: context)

        // Bookmark-Metadaten als JSON speichern
        let encoder = JSONEncoder()
        let bookmarkData = try encoder.encode(bookmark)
        let bookmarkJSON = String(data: bookmarkData, encoding: .utf8)

        cachedArticle.id = UUID().uuidString
        cachedArticle.bookmarkId = bookmark.id
        cachedArticle.bookmarkJSON = bookmarkJSON
        cachedArticle.htmlContent = html
        cachedArticle.cachedDate = Date()
        cachedArticle.lastAccessDate = Date()
        cachedArticle.size = Int64(html.utf8.count)
        cachedArticle.hasImages = saveImages

        CoreDataManager.shared.save()
    }

    // 3. Bilder mit Kingfisher prefetchen (außerhalb CoreData context)
    if saveImages {
        let imageURLs = extractImageURLsFromHTML(html: html)
        cachedArticle.imageURLs = imageURLs.joined(separator: ",")

        // Prefetch images with Kingfisher
        Task.detached {
            await self.prefetchImagesWithKingfisher(imageURLs: imageURLs)
        }
    }
}

private func extractImageURLsFromHTML(html: String) -> [String] {
    // Extract all <img src="..."> URLs from HTML
    var imageURLs: [String] = []

    // Simple regex pattern for img tags
    let pattern = #"<img[^>]+src=\"([^\"]+)\""#

    if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
        let nsString = html as NSString
        let results = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length))

        for result in results {
            if result.numberOfRanges >= 2 {
                let urlRange = result.range(at: 1)
                if let url = nsString.substring(with: urlRange) as String? {
                    // Handle relative URLs
                    if url.hasPrefix("http") {
                        imageURLs.append(url)
                    }
                }
            }
        }
    }

    return imageURLs
}

private func prefetchImagesWithKingfisher(imageURLs: [String]) async {
    let urls = imageURLs.compactMap { URL(string: $0) }

    guard !urls.isEmpty else { return }

    // Use Kingfisher's prefetcher mit niedriger Priorität
    let prefetcher = ImagePrefetcher(urls: urls) { skippedResources, failedResources, completedResources in
        print("Prefetch completed: \(completedResources.count)/\(urls.count) images cached")
        if !failedResources.isEmpty {
            print("Failed to cache \(failedResources.count) images")
        }
    }

    // Optional: Setze Download-Priority auf .low für Background-Downloads
    // prefetcher.options = [.downloadPriority(.low)]

    prefetcher.start()
}

func getCachedArticle(id: String) -> String? {
    let fetchRequest: NSFetchRequest<CachedArticle> = CachedArticle.fetchRequest()
    fetchRequest.predicate = NSPredicate(format: "bookmarkId == %@", id)
    fetchRequest.fetchLimit = 1

    do {
        let results = try CoreDataManager.shared.context.fetch(fetchRequest)
        if let cached = results.first {
            // Update last access date
            cached.lastAccessDate = Date()
            CoreDataManager.shared.save()
            return cached.htmlContent
        }
    } catch {
        print("Error fetching cached article: \(error)")
    }

    return nil
}

func hasCachedArticle(id: String) -> Bool {
    return getCachedArticle(id: id) != nil
}

func getCachedArticlesCount() -> Int {
    let fetchRequest: NSFetchRequest<CachedArticle> = CachedArticle.fetchRequest()
    return (try? CoreDataManager.shared.context.count(for: fetchRequest)) ?? 0
}

func getCacheSize() -> String {
    let fetchRequest: NSFetchRequest<CachedArticle> = CachedArticle.fetchRequest()

    do {
        let articles = try CoreDataManager.shared.context.fetch(fetchRequest)
        let totalBytes = articles.reduce(0) { $0 + $1.size }
        return ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file)
    } catch {
        return "0 KB"
    }
}

func getCachedBookmarks() async throws -> [Bookmark] {
    let fetchRequest: NSFetchRequest<CachedArticle> = CachedArticle.fetchRequest()
    // Sort by cached date, newest first
    fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: false)]

    let context = CoreDataManager.shared.context
    return try await context.perform {
        let cachedArticles = try context.fetch(fetchRequest)
        return cachedArticles.compactMap { cached -> Bookmark? in
            try? cached.toBookmark()
        }
    }
}

func clearCache() async throws {
    let fetchRequest: NSFetchRequest<NSFetchRequestResult> = CachedArticle.fetchRequest()
    let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)

    try await CoreDataManager.shared.context.perform {
        try CoreDataManager.shared.context.execute(deleteRequest)
        CoreDataManager.shared.save()
    }

    // Optional: Auch Kingfisher-Cache löschen
    // KingfisherManager.shared.cache.clearDiskCache()
    // KingfisherManager.shared.cache.clearMemoryCache()
}

func cleanupOldestCachedArticles(keepCount: Int) async throws {
    let fetchRequest: NSFetchRequest<CachedArticle> = CachedArticle.fetchRequest()
    fetchRequest.sortDescriptors = [NSSortDescriptor(key: "cachedDate", ascending: true)]

    let context = CoreDataManager.shared.context
    try await context.perform {
        let allArticles = try context.fetch(fetchRequest)

        // Delete oldest articles if we exceed keepCount
        if allArticles.count > keepCount {
            let articlesToDelete = allArticles.prefix(allArticles.count - keepCount)
            articlesToDelete.forEach { context.delete($0) }
            CoreDataManager.shared.save()
        }
    }
}

Wichtig zu Kingfisher:

  • Import erforderlich: import Kingfisher in BookmarksRepository.swift
  • Kingfisher ist bereits konfiguriert mit Cache-Limits (siehe CacheSettingsView.swift)
  • Der User kann die Cache-Größe bereits in den Settings anpassen (50-1200 MB)
  • Kingfisher verwaltet automatisch das Löschen alter Bilder basierend auf Speicherplatz-Limits
  • Die ImagePrefetcher API lädt alle Bilder im Hintergrund herunter und cached sie

3. Use Cases

OfflineCacheSyncUseCase

Datei: readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift

import Foundation
import Combine

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
}

// WICHTIG: Der UseCase selbst läuft synchron auf dem aufrufenden Thread.
// Die Background-Priority wird vom Caller gesetzt (z.B. Task.detached(priority: .background))
// Dadurch ist der UseCase flexibel für verschiedene Prioritäten:
// - Auto-Sync: .background
// - Manual-Sync: .utility oder .userInitiated

class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
    private let bookmarksRepository: PBookmarksRepository
    private let settingsRepository: PSettingsRepository

    @Published private var _isSyncing = false
    @Published private var _syncProgress: String?

    var isSyncing: AnyPublisher<Bool, Never> {
        $_isSyncing.eraseToAnyPublisher()
    }

    var syncProgress: AnyPublisher<String?, Never> {
        $_syncProgress.eraseToAnyPublisher()
    }

    init(
        bookmarksRepository: PBookmarksRepository,
        settingsRepository: PSettingsRepository
    ) {
        self.bookmarksRepository = bookmarksRepository
        self.settingsRepository = settingsRepository
    }

    func syncOfflineArticles(settings: OfflineSettings) async {
        guard settings.enabled else { return }

        await MainActor.run {
            _isSyncing = true
            _syncProgress = "Lade ungelesene Artikel..."
        }

        do {
            // 1. Fetch unread bookmarks (limit by maxUnreadArticles)
            let bookmarksPage = try await bookmarksRepository.fetchBookmarks(
                state: .unread,
                limit: settings.maxUnreadArticlesInt,
                offset: nil,
                search: nil,
                type: nil,
                tag: nil
            )

            let bookmarks = bookmarksPage.bookmarks

            await MainActor.run {
                _syncProgress = "Laden \(bookmarks.count) Artikel..."
            }

            // 2. Download articles with metadata (sequenziell)
            var successCount = 0
            var skipCount = 0
            var errorCount = 0

            for (index, bookmark) in bookmarks.enumerated() {
                // Skip if already cached
                if bookmarksRepository.hasCachedArticle(id: bookmark.id) {
                    skipCount += 1
                    await MainActor.run {
                        _syncProgress = "Artikel \(index + 1)/\(bookmarks.count) (bereits gecacht)..."
                    }
                    continue
                }

                await MainActor.run {
                    _syncProgress = "Lade Artikel \(index + 1)/\(bookmarks.count)..."
                }

                // Download article HTML
                do {
                    let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id)

                    // Cache article with bookmark metadata
                    try await bookmarksRepository.cacheBookmarkWithMetadata(
                        bookmark: bookmark,
                        html: html,
                        saveImages: settings.saveImages
                    )

                    successCount += 1

                    // Optional: Show image download progress
                    if settings.saveImages {
                        await MainActor.run {
                            _syncProgress = "Artikel \(index + 1)/\(bookmarks.count) + Bilder..."
                        }
                    }
                } catch {
                    print("Failed to cache article \(bookmark.id): \(error)")
                    errorCount += 1
                    continue
                }
            }

            // 3. Cleanup old articles (keep only maxUnreadArticles)
            try await bookmarksRepository.cleanupOldestCachedArticles(
                keepCount: settings.maxUnreadArticlesInt
            )

            // 4. Update last sync date
            var updatedSettings = settings
            updatedSettings.lastSyncDate = Date()
            try await settingsRepository.saveOfflineSettings(updatedSettings)

            // Show final status
            let statusMessage: String
            if errorCount == 0 && successCount > 0 {
                statusMessage = "✅ \(successCount) Artikel synchronisiert"
            } else if successCount > 0 && errorCount > 0 {
                statusMessage = "⚠️ \(successCount) synchronisiert, \(errorCount) fehlgeschlagen"
            } else if skipCount == bookmarks.count {
                statusMessage = " Alle Artikel bereits gecacht"
            } else {
                statusMessage = "❌ Synchronisierung fehlgeschlagen"
            }

            await MainActor.run {
                _isSyncing = false
                _syncProgress = statusMessage
            }

            // Clear success message after 3 seconds
            try? await Task.sleep(nanoseconds: 3_000_000_000)
            await MainActor.run {
                _syncProgress = nil
            }

        } catch {
            await MainActor.run {
                _isSyncing = false
                _syncProgress = "❌ Fehler: \(error.localizedDescription)"
            }

            // Clear error message after 5 seconds
            try? await Task.sleep(nanoseconds: 5_000_000_000)
            await MainActor.run {
                _syncProgress = nil
            }
        }
    }

    func getCachedArticlesCount() -> Int {
        return bookmarksRepository.getCachedArticlesCount()
    }

    func getCacheSize() -> String {
        return bookmarksRepository.getCacheSize()
    }
}

4. Settings Repository

PSettingsRepository erweitern

Datei: readeck/Domain/Protocols/PSettingsRepository.swift (falls vorhanden, sonst neu erstellen)

protocol PSettingsRepository {
    func loadOfflineSettings() async throws -> OfflineSettings
    func saveOfflineSettings(_ settings: OfflineSettings) async throws
}

SettingsRepository Implementation

Datei: readeck/Data/Repository/SettingsRepository.swift

import Foundation

class SettingsRepository: PSettingsRepository {
    private let userDefaults = UserDefaults.standard
    private let offlineSettingsKey = "offlineSettings"

    func loadOfflineSettings() async throws -> OfflineSettings {
        guard let data = userDefaults.data(forKey: offlineSettingsKey) else {
            return OfflineSettings() // Default settings
        }

        let decoder = JSONDecoder()
        return try decoder.decode(OfflineSettings.self, from: data)
    }

    func saveOfflineSettings(_ settings: OfflineSettings) async throws {
        let encoder = JSONEncoder()
        let data = try encoder.encode(settings)
        userDefaults.set(data, forKey: offlineSettingsKey)
    }
}

5. ViewModel für Settings

OfflineSettingsViewModel

Datei: readeck/UI/Settings/OfflineSettingsViewModel.swift

import Foundation
import Combine

@MainActor
class OfflineSettingsViewModel: ObservableObject {
    @Published var offlineSettings: OfflineSettings
    @Published var isSyncing = false
    @Published var syncProgress: String?
    @Published var cachedArticlesCount = 0
    @Published var cacheSize = "0 KB"

    private let settingsRepository: PSettingsRepository
    private let offlineCacheSyncUseCase: POfflineCacheSyncUseCase
    private var cancellables = Set<AnyCancellable>()

    init(
        settingsRepository: PSettingsRepository,
        offlineCacheSyncUseCase: POfflineCacheSyncUseCase
    ) {
        self.settingsRepository = settingsRepository
        self.offlineCacheSyncUseCase = offlineCacheSyncUseCase
        self.offlineSettings = OfflineSettings()

        setupBindings()

        Task {
            await loadSettings()
            updateCacheStats()
        }
    }

    private func setupBindings() {
        offlineCacheSyncUseCase.isSyncing
            .assign(to: &$isSyncing)

        offlineCacheSyncUseCase.syncProgress
            .assign(to: &$syncProgress)

        // Auto-save when settings change
        $offlineSettings
            .debounce(for: 0.5, scheduler: DispatchQueue.main)
            .sink { [weak self] settings in
                Task {
                    try? await self?.settingsRepository.saveOfflineSettings(settings)
                }
            }
            .store(in: &cancellables)
    }

    func loadSettings() async {
        do {
            offlineSettings = try await settingsRepository.loadOfflineSettings()
        } catch {
            print("Failed to load offline settings: \(error)")
        }
    }

    func syncNow() async {
        // Manueller Sync mit höherer Priorität (.utility statt .background)
        // User wartet aktiv auf das Ergebnis und sieht Progress-UI
        await offlineCacheSyncUseCase.syncOfflineArticles(settings: offlineSettings)
        updateCacheStats()
    }

    func updateCacheStats() {
        cachedArticlesCount = offlineCacheSyncUseCase.getCachedArticlesCount()
        cacheSize = offlineCacheSyncUseCase.getCacheSize()
    }
}

6. Settings UI View

OfflineSettingsView

Datei: readeck/UI/Settings/OfflineSettingsView.swift

import SwiftUI

struct OfflineSettingsView: View {
    @StateObject private var viewModel: OfflineSettingsViewModel

    init(viewModel: OfflineSettingsViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }

    var body: some View {
        Form {
            Section {
                Toggle("Offline-Reading", isOn: $viewModel.offlineSettings.enabled)

                if viewModel.offlineSettings.enabled {
                    VStack(alignment: .leading, spacing: 8) {
                        Text("Ungelesene Artikel werden automatisch heruntergeladen und sind offline verfügbar. Änderungen werden synchronisiert, sobald du wieder online bist.")
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                    .padding(.vertical, 4)

                    VStack(alignment: .leading, spacing: 4) {
                        HStack {
                            Text("Max. Artikel offline")
                            Spacer()
                            Text("\(viewModel.offlineSettings.maxUnreadArticlesInt)")
                                .foregroundColor(.secondary)
                        }
                        Slider(
                            value: $viewModel.offlineSettings.maxUnreadArticles,
                            in: 0...100,
                            step: 10
                        )
                    }

                    Toggle("Bilder speichern", isOn: $viewModel.offlineSettings.saveImages)

                    Button(action: {
                        Task {
                            await viewModel.syncNow()
                        }
                    }) {
                        HStack {
                            Text("Jetzt synchronisieren")
                            Spacer()
                            if viewModel.isSyncing {
                                ProgressView()
                            }
                        }
                    }
                    .disabled(viewModel.isSyncing)

                    if let syncProgress = viewModel.syncProgress {
                        Text(syncProgress)
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }

                    if let lastSync = viewModel.offlineSettings.lastSyncDate {
                        Text("Zuletzt synchronisiert: \(lastSync, style: .relative)")
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }

                    if viewModel.cachedArticlesCount > 0 {
                        HStack {
                            Text("Gespeicherte Artikel")
                            Spacer()
                            Text("\(viewModel.cachedArticlesCount) Artikel, \(viewModel.cacheSize)")
                                .foregroundColor(.secondary)
                        }
                        .font(.caption)
                    }
                }
            } header: {
                Text("Offline-Reading")
            }
        }
        .navigationTitle("Offline-Reading")
        .onAppear {
            viewModel.updateCacheStats()
        }
    }
}

7. Integration in SettingsContainerView

Datei: readeck/UI/Settings/SettingsContainerView.swift

In der bestehenden Settings-View einen neuen NavigationLink hinzufügen:

Section {
    NavigationLink(destination: OfflineSettingsView(
        viewModel: DefaultUseCaseFactory.shared.makeOfflineSettingsViewModel()
    )) {
        Label("Offline-Reading", systemImage: "arrow.down.circle")
    }

    // ... existing settings items
} header: {
    Text("Allgemein")
}

8. Factory Erweiterung

Datei: readeck/UI/Factory/DefaultUseCaseFactory.swift

Erweitern mit:

// MARK: - Offline Settings

func makeOfflineSettingsViewModel() -> OfflineSettingsViewModel {
    return OfflineSettingsViewModel(
        settingsRepository: makeSettingsRepository(),
        offlineCacheSyncUseCase: makeOfflineCacheSyncUseCase()
    )
}

private func makeSettingsRepository() -> PSettingsRepository {
    return SettingsRepository()
}

private func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase {
    return OfflineCacheSyncUseCase(
        bookmarksRepository: BookmarksRepository(api: API()),
        settingsRepository: makeSettingsRepository()
    )
}

9. Automatischer Sync mit 4-Stunden-Check

App-Start Sync mit Background Priority

Datei: readeck/UI/AppViewModel.swift

Erweitern um:

@MainActor
func onAppStart() async {
    await checkServerReachability()
    syncOfflineArticlesIfNeeded() // Kein await! Läuft im Hintergrund
}

private func syncOfflineArticlesIfNeeded() {
    let settingsRepo = SettingsRepository()

    // Starte Background Task mit niedriger Priorität
    Task.detached(priority: .background) {
        guard let settings = try? await settingsRepo.loadOfflineSettings() else {
            return
        }

        // Check if sync is needed (enabled + more than 4 hours)
        if settings.shouldSyncOnAppStart {
            let syncUseCase = DefaultUseCaseFactory.shared.makeOfflineCacheSyncUseCase()
            await syncUseCase.syncOfflineArticles(settings: settings)
        }
    }
}

Wichtig: Kein await vor syncOfflineArticlesIfNeeded(), damit der App-Start nicht blockiert wird!

In readeckApp.swift:

.task {
    await appViewModel.onAppStart()
}

10. Offline-Modus UI-Anpassungen

BookmarksViewModel erweitern

Datei: readeck/UI/Bookmarks/BookmarksViewModel.swift

Erweitern um gecachte Bookmarks zu laden:

@MainActor
func loadBookmarks(state: BookmarkState = .unread, type: [BookmarkType] = [.article], tag: String? = nil) async {
    guard !isUpdating else { return }
    isUpdating = true
    defer { isUpdating = false }

    isLoading = true
    errorMessage = nil
    currentState = state
    currentType = type
    currentTag = tag

    offset = 0
    hasMoreData = true

    do {
        let newBookmarks = try await getBooksmarksUseCase.execute(
            state: state,
            limit: limit,
            offset: offset,
            search: searchQuery,
            type: type,
            tag: tag
        )
        bookmarks = newBookmarks
        hasMoreData = newBookmarks.currentPage != newBookmarks.totalPages
        isNetworkError = false
    } catch {
        // Check if it's a network error
        if let urlError = error as? URLError {
            switch urlError.code {
            case .notConnectedToInternet, .networkConnectionLost, .timedOut, .cannotConnectToHost, .cannotFindHost:
                isNetworkError = true
                errorMessage = "No internet connection"

                // NEUE LOGIK: Versuche gecachte Bookmarks zu laden
                await loadCachedBookmarks()
            default:
                isNetworkError = false
                errorMessage = "Error loading bookmarks"
            }
        } else {
            isNetworkError = false
            errorMessage = "Error loading bookmarks"
        }
    }

    isLoading = false
    isInitialLoading = false
}

private func loadCachedBookmarks() async {
    // Load cached bookmarks from repository
    let bookmarksRepository = BookmarksRepository(api: API())

    do {
        let cachedBookmarks = try await bookmarksRepository.getCachedBookmarks()

        if !cachedBookmarks.isEmpty {
            // Show cached bookmarks
            bookmarks = BookmarksPage(
                bookmarks: cachedBookmarks,
                currentPage: 1,
                totalCount: cachedBookmarks.count,
                totalPages: 1,
                links: nil
            )
            hasMoreData = false

            // Keep error message to show offline banner
            // But don't show full-screen error
        }
    } catch {
        print("Failed to load cached bookmarks: \(error)")
    }
}

BookmarksView erweitern - Offline-Banner

Datei: readeck/UI/Bookmarks/BookmarksView.swift

Anpassen der UI, um im Offline-Modus einen Banner zu zeigen statt Full-Screen-Error:

var body: some View {
    ZStack {
        VStack(spacing: 0) {
            // Offline-Banner - nur bei Network-Error und wenn Bookmarks vorhanden
            if viewModel.isNetworkError && !(viewModel.bookmarks?.bookmarks.isEmpty ?? true) {
                offlineBanner
            }

            // 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
        }
    }
    // ... rest of modifiers
}

// Offline-Banner über der Liste
private var offlineBanner: some View {
    HStack(spacing: 8) {
        Image(systemName: "wifi.slash")
            .font(.caption)
            .foregroundColor(.secondary)

        Text("Offline-Modus  Zeige gespeicherte Artikel")
            .font(.caption)
            .foregroundColor(.secondary)

        Spacer()
    }
    .padding(.horizontal, 16)
    .padding(.vertical, 8)
    .background(Color(.systemGray6))
}

// Anpassen der Error-Logic
private var shouldShowCenteredState: Bool {
    let isEmpty = viewModel.bookmarks?.bookmarks.isEmpty == true
    let hasError = viewModel.errorMessage != nil

    // Zeige Full-Screen Error nur wenn leer UND Error (nicht bei gecachten Bookmarks)
    return isEmpty && hasError
}

11. Offline-Artikel Laden

Datei: readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift

func loadArticle() async {
    isLoading = true

    do {
        // 1. Versuche zuerst aus Cache zu laden
        if let cachedHTML = bookmarksRepository.getCachedArticle(id: bookmarkId) {
            await MainActor.run {
                self.articleHTML = cachedHTML
                self.isLoading = false
            }
            return
        }

        // 2. Falls nicht gecacht, vom Server laden
        let html = try await getBookmarkArticleUseCase.execute(id: bookmarkId)

        await MainActor.run {
            self.articleHTML = html
            self.isLoading = false
        }

        // 3. Optional: Im Hintergrund cachen
        Task.detached(priority: .background) {
            let settings = try? await SettingsRepository().loadOfflineSettings()
            if settings?.enabled == true {
                try? await self.bookmarksRepository.cacheBookmarkArticle(
                    id: self.bookmarkId,
                    html: html,
                    saveImages: settings?.saveImages ?? false
                )
            }
        }

    } catch {
        await MainActor.run {
            self.error = error
            self.isLoading = false
        }
    }
}

Dateien die erstellt/geändert werden müssen

Neu zu erstellen:

  1. readeck/Domain/Model/OfflineSettings.swift
  2. readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift
  3. readeck/Domain/Protocols/PSettingsRepository.swift (falls nicht vorhanden)
  4. readeck/Data/Repository/SettingsRepository.swift
  5. readeck/UI/Settings/OfflineSettingsViewModel.swift
  6. readeck/UI/Settings/OfflineSettingsView.swift
  7. CoreData Entity: CachedArticle (mit imageURLs für Kingfisher)

Zu erweitern:

  1. readeck/Domain/Protocols/PBookmarksRepository.swift - Neue Methoden für Offline-Cache
  2. readeck/Data/Repository/BookmarksRepository.swift - Implementation mit Kingfisher ImagePrefetcher
  3. readeck/UI/Settings/SettingsContainerView.swift - NavigationLink zu Offline-Settings
  4. readeck/UI/Factory/DefaultUseCaseFactory.swift - Factory-Methoden für neue ViewModels/UseCases
  5. readeck/UI/AppViewModel.swift - Auto-Sync bei App-Start (4-Stunden-Check)
  6. readeck/UI/Bookmarks/BookmarksViewModel.swift - Laden gecachter Bookmarks bei Network-Error
  7. readeck/UI/Bookmarks/BookmarksView.swift - Offline-Banner statt Full-Screen Error
  8. readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift - Offline-First Artikel-Laden
  9. readeck.xcdatamodeld - CoreData Schema

Keine Änderung nötig (bereits vorhanden):

  • Kingfisher - Bereits im Projekt integriert und konfiguriert
  • CacheSettingsView - User kann bereits Image-Cache-Größe anpassen (50-1200 MB)

Testing Checklist

Unit Tests

  • OfflineCacheSyncUseCase: Sync-Logic
  • SettingsRepository: Save/Load Settings
  • BookmarksRepository: Cache CRUD Operations
  • Cleanup-Logic: FIFO Prinzip
  • Image URL Extraction aus HTML
  • Kingfisher Prefetch Integration

Integration Tests

  • App-Start → Auto-Sync (nur bei >4 Stunden)
  • Settings ändern → Auto-Save
  • Offline-Artikel laden → Cache-Fallback
  • Network-Error → Gecachte Bookmarks anzeigen
  • Bilder werden mit Kingfisher gecacht

UI Tests

  • Settings Toggle aktivieren/deaktivieren
  • Slider-Werte ändern
  • Manual Sync Button
  • Offline-Banner wird angezeigt bei Network-Error
  • Cache-Statistiken anzeigen
  • Alle Tabs bleiben navigierbar im Offline-Modus

Edge Cases

  • Netzwerk-Loss während Sync
  • App-Kill während Download
  • Speicher voll
  • Cache löschen bei Logout
  • Deaktivieren von Offline-Reading löscht Cache
  • 4-Stunden-Check verhindert unnötige Syncs
  • Kingfisher Cache-Limit wird respektiert

Nächste Schritte

Phase 1: Core-Funktionalität (1-2 Tage)

  1. CoreData Schema erstellen (CachedArticle Entity mit imageURLs)
  2. OfflineSettings Model + Repository implementieren (Default: 20 Artikel)
  3. BookmarksRepository um Cache-Methoden erweitern:
    • cacheBookmarkWithMetadata() mit HTML-Parsing
    • Kingfisher ImagePrefetcher Integration
    • getCachedBookmarks() für Offline-Modus

Phase 2: Sync-Logic (1-2 Tage)

  1. OfflineCacheSyncUseCase implementieren:
    • Sequenzieller Download (21 API-Calls für 20 Artikel)
    • Progress-Tracking mit Success/Error/Skip Counts
    • Fehlerbehandlung für einzelne Artikel
  2. 4-Stunden-Check in shouldSyncOnAppStart
  3. FIFO Cleanup-Logic

Phase 3: UI Integration (1 Tag)

  1. OfflineSettingsView erstellen:
    • Slider mit Default 20, Max 100
    • Manual Sync Button mit Progress
    • Last Sync Date + Cache Stats
  2. Integration in SettingsContainerView
  3. Factory erweitern für alle neuen Dependencies

Phase 4: Offline-Modus (1 Tag)

  1. BookmarksViewModel erweitern:
    • loadCachedBookmarks() bei Network-Error
    • Offline-Banner statt Full-Screen Error
  2. BookmarkDetailViewModel: Cache-First Loading
  3. Auto-Sync bei App-Start

Phase 5: Testing & Polish (1-2 Tage)

  1. Unit Tests für Sync-Logic & Cache-Operations
  2. Integration Tests für Offline-Flow
  3. UI Tests für Settings & Offline-Banner
  4. Performance Testing mit 100 Artikeln
  5. Bug-Fixing & Edge Cases

Geschätzte Gesamtdauer: 5-8 Tage


Zusammenfassung: Kingfisher Integration

Warum Kingfisher?

  • Bereits im Projekt: Kingfisher ist bereits integriert und konfiguriert
  • User-konfigurierbar: Cache-Größe ist in Settings anpassbar (50-1200 MB)
  • Automatisches Management: LRU-Cache mit automatischer Größenverwaltung
  • Performant: Optimiert für iOS mit Memory & Disk Caching

Wie funktioniert es?

  1. HTML Parsen: Image-URLs werden aus dem HTML extrahiert via Regex
  2. URLs speichern: In CoreData als komma-separierte Liste (für spätere Verwendung)
  3. Kingfisher Prefetch: ImagePrefetcher lädt alle Bilder im Hintergrund
  4. Automatisches Caching: Kingfisher speichert Bilder auf Disk
  5. WebView lädt Bilder: Beim Öffnen des Artikels lädt WebView Bilder aus Kingfisher-Cache

Cache-Management

  • Existierende Settings nutzen: CacheSettingsView erlaubt User Cache-Größe zu setzen
  • Automatic Cleanup: Kingfisher löscht automatisch alte Bilder bei Speicherplatz-Knappheit
  • Separate von Artikel-Cache: Bilder und HTML werden getrennt verwaltet
  • Optional cleanup: Bei clearCache() kann Kingfisher-Cache mit geleert werden

Vorteile gegenüber CoreData für Bilder

  • 🚀 Bessere Performance: Optimiert für Bilder
  • 💾 Weniger Speicher: Kompression & Deduplizierung
  • 🔄 Weniger Code: Keine eigene Image-Download-Logic nötig
  • ⚙️ Konfigurierbar: User kann Limits selbst setzen

Erstellt: 2025-11-01 Aktualisiert: 2025-11-01 (Kingfisher Integration) Basierend auf: Offline-Konzept.md - Stufe 1