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:
parent
fdc6b3a6b6
commit
e4657aa281
102
readeck/Data/Repository/NetworkMonitorRepository.swift
Normal file
102
readeck/Data/Repository/NetworkMonitorRepository.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
readeck/Domain/UseCase/NetworkMonitorUseCase.swift
Normal file
58
readeck/Domain/UseCase/NetworkMonitorUseCase.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,22 +5,23 @@ import SwiftUI
|
|||||||
struct BookmarksView: View {
|
struct BookmarksView: View {
|
||||||
|
|
||||||
// MARK: States
|
// MARK: States
|
||||||
|
|
||||||
@State private var viewModel: BookmarksViewModel
|
@State private var viewModel: BookmarksViewModel
|
||||||
@State private var showingAddBookmark = false
|
@State private var showingAddBookmark = false
|
||||||
@State private var selectedBookmarkId: String?
|
@State private var selectedBookmarkId: String?
|
||||||
@State private var showingAddBookmarkFromShare = false
|
@State private var showingAddBookmarkFromShare = false
|
||||||
@State private var shareURL = ""
|
@State private var shareURL = ""
|
||||||
@State private var shareTitle = ""
|
@State private var shareTitle = ""
|
||||||
|
|
||||||
let state: BookmarkState
|
let state: BookmarkState
|
||||||
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
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||||
@Environment(\.verticalSizeClass) var verticalSizeClass
|
@Environment(\.verticalSizeClass) var verticalSizeClass
|
||||||
|
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,7 +92,21 @@ struct BookmarksView: View {
|
|||||||
Task {
|
Task {
|
||||||
// Wait a bit for the server to process the new bookmark
|
// Wait a bit for the server to process the new bookmark
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
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()
|
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
|
@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)
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ class BookmarksViewModel {
|
|||||||
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
|
||||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||||
private let offlineCacheRepository: POfflineCacheRepository
|
private let offlineCacheRepository: POfflineCacheRepository
|
||||||
|
|
||||||
var bookmarks: BookmarksPage?
|
var bookmarks: BookmarksPage?
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var isInitialLoading = true
|
var isInitialLoading = true
|
||||||
@ -19,7 +19,7 @@ class BookmarksViewModel {
|
|||||||
var currentType = [BookmarkType.article]
|
var currentType = [BookmarkType.article]
|
||||||
var currentTag: String? = nil
|
var currentTag: String? = nil
|
||||||
var cardLayoutStyle: CardLayoutStyle = .magazine
|
var cardLayoutStyle: CardLayoutStyle = .magazine
|
||||||
|
|
||||||
var showingAddBookmarkFromShare = false
|
var showingAddBookmarkFromShare = false
|
||||||
var shareURL = ""
|
var shareURL = ""
|
||||||
var shareTitle = ""
|
var shareTitle = ""
|
||||||
@ -29,8 +29,7 @@ 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
|
||||||
@ -69,7 +68,7 @@ class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Listen for
|
// Listen for
|
||||||
NotificationCenter.default
|
NotificationCenter.default
|
||||||
.publisher(for: .addBookmarkFromShare)
|
.publisher(for: .addBookmarkFromShare)
|
||||||
@ -179,6 +178,13 @@ class BookmarksViewModel {
|
|||||||
Logger.viewModel.error("Failed to load cached bookmarks: \(error.localizedDescription)")
|
Logger.viewModel.error("Failed to load cached bookmarks: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func loadCachedBookmarksFromUI() async {
|
||||||
|
isNetworkError = true
|
||||||
|
errorMessage = "No internet connection"
|
||||||
|
await loadCachedBookmarks()
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadMoreBookmarks() async {
|
func loadMoreBookmarks() async {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user