Fix offline reading bugs and improve network monitoring (Phase 5)

Bugfixes:
- Add toggle for offline mode simulation (DEBUG only)
- Fix VPN false-positives with interface count check
- Add detailed error logging for download failures
- Fix last sync timestamp display
- Translate all strings to English

Network Monitoring:
- Add NetworkMonitorRepository with NWPathMonitor
- Check path.status AND availableInterfaces for reliability
- Add manual reportConnectionFailure/Success methods
- Auto-load cached bookmarks when offline
- Visual debug banner (green=online, red=offline)

Architecture:
- Clean architecture with Repository → UseCase → ViewModel
- Network status in AppSettings for global access
- Combine publishers for reactive updates
This commit is contained in:
Ilyas Hallak 2025-11-21 21:37:24 +01:00
parent fdc6b3a6b6
commit e4657aa281
12 changed files with 349 additions and 55 deletions

View File

@ -0,0 +1,102 @@
//
// NetworkMonitorRepository.swift
// readeck
//
// Created by Claude on 18.11.25.
//
import Foundation
import Network
import Combine
// MARK: - Protocol
protocol PNetworkMonitorRepository {
var isConnected: AnyPublisher<Bool, Never> { get }
func startMonitoring()
func stopMonitoring()
func reportConnectionFailure()
func reportConnectionSuccess()
}
// MARK: - Implementation
final class NetworkMonitorRepository: PNetworkMonitorRepository {
// MARK: - Properties
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.readeck.networkmonitor")
private let _isConnectedSubject = CurrentValueSubject<Bool, Never>(true)
private var hasPathConnection = true
private var hasRealConnection = true
var isConnected: AnyPublisher<Bool, Never> {
_isConnectedSubject.eraseToAnyPublisher()
}
// MARK: - Initialization
init() {
// Repository just manages the monitor, doesn't start it automatically
}
deinit {
monitor.cancel()
}
// MARK: - Public Methods
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
// More sophisticated check: path must be satisfied AND have actual interfaces
let hasInterfaces = path.availableInterfaces.count > 0
let isConnected = path.status == .satisfied && hasInterfaces
self.hasPathConnection = isConnected
self.updateConnectionState()
// Log network changes with details
if path.status == .satisfied {
if hasInterfaces {
Logger.network.info("📡 Network path available (interfaces: \(path.availableInterfaces.count))")
} else {
Logger.network.warning("⚠️ Network path satisfied but no interfaces (VPN?)")
}
} else {
Logger.network.warning("📡 Network path unavailable")
}
}
monitor.start(queue: queue)
Logger.network.debug("Network monitoring started")
}
func stopMonitoring() {
monitor.cancel()
Logger.network.debug("Network monitoring stopped")
}
func reportConnectionFailure() {
hasRealConnection = false
updateConnectionState()
Logger.network.warning("⚠️ Real connection failure reported (VPN/unreachable server)")
}
func reportConnectionSuccess() {
hasRealConnection = true
updateConnectionState()
Logger.network.info("✅ Real connection success reported")
}
private func updateConnectionState() {
// Only connected if BOTH path is available AND real connection works
let isConnected = hasPathConnection && hasRealConnection
DispatchQueue.main.async {
self._isConnectedSubject.send(isConnected)
}
}
}

View File

@ -0,0 +1,58 @@
//
// NetworkMonitorUseCase.swift
// readeck
//
// Created by Claude on 18.11.25.
//
import Foundation
import Combine
// MARK: - Protocol
protocol PNetworkMonitorUseCase {
var isConnected: AnyPublisher<Bool, Never> { get }
func startMonitoring()
func stopMonitoring()
func reportConnectionFailure()
func reportConnectionSuccess()
}
// MARK: - Implementation
final class NetworkMonitorUseCase: PNetworkMonitorUseCase {
// MARK: - Dependencies
private let repository: PNetworkMonitorRepository
// MARK: - Properties
var isConnected: AnyPublisher<Bool, Never> {
repository.isConnected
}
// MARK: - Initialization
init(repository: PNetworkMonitorRepository) {
self.repository = repository
}
// MARK: - Public Methods
func startMonitoring() {
repository.startMonitoring()
}
func stopMonitoring() {
repository.stopMonitoring()
}
func reportConnectionFailure() {
repository.reportConnectionFailure()
}
func reportConnectionSuccess() {
repository.reportConnectionSuccess()
}
}

