diff --git a/readeck/Data/Repository/NetworkMonitorRepository.swift b/readeck/Data/Repository/NetworkMonitorRepository.swift new file mode 100644 index 0000000..315e400 --- /dev/null +++ b/readeck/Data/Repository/NetworkMonitorRepository.swift @@ -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 { 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(true) + private var hasPathConnection = true + private var hasRealConnection = true + + var isConnected: AnyPublisher { + _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) + } + } +} diff --git a/readeck/Domain/UseCase/NetworkMonitorUseCase.swift b/readeck/Domain/UseCase/NetworkMonitorUseCase.swift new file mode 100644 index 0000000..cbaabc2 --- /dev/null +++ b/readeck/Domain/UseCase/NetworkMonitorUseCase.swift @@ -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 { 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 { + 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() + } +} diff --git a/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift b/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift index 8d46809..5449540 100644 --- a/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift +++ b/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift @@ -92,13 +92,13 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { if offlineCacheRepository.hasCachedArticle(id: bookmark.id) { Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)") skippedCount += 1 - _syncProgressSubject.send("⏭️ Artikel \(progress) bereits gecacht...") + _syncProgressSubject.send("⏭️ Article \(progress) already cached...") continue } // Update progress - let imagesSuffix = settings.saveImages ? " + Bilder" : "" - _syncProgressSubject.send("📥 Artikel \(progress)\(imagesSuffix)...") + let imagesSuffix = settings.saveImages ? " + images" : "" + _syncProgressSubject.send("📥 Article \(progress)\(imagesSuffix)...") Logger.sync.info("📥 Caching '\(bookmark.title)'") do { @@ -116,7 +116,15 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { Logger.sync.info("✅ Cached '\(bookmark.title)'") } catch { 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) // Final status - let statusMessage = "✅ Synchronisiert: \(successCount), Übersprungen: \(skippedCount), Fehler: \(errorCount)" + let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)" Logger.sync.info(statusMessage) _syncProgressSubject.send(statusMessage) @@ -139,7 +147,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase { } catch { Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)") - _syncProgressSubject.send("❌ Synchronisierung fehlgeschlagen") + _syncProgressSubject.send("❌ Sync failed") // Clear error message after 5 seconds try? await Task.sleep(nanoseconds: 5_000_000_000) diff --git a/readeck/UI/AppViewModel.swift b/readeck/UI/AppViewModel.swift index ba677b3..2f8de10 100644 --- a/readeck/UI/AppViewModel.swift +++ b/readeck/UI/AppViewModel.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import Combine @MainActor @Observable @@ -14,17 +15,22 @@ class AppViewModel { private let settingsRepository = SettingsRepository() private let factory: UseCaseFactory private let syncTagsUseCase: PSyncTagsUseCase + let networkMonitorUseCase: PNetworkMonitorUseCase var hasFinishedSetup: Bool = true var isServerReachable: Bool = false + var isNetworkConnected: Bool = true private var lastAppStartTagSyncTime: Date? + private var cancellables = Set() init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) { self.factory = factory self.syncTagsUseCase = factory.makeSyncTagsUseCase() - setupNotificationObservers() + self.networkMonitorUseCase = factory.makeNetworkMonitorUseCase() + setupNotificationObservers() + setupNetworkMonitoring() 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() { hasFinishedSetup = settingsRepository.hasFinishedSetup } diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index c036a99..dfc12b3 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -5,22 +5,23 @@ import SwiftUI struct BookmarksView: View { // MARK: States - + @State private var viewModel: BookmarksViewModel @State private var showingAddBookmark = false @State private var selectedBookmarkId: String? @State private var showingAddBookmarkFromShare = false @State private var shareURL = "" @State private var shareTitle = "" - + let state: BookmarkState let type: [BookmarkType] @Binding var selectedBookmark: Bookmark? @EnvironmentObject var playerUIState: PlayerUIState + @EnvironmentObject var appSettings: AppSettings let tag: String? - + // MARK: Environments - + @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.verticalSizeClass) var verticalSizeClass @@ -37,8 +38,13 @@ struct BookmarksView: View { var body: some View { ZStack { VStack(spacing: 0) { + #if DEBUG + // Debug: Network status indicator + debugNetworkStatusBanner + #endif + // Offline banner - if viewModel.isNetworkError && (viewModel.bookmarks?.bookmarks.isEmpty == false) { + if !appSettings.isNetworkConnected && (viewModel.bookmarks?.bookmarks.isEmpty == false) { offlineBanner } @@ -86,7 +92,21 @@ struct BookmarksView: View { Task { // Wait a bit for the server to process the new bookmark try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - + + await viewModel.refreshBookmarks() + } + } + } + .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() } } @@ -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 private var offlineBanner: some View { HStack(spacing: 12) { @@ -292,7 +337,7 @@ struct BookmarksView: View { .font(.body) .foregroundColor(.secondary) - Text("Offline-Modus – Zeige gespeicherte Artikel") + Text("Offline Mode – Showing cached articles") .font(.caption) .foregroundColor(.secondary) diff --git a/readeck/UI/Bookmarks/BookmarksViewModel.swift b/readeck/UI/Bookmarks/BookmarksViewModel.swift index 4f8ef20..2e21209 100644 --- a/readeck/UI/Bookmarks/BookmarksViewModel.swift +++ b/readeck/UI/Bookmarks/BookmarksViewModel.swift @@ -9,7 +9,7 @@ class BookmarksViewModel { private let deleteBookmarkUseCase: PDeleteBookmarkUseCase private let loadCardLayoutUseCase: PLoadCardLayoutUseCase private let offlineCacheRepository: POfflineCacheRepository - + var bookmarks: BookmarksPage? var isLoading = false var isInitialLoading = true @@ -19,7 +19,7 @@ class BookmarksViewModel { var currentType = [BookmarkType.article] var currentTag: String? = nil var cardLayoutStyle: CardLayoutStyle = .magazine - + var showingAddBookmarkFromShare = false var shareURL = "" var shareTitle = "" @@ -29,8 +29,7 @@ class BookmarksViewModel { // Prevent concurrent updates private var isUpdating = false - - + private var cancellables = Set() private var limit = 50 private var offset = 0 @@ -69,7 +68,7 @@ class BookmarksViewModel { } } .store(in: &cancellables) - + // Listen for NotificationCenter.default .publisher(for: .addBookmarkFromShare) @@ -179,6 +178,13 @@ class BookmarksViewModel { Logger.viewModel.error("Failed to load cached bookmarks: \(error.localizedDescription)") } } + + @MainActor + func loadCachedBookmarksFromUI() async { + isNetworkError = true + errorMessage = "No internet connection" + await loadCachedBookmarks() + } @MainActor func loadMoreBookmarks() async { diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index b68ac12..74143d7 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -27,6 +27,7 @@ protocol UseCaseFactory { func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase func makeSettingsRepository() -> PSettingsRepository func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase + func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase } @@ -41,6 +42,7 @@ class DefaultUseCaseFactory: UseCaseFactory { private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient) private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api) private let offlineCacheRepository: POfflineCacheRepository = OfflineCacheRepository() + private let networkMonitorRepository: PNetworkMonitorRepository = NetworkMonitorRepository() static let shared = DefaultUseCaseFactory() @@ -159,4 +161,8 @@ class DefaultUseCaseFactory: UseCaseFactory { settingsRepository: settingsRepository ) } + + func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase { + return NetworkMonitorUseCase(repository: networkMonitorRepository) + } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index 3e6232f..0e0d995 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -112,6 +112,10 @@ class MockUseCaseFactory: UseCaseFactory { func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase { MockOfflineCacheSyncUseCase() } + + func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase { + MockNetworkMonitorUseCase() + } } @@ -331,6 +335,45 @@ class MockOfflineCacheSyncUseCase: POfflineCacheSyncUseCase { } } +class MockNetworkMonitorRepository: PNetworkMonitorRepository { + var isConnected: AnyPublisher { + 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 { + repository.isConnected + } + + func startMonitoring() { + repository.startMonitoring() + } + + func stopMonitoring() { + repository.stopMonitoring() + } + + func reportConnectionFailure() { + repository.reportConnectionFailure() + } + + func reportConnectionSuccess() { + repository.reportConnectionSuccess() + } +} + 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/Models/AppSettings.swift b/readeck/UI/Models/AppSettings.swift index c09d8ff..291dc6b 100644 --- a/readeck/UI/Models/AppSettings.swift +++ b/readeck/UI/Models/AppSettings.swift @@ -18,6 +18,7 @@ import Combine class AppSettings: ObservableObject { @Published var settings: Settings? + @Published var isNetworkConnected: Bool = true var enableTTS: Bool { settings?.enableTTS ?? false diff --git a/readeck/UI/Settings/OfflineSettingsView.swift b/readeck/UI/Settings/OfflineSettingsView.swift index 75129a8..b26631a 100644 --- a/readeck/UI/Settings/OfflineSettingsView.swift +++ b/readeck/UI/Settings/OfflineSettingsView.swift @@ -9,19 +9,20 @@ import SwiftUI struct OfflineSettingsView: View { @State private var viewModel = OfflineSettingsViewModel() + @EnvironmentObject var appSettings: AppSettings var body: some View { Group { Section { 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) { Task { await viewModel.saveSettings() } } - Text("Lade automatisch Artikel für die Offline-Nutzung herunter.") + Text("Automatically download articles for offline use.") .font(.caption) .foregroundColor(.secondary) .padding(.top, 2) @@ -31,7 +32,7 @@ struct OfflineSettingsView: View { // Max articles slider VStack(alignment: .leading, spacing: 8) { HStack { - Text("Maximale Artikel") + Text("Maximum Articles") Spacer() Text("\(viewModel.offlineSettings.maxUnreadArticlesInt)") .font(.caption) @@ -43,7 +44,7 @@ struct OfflineSettingsView: View { in: 0...100, step: 10 ) { - Text("Max. Artikel offline") + Text("Max. Articles Offline") } .onChange(of: viewModel.offlineSettings.maxUnreadArticles) { Task { @@ -54,14 +55,14 @@ struct OfflineSettingsView: View { // Save images toggle VStack(alignment: .leading, spacing: 4) { - Toggle("Bilder speichern", isOn: $viewModel.offlineSettings.saveImages) + Toggle("Save Images", isOn: $viewModel.offlineSettings.saveImages) .onChange(of: viewModel.offlineSettings.saveImages) { Task { await viewModel.saveSettings() } } - Text("Lädt auch Bilder für die Offline-Nutzung herunter.") + Text("Also download images for offline use.") .font(.caption) .foregroundColor(.secondary) .padding(.top, 2) @@ -83,7 +84,7 @@ struct OfflineSettingsView: View { } VStack(alignment: .leading, spacing: 2) { - Text("Jetzt synchronisieren") + Text("Sync Now") .foregroundColor(viewModel.isSyncing ? .secondary : .blue) if let progress = viewModel.syncProgress { @@ -91,7 +92,7 @@ struct OfflineSettingsView: View { .font(.caption) .foregroundColor(.secondary) } else if let lastSync = viewModel.offlineSettings.lastSyncDate { - Text("Zuletzt: \(lastSync.formatted(.relative(presentation: .named)))") + Text("Last synced: \(lastSync.formatted(.relative(presentation: .named)))") .font(.caption) .foregroundColor(.secondary) } @@ -106,8 +107,8 @@ struct OfflineSettingsView: View { if viewModel.cachedArticlesCount > 0 { HStack { VStack(alignment: .leading, spacing: 2) { - Text("Gespeicherte Artikel") - Text("\(viewModel.cachedArticlesCount) Artikel (\(viewModel.cacheSize))") + Text("Cached Articles") + Text("\(viewModel.cachedArticlesCount) articles (\(viewModel.cacheSize))") .font(.caption) .foregroundColor(.secondary) } @@ -116,43 +117,36 @@ struct OfflineSettingsView: View { } #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) + // Debug: Toggle offline mode simulation + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: Binding( + get: { !appSettings.isNetworkConnected }, + set: { isOffline in + appSettings.isNetworkConnected = !isOffline } + )) { + 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 } } header: { - Text("Offline-Reading") + 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 index 4439b98..75ebddb 100644 --- a/readeck/UI/Settings/OfflineSettingsViewModel.swift +++ b/readeck/UI/Settings/OfflineSettingsViewModel.swift @@ -78,6 +78,8 @@ class OfflineSettingsViewModel { func syncNow() async { Logger.viewModel.info("Manual sync triggered") await offlineCacheSyncUseCase.syncOfflineArticles(settings: offlineSettings) + // Reload settings to get updated lastSyncDate + await loadSettings() updateCacheStats() } diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 61292a1..d03d710 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -34,6 +34,7 @@ struct readeckApp: App { Task { await loadAppSettings() } + appViewModel.bindNetworkStatus(to: appSettings) } .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in Task {