View File

@ -92,13 +92,13 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
if offlineCacheRepository.hasCachedArticle(id: bookmark.id) { if offlineCacheRepository.hasCachedArticle(id: bookmark.id) {
Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)") Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)")
skippedCount += 1 skippedCount += 1
_syncProgressSubject.send("⏭️ Artikel \(progress) bereits gecacht...") _syncProgressSubject.send("⏭️ Article \(progress) already cached...")
continue continue
} }
// Update progress // Update progress
let imagesSuffix = settings.saveImages ? " + Bilder" : "" let imagesSuffix = settings.saveImages ? " + images" : ""
_syncProgressSubject.send("📥 Artikel \(progress)\(imagesSuffix)...") _syncProgressSubject.send("📥 Article \(progress)\(imagesSuffix)...")
Logger.sync.info("📥 Caching '\(bookmark.title)'") Logger.sync.info("📥 Caching '\(bookmark.title)'")
do { do {
@ -116,7 +116,15 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
Logger.sync.info("✅ Cached '\(bookmark.title)'") Logger.sync.info("✅ Cached '\(bookmark.title)'")
} catch { } catch {
errorCount += 1 errorCount += 1
Logger.sync.error("❌ Failed to cache '\(bookmark.title)': \(error.localizedDescription)")
// Detailed error logging
if let urlError = error as? URLError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Network error: \(urlError.code.rawValue) (\(urlError.localizedDescription))")
} else if let decodingError = error as? DecodingError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Decoding error: \(decodingError)")
} else {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Error: \(error.localizedDescription) (Type: \(type(of: error)))")
}
} }
} }
@ -129,7 +137,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
try await settingsRepository.saveOfflineSettings(updatedSettings) try await settingsRepository.saveOfflineSettings(updatedSettings)
// Final status // Final status
let statusMessage = "✅ Synchronisiert: \(successCount), Übersprungen: \(skippedCount), Fehler: \(errorCount)" let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)"
Logger.sync.info(statusMessage) Logger.sync.info(statusMessage)
_syncProgressSubject.send(statusMessage) _syncProgressSubject.send(statusMessage)
@ -139,7 +147,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
} catch { } catch {
Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)") Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)")
_syncProgressSubject.send("❌ Synchronisierung fehlgeschlagen") _syncProgressSubject.send("❌ Sync failed")
// Clear error message after 5 seconds // Clear error message after 5 seconds
try? await Task.sleep(nanoseconds: 5_000_000_000) try? await Task.sleep(nanoseconds: 5_000_000_000)

View File

@ -7,6 +7,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine
@MainActor @MainActor
@Observable @Observable
@ -14,17 +15,22 @@ class AppViewModel {
private let settingsRepository = SettingsRepository() private let settingsRepository = SettingsRepository()
private let factory: UseCaseFactory private let factory: UseCaseFactory
private let syncTagsUseCase: PSyncTagsUseCase private let syncTagsUseCase: PSyncTagsUseCase
let networkMonitorUseCase: PNetworkMonitorUseCase
var hasFinishedSetup: Bool = true var hasFinishedSetup: Bool = true
var isServerReachable: Bool = false var isServerReachable: Bool = false
var isNetworkConnected: Bool = true
private var lastAppStartTagSyncTime: Date? private var lastAppStartTagSyncTime: Date?
private var cancellables = Set<AnyCancellable>()
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) { init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.factory = factory self.factory = factory
self.syncTagsUseCase = factory.makeSyncTagsUseCase() self.syncTagsUseCase = factory.makeSyncTagsUseCase()
setupNotificationObservers() self.networkMonitorUseCase = factory.makeNetworkMonitorUseCase()
setupNotificationObservers()
setupNetworkMonitoring()
loadSetupStatus() loadSetupStatus()
} }
@ -63,6 +69,28 @@ class AppViewModel {
} }
} }
private func setupNetworkMonitoring() {
// Start monitoring network status
networkMonitorUseCase.startMonitoring()
// Bind network status to our published property
networkMonitorUseCase.isConnected
.receive(on: DispatchQueue.main)
.assign(to: \.isNetworkConnected, on: self)
.store(in: &cancellables)
}
func bindNetworkStatus(to appSettings: AppSettings) {
// Bind network status to AppSettings for global access
networkMonitorUseCase.isConnected
.receive(on: DispatchQueue.main)
.sink { isConnected in
Logger.viewModel.info("🌐 Network status changed: \(isConnected ? "Connected" : "Disconnected")")
appSettings.isNetworkConnected = isConnected
}
.store(in: &cancellables)
}
private func loadSetupStatus() { private func loadSetupStatus() {
hasFinishedSetup = settingsRepository.hasFinishedSetup hasFinishedSetup = settingsRepository.hasFinishedSetup
} }

View File

@ -17,6 +17,7 @@ struct BookmarksView: View {
let type: [BookmarkType] let type: [BookmarkType]
@Binding var selectedBookmark: Bookmark? @Binding var selectedBookmark: Bookmark?
@EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
let tag: String? let tag: String?
// MARK: Environments // MARK: Environments
@ -37,8 +38,13 @@ struct BookmarksView: View {
var body: some View { var body: some View {
ZStack { ZStack {
VStack(spacing: 0) { VStack(spacing: 0) {
#if DEBUG
// Debug: Network status indicator
debugNetworkStatusBanner
#endif
// Offline banner // Offline banner
if viewModel.isNetworkError && (viewModel.bookmarks?.bookmarks.isEmpty == false) { if !appSettings.isNetworkConnected && (viewModel.bookmarks?.bookmarks.isEmpty == false) {
offlineBanner offlineBanner
} }
@ -91,6 +97,20 @@ struct BookmarksView: View {
} }
} }
} }
.onChange(of: appSettings.isNetworkConnected) { oldValue, newValue in
// Network status changed
if !newValue && oldValue {
// Lost network connection - load cached bookmarks
Task {
await viewModel.loadCachedBookmarksFromUI()
}
} else if newValue && !oldValue {
// Regained network connection - refresh from server
Task {
await viewModel.refreshBookmarks()
}
}
}
} }
// MARK: - Computed Properties // MARK: - Computed Properties
@ -285,6 +305,31 @@ struct BookmarksView: View {
} }
} }
@ViewBuilder
private var debugNetworkStatusBanner: some View {
HStack(spacing: 12) {
Image(systemName: appSettings.isNetworkConnected ? "wifi" : "wifi.slash")
.font(.body)
.foregroundColor(appSettings.isNetworkConnected ? .green : .red)
Text("DEBUG: Network \(appSettings.isNetworkConnected ? "Connected ✓" : "Disconnected ✗")")
.font(.caption)
.foregroundColor(appSettings.isNetworkConnected ? .green : .red)
.bold()
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(appSettings.isNetworkConnected ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
.overlay(
Rectangle()
.frame(height: 1)
.foregroundColor(appSettings.isNetworkConnected ? Color.green : Color.red),
alignment: .bottom
)
}
@ViewBuilder @ViewBuilder
private var offlineBanner: some View { private var offlineBanner: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@ -292,7 +337,7 @@ struct BookmarksView: View {
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("Offline-Modus Zeige gespeicherte Artikel") Text("Offline Mode Showing cached articles")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)

View File

@ -30,7 +30,6 @@ class BookmarksViewModel {
// Prevent concurrent updates // Prevent concurrent updates
private var isUpdating = false private var isUpdating = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var limit = 50 private var limit = 50
private var offset = 0 private var offset = 0
@ -180,6 +179,13 @@ class BookmarksViewModel {
} }
} }
@MainActor
func loadCachedBookmarksFromUI() async {
isNetworkError = true
errorMessage = "No internet connection"
await loadCachedBookmarks()
}
@MainActor @MainActor
func loadMoreBookmarks() async { func loadMoreBookmarks() async {
guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads guard !isLoading && hasMoreData && !isUpdating else { return } // prevent multiple loads

View File

@ -27,6 +27,7 @@ protocol UseCaseFactory {
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
func makeSettingsRepository() -> PSettingsRepository func makeSettingsRepository() -> PSettingsRepository
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase
} }
@ -41,6 +42,7 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient) private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient)
private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api) private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api)
private let offlineCacheRepository: POfflineCacheRepository = OfflineCacheRepository() private let offlineCacheRepository: POfflineCacheRepository = OfflineCacheRepository()
private let networkMonitorRepository: PNetworkMonitorRepository = NetworkMonitorRepository()
static let shared = DefaultUseCaseFactory() static let shared = DefaultUseCaseFactory()
@ -159,4 +161,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
settingsRepository: settingsRepository settingsRepository: settingsRepository
) )
} }
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase {
return NetworkMonitorUseCase(repository: networkMonitorRepository)
}
} }

View File

@ -112,6 +112,10 @@ class MockUseCaseFactory: UseCaseFactory {
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase { func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase {
MockOfflineCacheSyncUseCase() MockOfflineCacheSyncUseCase()
} }
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase {
MockNetworkMonitorUseCase()
}
} }
@ -331,6 +335,45 @@ class MockOfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
} }
} }
class MockNetworkMonitorRepository: PNetworkMonitorRepository {
var isConnected: AnyPublisher<Bool, Never> {
Just(true).eraseToAnyPublisher()
}
func startMonitoring() {}
func stopMonitoring() {}
func reportConnectionFailure() {}
func reportConnectionSuccess() {}
}
class MockNetworkMonitorUseCase: PNetworkMonitorUseCase {
private let repository: PNetworkMonitorRepository
init(repository: PNetworkMonitorRepository = MockNetworkMonitorRepository()) {
self.repository = repository
}
var isConnected: AnyPublisher<Bool, Never> {
repository.isConnected
}
func startMonitoring() {
repository.startMonitoring()
}
func stopMonitoring() {
repository.stopMonitoring()
}
func reportConnectionFailure() {
repository.reportConnectionFailure()
}
func reportConnectionSuccess() {
repository.reportConnectionSuccess()
}
}
extension Bookmark { extension Bookmark {
static let mock: Bookmark = .init( 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) 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

@ -18,6 +18,7 @@ import Combine
class AppSettings: ObservableObject { class AppSettings: ObservableObject {
@Published var settings: Settings? @Published var settings: Settings?
@Published var isNetworkConnected: Bool = true
var enableTTS: Bool { var enableTTS: Bool {
settings?.enableTTS ?? false settings?.enableTTS ?? false

View File

@ -9,19 +9,20 @@ import SwiftUI
struct OfflineSettingsView: View { struct OfflineSettingsView: View {
@State private var viewModel = OfflineSettingsViewModel() @State private var viewModel = OfflineSettingsViewModel()
@EnvironmentObject var appSettings: AppSettings
var body: some View { var body: some View {
Group { Group {
Section { Section {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Toggle("Offline-Reading aktivieren", isOn: $viewModel.offlineSettings.enabled) Toggle("Enable Offline Reading", isOn: $viewModel.offlineSettings.enabled)
.onChange(of: viewModel.offlineSettings.enabled) { .onChange(of: viewModel.offlineSettings.enabled) {
Task { Task {
await viewModel.saveSettings() await viewModel.saveSettings()
} }
} }
Text("Lade automatisch Artikel für die Offline-Nutzung herunter.") Text("Automatically download articles for offline use.")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(.top, 2) .padding(.top, 2)
@ -31,7 +32,7 @@ struct OfflineSettingsView: View {
// Max articles slider // Max articles slider
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("Maximale Artikel") Text("Maximum Articles")
Spacer() Spacer()
Text("\(viewModel.offlineSettings.maxUnreadArticlesInt)") Text("\(viewModel.offlineSettings.maxUnreadArticlesInt)")
.font(.caption) .font(.caption)
@ -43,7 +44,7 @@ struct OfflineSettingsView: View {
in: 0...100, in: 0...100,
step: 10 step: 10
) { ) {
Text("Max. Artikel offline") Text("Max. Articles Offline")
} }
.onChange(of: viewModel.offlineSettings.maxUnreadArticles) { .onChange(of: viewModel.offlineSettings.maxUnreadArticles) {
Task { Task {
@ -54,14 +55,14 @@ struct OfflineSettingsView: View {
// Save images toggle // Save images toggle
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Toggle("Bilder speichern", isOn: $viewModel.offlineSettings.saveImages) Toggle("Save Images", isOn: $viewModel.offlineSettings.saveImages)
.onChange(of: viewModel.offlineSettings.saveImages) { .onChange(of: viewModel.offlineSettings.saveImages) {
Task { Task {
await viewModel.saveSettings() await viewModel.saveSettings()
} }
} }
Text("Lädt auch Bilder für die Offline-Nutzung herunter.") Text("Also download images for offline use.")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(.top, 2) .padding(.top, 2)
@ -83,7 +84,7 @@ struct OfflineSettingsView: View {
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Jetzt synchronisieren") Text("Sync Now")
.foregroundColor(viewModel.isSyncing ? .secondary : .blue) .foregroundColor(viewModel.isSyncing ? .secondary : .blue)
if let progress = viewModel.syncProgress { if let progress = viewModel.syncProgress {
@ -91,7 +92,7 @@ struct OfflineSettingsView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} else if let lastSync = viewModel.offlineSettings.lastSyncDate { } else if let lastSync = viewModel.offlineSettings.lastSyncDate {
Text("Zuletzt: \(lastSync.formatted(.relative(presentation: .named)))") Text("Last synced: \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@ -106,8 +107,8 @@ struct OfflineSettingsView: View {
if viewModel.cachedArticlesCount > 0 { if viewModel.cachedArticlesCount > 0 {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Gespeicherte Artikel") Text("Cached Articles")
Text("\(viewModel.cachedArticlesCount) Artikel (\(viewModel.cacheSize))") Text("\(viewModel.cachedArticlesCount) articles (\(viewModel.cacheSize))")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@ -116,43 +117,36 @@ struct OfflineSettingsView: View {
} }
#if DEBUG #if DEBUG
// Debug: Force offline mode // Debug: Toggle offline mode simulation
Button(action: { VStack(alignment: .leading, spacing: 4) {
simulateOfflineMode() Toggle(isOn: Binding(
}) { get: { !appSettings.isNetworkConnected },
HStack { set: { isOffline in
Image(systemName: "airplane") appSettings.isNetworkConnected = !isOffline
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Offline-Modus simulieren")
.foregroundColor(.orange)
Text("DEBUG: Netzwerk temporär deaktivieren")
.font(.caption)
.foregroundColor(.secondary)
} }
)) {
HStack {
Image(systemName: "airplane")
.foregroundColor(.orange)
Spacer() VStack(alignment: .leading, spacing: 2) {
Text("Simulate Offline Mode")
.foregroundColor(.orange)
Text("DEBUG: Toggle network status")
.font(.caption)
.foregroundColor(.secondary)
}
}
} }
} }
#endif #endif
} }
} header: { } header: {
Text("Offline-Reading") Text("Offline Reading")
} }
} }
.task { .task {
await viewModel.loadSettings() 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

@ -78,6 +78,8 @@ class OfflineSettingsViewModel {
func syncNow() async { func syncNow() async {
Logger.viewModel.info("Manual sync triggered") Logger.viewModel.info("Manual sync triggered")
await offlineCacheSyncUseCase.syncOfflineArticles(settings: offlineSettings) await offlineCacheSyncUseCase.syncOfflineArticles(settings: offlineSettings)
// Reload settings to get updated lastSyncDate
await loadSettings()
updateCacheStats() updateCacheStats()
} }

View File

@ -34,6 +34,7 @@ struct readeckApp: App {
Task { Task {
await loadAppSettings() await loadAppSettings()
} }
appViewModel.bindNetworkStatus(to: appSettings)
} }
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
Task { Task {