From 4fd55ef5d0f0caff7d2fbf6dd7567bccab21ecd5 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Mon, 1 Dec 2025 21:56:13 +0100 Subject: [PATCH] Refactor settings to use Clean Architecture with ViewModels - Add cache settings UseCases (get/update size, clear cache) - Create CacheSettingsViewModel and OfflineSettingsViewModel - Replace direct UserDefaults access with repository pattern - Add CachedArticlesPreviewView for viewing offline articles - Integrate offline settings into main SettingsContainerView - Wire up new UseCases in factory pattern --- readeck/Data/CoreData/CoreDataManager.swift | 29 +++ readeck/Domain/Model/OfflineSettings.swift | 2 +- .../Domain/UseCase/ClearCacheUseCase.swift | 17 ++ .../Domain/UseCase/GetCacheSizeUseCase.swift | 17 ++ .../UseCase/GetMaxCacheSizeUseCase.swift | 17 ++ .../UseCase/UpdateMaxCacheSizeUseCase.swift | 17 ++ .../UI/Factory/DefaultUseCaseFactory.swift | 20 ++ readeck/UI/Factory/MockUseCaseFactory.swift | 77 +++++++ readeck/UI/Settings/CacheSettingsView.swift | 87 ++------ .../UI/Settings/CacheSettingsViewModel.swift | 92 ++++++++ .../Settings/CachedArticlesPreviewView.swift | 200 ++++++++++++++++++ .../CachedArticlesPreviewViewModel.swift | 53 +++++ ...w.swift => OfflineReadingDetailView.swift} | 60 ++++-- .../UI/Settings/SettingsContainerView.swift | 75 ++++++- readeck/UI/Settings/SettingsGeneralView.swift | 11 +- 15 files changed, 677 insertions(+), 97 deletions(-) create mode 100644 readeck/Domain/UseCase/ClearCacheUseCase.swift create mode 100644 readeck/Domain/UseCase/GetCacheSizeUseCase.swift create mode 100644 readeck/Domain/UseCase/GetMaxCacheSizeUseCase.swift create mode 100644 readeck/Domain/UseCase/UpdateMaxCacheSizeUseCase.swift create mode 100644 readeck/UI/Settings/CacheSettingsViewModel.swift create mode 100644 readeck/UI/Settings/CachedArticlesPreviewView.swift create mode 100644 readeck/UI/Settings/CachedArticlesPreviewViewModel.swift rename readeck/UI/Settings/{OfflineSettingsView.swift => OfflineReadingDetailView.swift} (75%) diff --git a/readeck/Data/CoreData/CoreDataManager.swift b/readeck/Data/CoreData/CoreDataManager.swift index 7bb0fa0..bd2c899 100644 --- a/readeck/Data/CoreData/CoreDataManager.swift +++ b/readeck/Data/CoreData/CoreDataManager.swift @@ -75,6 +75,35 @@ class CoreDataManager { } } } + + #if DEBUG + func resetDatabase() throws { + logger.warning("⚠️ Resetting Core Data database - ALL DATA WILL BE DELETED") + + guard let store = persistentContainer.persistentStoreCoordinator.persistentStores.first else { + throw NSError(domain: "CoreDataManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No persistent store found"]) + } + + guard let storeURL = store.url else { + throw NSError(domain: "CoreDataManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Store URL not found"]) + } + + // Remove the persistent store + try persistentContainer.persistentStoreCoordinator.remove(store) + + // Delete the store files + try FileManager.default.removeItem(at: storeURL) + + // Also delete related files (-wal, -shm) + let walURL = storeURL.deletingPathExtension().appendingPathExtension("sqlite-wal") + let shmURL = storeURL.deletingPathExtension().appendingPathExtension("sqlite-shm") + + try? FileManager.default.removeItem(at: walURL) + try? FileManager.default.removeItem(at: shmURL) + + logger.info("Core Data database files deleted successfully") + } + #endif private func setupInMemoryStore(container: NSPersistentContainer) { logger.warning("Setting up in-memory Core Data store as fallback") diff --git a/readeck/Domain/Model/OfflineSettings.swift b/readeck/Domain/Model/OfflineSettings.swift index d142805..26f84ef 100644 --- a/readeck/Domain/Model/OfflineSettings.swift +++ b/readeck/Domain/Model/OfflineSettings.swift @@ -8,7 +8,7 @@ import Foundation struct OfflineSettings: Codable { - var enabled: Bool = true + var enabled: Bool = false var maxUnreadArticles: Double = 20 // Double für Slider (Default: 20 Artikel) var saveImages: Bool = false var lastSyncDate: Date? diff --git a/readeck/Domain/UseCase/ClearCacheUseCase.swift b/readeck/Domain/UseCase/ClearCacheUseCase.swift new file mode 100644 index 0000000..04de2aa --- /dev/null +++ b/readeck/Domain/UseCase/ClearCacheUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PClearCacheUseCase { + func execute() async throws +} + +class ClearCacheUseCase: PClearCacheUseCase { + private let settingsRepository: PSettingsRepository + + init(settingsRepository: PSettingsRepository) { + self.settingsRepository = settingsRepository + } + + func execute() async throws { + try await settingsRepository.clearCache() + } +} diff --git a/readeck/Domain/UseCase/GetCacheSizeUseCase.swift b/readeck/Domain/UseCase/GetCacheSizeUseCase.swift new file mode 100644 index 0000000..e967c18 --- /dev/null +++ b/readeck/Domain/UseCase/GetCacheSizeUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PGetCacheSizeUseCase { + func execute() async throws -> UInt +} + +class GetCacheSizeUseCase: PGetCacheSizeUseCase { + private let settingsRepository: PSettingsRepository + + init(settingsRepository: PSettingsRepository) { + self.settingsRepository = settingsRepository + } + + func execute() async throws -> UInt { + return try await settingsRepository.getCacheSize() + } +} diff --git a/readeck/Domain/UseCase/GetMaxCacheSizeUseCase.swift b/readeck/Domain/UseCase/GetMaxCacheSizeUseCase.swift new file mode 100644 index 0000000..0519fe0 --- /dev/null +++ b/readeck/Domain/UseCase/GetMaxCacheSizeUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PGetMaxCacheSizeUseCase { + func execute() async throws -> UInt +} + +class GetMaxCacheSizeUseCase: PGetMaxCacheSizeUseCase { + private let settingsRepository: PSettingsRepository + + init(settingsRepository: PSettingsRepository) { + self.settingsRepository = settingsRepository + } + + func execute() async throws -> UInt { + return try await settingsRepository.getMaxCacheSize() + } +} diff --git a/readeck/Domain/UseCase/UpdateMaxCacheSizeUseCase.swift b/readeck/Domain/UseCase/UpdateMaxCacheSizeUseCase.swift new file mode 100644 index 0000000..9507de7 --- /dev/null +++ b/readeck/Domain/UseCase/UpdateMaxCacheSizeUseCase.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol PUpdateMaxCacheSizeUseCase { + func execute(sizeInBytes: UInt) async throws +} + +class UpdateMaxCacheSizeUseCase: PUpdateMaxCacheSizeUseCase { + private let settingsRepository: PSettingsRepository + + init(settingsRepository: PSettingsRepository) { + self.settingsRepository = settingsRepository + } + + func execute(sizeInBytes: UInt) async throws { + try await settingsRepository.updateMaxCacheSize(sizeInBytes) + } +} diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index d243564..02adfd8 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -31,6 +31,10 @@ protocol UseCaseFactory { func makeGetCachedBookmarksUseCase() -> PGetCachedBookmarksUseCase func makeGetCachedArticleUseCase() -> PGetCachedArticleUseCase func makeCreateAnnotationUseCase() -> PCreateAnnotationUseCase + func makeGetCacheSizeUseCase() -> PGetCacheSizeUseCase + func makeGetMaxCacheSizeUseCase() -> PGetMaxCacheSizeUseCase + func makeUpdateMaxCacheSizeUseCase() -> PUpdateMaxCacheSizeUseCase + func makeClearCacheUseCase() -> PClearCacheUseCase } @@ -180,4 +184,20 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeCreateAnnotationUseCase() -> PCreateAnnotationUseCase { return CreateAnnotationUseCase(repository: annotationsRepository) } + + func makeGetCacheSizeUseCase() -> PGetCacheSizeUseCase { + return GetCacheSizeUseCase(settingsRepository: settingsRepository) + } + + func makeGetMaxCacheSizeUseCase() -> PGetMaxCacheSizeUseCase { + return GetMaxCacheSizeUseCase(settingsRepository: settingsRepository) + } + + func makeUpdateMaxCacheSizeUseCase() -> PUpdateMaxCacheSizeUseCase { + return UpdateMaxCacheSizeUseCase(settingsRepository: settingsRepository) + } + + func makeClearCacheUseCase() -> PClearCacheUseCase { + return ClearCacheUseCase(settingsRepository: settingsRepository) + } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index 0e0d995..46bdff4 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -9,6 +9,18 @@ import Foundation import Combine class MockUseCaseFactory: UseCaseFactory { + func makeGetCachedBookmarksUseCase() -> any PGetCachedBookmarksUseCase { + MockGetCachedBookmarksUseCase() + } + + func makeGetCachedArticleUseCase() -> any PGetCachedArticleUseCase { + MockGetCachedArticleUseCase() + } + + func makeCreateAnnotationUseCase() -> any PCreateAnnotationUseCase { + MockCreateAnnotationUseCase() + } + func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase { MockCheckServerReachabilityUseCase() } @@ -116,6 +128,22 @@ class MockUseCaseFactory: UseCaseFactory { func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase { MockNetworkMonitorUseCase() } + + func makeGetCacheSizeUseCase() -> PGetCacheSizeUseCase { + MockGetCacheSizeUseCase() + } + + func makeGetMaxCacheSizeUseCase() -> PGetMaxCacheSizeUseCase { + MockGetMaxCacheSizeUseCase() + } + + func makeUpdateMaxCacheSizeUseCase() -> PUpdateMaxCacheSizeUseCase { + MockUpdateMaxCacheSizeUseCase() + } + + func makeClearCacheUseCase() -> PClearCacheUseCase { + MockClearCacheUseCase() + } } @@ -313,6 +341,10 @@ class MockSettingsRepository: PSettingsRepository { return OfflineSettings() } func saveOfflineSettings(_ settings: OfflineSettings) async throws {} + func getCacheSize() async throws -> UInt { return 0 } + func getMaxCacheSize() async throws -> UInt { return 200 * 1024 * 1024 } + func updateMaxCacheSize(_ sizeInBytes: UInt) async throws {} + func clearCache() async throws {} } class MockOfflineCacheSyncUseCase: POfflineCacheSyncUseCase { @@ -374,8 +406,53 @@ class MockNetworkMonitorUseCase: PNetworkMonitorUseCase { } } +class MockGetCachedBookmarksUseCase: PGetCachedBookmarksUseCase { + func execute() async throws -> [Bookmark] { + return [Bookmark.mock] + } +} + +class MockGetCachedArticleUseCase: PGetCachedArticleUseCase { + func execute(id: String) -> String? { + let path = Bundle.main.path(forResource: "article", ofType: "html") + return try? String(contentsOfFile: path!) + } +} + +class MockCreateAnnotationUseCase: PCreateAnnotationUseCase { + func execute(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> Annotation { + return Annotation(id: "", text: "", created: "", startOffset: 0, endOffset: 1, startSelector: "", endSelector: "") + + + } + + func execute(bookmarkId: String, text: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws { + // Mock implementation - do nothing + } +} + 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) ) } + +class MockGetCacheSizeUseCase: PGetCacheSizeUseCase { + func execute() async throws -> UInt { + return 0 + } +} + +class MockGetMaxCacheSizeUseCase: PGetMaxCacheSizeUseCase { + func execute() async throws -> UInt { + return 200 * 1024 * 1024 + } +} + +class MockUpdateMaxCacheSizeUseCase: PUpdateMaxCacheSizeUseCase { + func execute(sizeInBytes: UInt) async throws {} +} + +class MockClearCacheUseCase: PClearCacheUseCase { + func execute() async throws {} +} diff --git a/readeck/UI/Settings/CacheSettingsView.swift b/readeck/UI/Settings/CacheSettingsView.swift index 53b98b6..c5b1bf6 100644 --- a/readeck/UI/Settings/CacheSettingsView.swift +++ b/readeck/UI/Settings/CacheSettingsView.swift @@ -1,24 +1,22 @@ import SwiftUI -import Kingfisher struct CacheSettingsView: View { - @State private var cacheSize: String = "0 MB" - @State private var maxCacheSize: Double = 200 - @State private var isClearing: Bool = false - @State private var showClearAlert: Bool = false + @State private var viewModel = CacheSettingsViewModel() var body: some View { Section { HStack { VStack(alignment: .leading, spacing: 2) { Text("Current Cache Size") - Text("\(cacheSize) / \(Int(maxCacheSize)) MB max") + Text("\(viewModel.cacheSize) / \(Int(viewModel.maxCacheSize)) MB max") .font(.caption) .foregroundColor(.secondary) } Spacer() Button("Refresh") { - updateCacheSize() + Task { + await viewModel.updateCacheSize() + } } .font(.caption) .foregroundColor(.blue) @@ -28,24 +26,26 @@ struct CacheSettingsView: View { HStack { Text("Max Cache Size") Spacer() - Text("\(Int(maxCacheSize)) MB") + Text("\(Int(viewModel.maxCacheSize)) MB") .font(.caption) .foregroundColor(.secondary) } - Slider(value: $maxCacheSize, in: 50...1200, step: 50) { + Slider(value: $viewModel.maxCacheSize, in: 50...1200, step: 50) { Text("Max Cache Size") } - .onChange(of: maxCacheSize) { _, newValue in - updateMaxCacheSize(newValue) + .onChange(of: viewModel.maxCacheSize) { _, newValue in + Task { + await viewModel.updateMaxCacheSize(newValue) + } } } Button(action: { - showClearAlert = true + viewModel.showClearAlert = true }) { HStack { - if isClearing { + if viewModel.isClearing { ProgressView() .scaleEffect(0.8) } else { @@ -55,7 +55,7 @@ struct CacheSettingsView: View { VStack(alignment: .leading, spacing: 2) { Text("Clear Cache") - .foregroundColor(isClearing ? .secondary : .red) + .foregroundColor(viewModel.isClearing ? .secondary : .red) Text("Remove all cached images") .font(.caption) .foregroundColor(.secondary) @@ -64,69 +64,24 @@ struct CacheSettingsView: View { Spacer() } } - .disabled(isClearing) + .disabled(viewModel.isClearing) } header: { Text("Cache Settings") } - .onAppear { - updateCacheSize() - loadMaxCacheSize() + .task { + await viewModel.loadCacheSettings() } - .alert("Clear Cache", isPresented: $showClearAlert) { + .alert("Clear Cache", isPresented: $viewModel.showClearAlert) { Button("Cancel", role: .cancel) { } Button("Clear", role: .destructive) { - clearCache() + Task { + await viewModel.clearCache() + } } } message: { Text("This will remove all cached images. They will be downloaded again when needed.") } } - - private func updateCacheSize() { - KingfisherManager.shared.cache.calculateDiskStorageSize { result in - DispatchQueue.main.async { - switch result { - case .success(let size): - let mbSize = Double(size) / (1024 * 1024) - self.cacheSize = String(format: "%.1f MB", mbSize) - case .failure: - self.cacheSize = "Unknown" - } - } - } - } - - private func loadMaxCacheSize() { - let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt - if let savedSize = savedSize { - maxCacheSize = Double(savedSize) / (1024 * 1024) - KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize - } else { - maxCacheSize = 200 - let defaultBytes = UInt(200 * 1024 * 1024) - KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes - UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize") - } - } - - private func updateMaxCacheSize(_ newSize: Double) { - let bytes = UInt(newSize * 1024 * 1024) - KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes - UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize") - } - - private func clearCache() { - isClearing = true - - KingfisherManager.shared.cache.clearDiskCache { - DispatchQueue.main.async { - self.isClearing = false - self.updateCacheSize() - } - } - - KingfisherManager.shared.cache.clearMemoryCache() - } } #Preview { diff --git a/readeck/UI/Settings/CacheSettingsViewModel.swift b/readeck/UI/Settings/CacheSettingsViewModel.swift new file mode 100644 index 0000000..cfde1d5 --- /dev/null +++ b/readeck/UI/Settings/CacheSettingsViewModel.swift @@ -0,0 +1,92 @@ +// +// CacheSettingsViewModel.swift +// readeck +// +// Created by Claude on 01.12.25. +// + +import Foundation +import Observation + +@Observable +class CacheSettingsViewModel { + + // MARK: - Dependencies + + private let getCacheSizeUseCase: PGetCacheSizeUseCase + private let getMaxCacheSizeUseCase: PGetMaxCacheSizeUseCase + private let updateMaxCacheSizeUseCase: PUpdateMaxCacheSizeUseCase + private let clearCacheUseCase: PClearCacheUseCase + + // MARK: - Published State + + var cacheSize: String = "0 MB" + var maxCacheSize: Double = 200 // in MB + var isClearing: Bool = false + var showClearAlert: Bool = false + + // MARK: - Initialization + + init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + self.getCacheSizeUseCase = factory.makeGetCacheSizeUseCase() + self.getMaxCacheSizeUseCase = factory.makeGetMaxCacheSizeUseCase() + self.updateMaxCacheSizeUseCase = factory.makeUpdateMaxCacheSizeUseCase() + self.clearCacheUseCase = factory.makeClearCacheUseCase() + } + + // MARK: - Public Methods + + @MainActor + func loadCacheSettings() async { + await updateCacheSize() + await loadMaxCacheSize() + } + + @MainActor + func updateCacheSize() async { + do { + let sizeInBytes = try await getCacheSizeUseCase.execute() + let mbSize = Double(sizeInBytes) / (1024 * 1024) + cacheSize = String(format: "%.1f MB", mbSize) + Logger.viewModel.debug("Cache size: \(cacheSize)") + } catch { + cacheSize = "Unknown" + Logger.viewModel.error("Failed to get cache size: \(error.localizedDescription)") + } + } + + @MainActor + func loadMaxCacheSize() async { + do { + let sizeInBytes = try await getMaxCacheSizeUseCase.execute() + maxCacheSize = Double(sizeInBytes) / (1024 * 1024) + Logger.viewModel.debug("Max cache size: \(maxCacheSize) MB") + } catch { + Logger.viewModel.error("Failed to load max cache size: \(error.localizedDescription)") + } + } + + @MainActor + func updateMaxCacheSize(_ newSize: Double) async { + let bytes = UInt(newSize * 1024 * 1024) + do { + try await updateMaxCacheSizeUseCase.execute(sizeInBytes: bytes) + Logger.viewModel.info("Updated max cache size to \(newSize) MB") + } catch { + Logger.viewModel.error("Failed to update max cache size: \(error.localizedDescription)") + } + } + + @MainActor + func clearCache() async { + isClearing = true + do { + try await clearCacheUseCase.execute() + await updateCacheSize() + Logger.viewModel.info("Cache cleared successfully") + } catch { + Logger.viewModel.error("Failed to clear cache: \(error.localizedDescription)") + } + isClearing = false + } +} diff --git a/readeck/UI/Settings/CachedArticlesPreviewView.swift b/readeck/UI/Settings/CachedArticlesPreviewView.swift new file mode 100644 index 0000000..93625d5 --- /dev/null +++ b/readeck/UI/Settings/CachedArticlesPreviewView.swift @@ -0,0 +1,200 @@ +// +// CachedArticlesPreviewView.swift +// readeck +// +// Created by Claude on 30.11.25. +// + +import SwiftUI + +struct CachedArticlesPreviewView: View { + + // MARK: - State + + @State private var viewModel = CachedArticlesPreviewViewModel() + @State private var selectedBookmarkId: String? + @EnvironmentObject var appSettings: AppSettings + + // MARK: - Body + + var body: some View { + ZStack { + if viewModel.isLoading && viewModel.cachedBookmarks.isEmpty { + loadingView + } else if let errorMessage = viewModel.errorMessage { + errorView(message: errorMessage) + } else if viewModel.cachedBookmarks.isEmpty { + emptyStateView + } else { + cachedBookmarksList + } + } + .navigationTitle("Cached Articles") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination( + item: Binding( + get: { selectedBookmarkId }, + set: { selectedBookmarkId = $0 } + ) + ) { bookmarkId in + BookmarkDetailView(bookmarkId: bookmarkId) + .toolbar(.hidden, for: .tabBar) + } + .task { + await viewModel.loadCachedBookmarks() + } + } + + // MARK: - View Components + + @ViewBuilder + private var cachedBookmarksList: some View { + List { + Section { + ForEach(viewModel.cachedBookmarks, id: \.id) { bookmark in + Button(action: { + selectedBookmarkId = bookmark.id + }) { + BookmarkCardView( + bookmark: bookmark, + currentState: .unread, + layout: .magazine, + onArchive: { _ in }, + onDelete: { _ in }, + onToggleFavorite: { _ in } + ) + } + .buttonStyle(PlainButtonStyle()) + .listRowInsets(EdgeInsets( + top: 12, + leading: 16, + bottom: 12, + trailing: 16 + )) + .listRowSeparator(.hidden) + .listRowBackground(Color(R.color.bookmark_list_bg)) + } + } header: { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + Text("\(viewModel.cachedBookmarks.count) articles cached") + .font(.caption) + .foregroundColor(.secondary) + } + .textCase(nil) + .padding(.bottom, 4) + } footer: { + Text("These articles are available offline. You can read them without an internet connection.") + .font(.caption) + .foregroundColor(.secondary) + } + } + .listStyle(.insetGrouped) + .background(Color(R.color.bookmark_list_bg)) + .scrollContentBackground(.hidden) + .refreshable { + await viewModel.refreshList() + } + } + + @ViewBuilder + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.3) + .tint(.accentColor) + + VStack(spacing: 8) { + Text("Loading Cached Articles") + .font(.headline) + .foregroundColor(.primary) + + Text("Please wait...") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(R.color.bookmark_list_bg)) + } + + @ViewBuilder + private func errorView(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundColor(.orange) + + VStack(spacing: 8) { + Text("Unable to load cached articles") + .font(.headline) + .foregroundColor(.primary) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + Button("Try Again") { + Task { + await viewModel.loadCachedBookmarks() + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(.horizontal, 40) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(R.color.bookmark_list_bg)) + } + + @ViewBuilder + private var emptyStateView: some View { + VStack(spacing: 20) { + Image(systemName: "tray") + .font(.system(size: 64)) + .foregroundColor(.secondary.opacity(0.5)) + + VStack(spacing: 8) { + Text("No Cached Articles") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text("Enable offline reading and sync to cache articles for offline access") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + // Hint + VStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + .font(.caption) + Text("Use 'Sync Now' to download articles") + .font(.caption) + } + .foregroundColor(.accentColor) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.1)) + .clipShape(Capsule()) + } + .padding(.top, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(R.color.bookmark_list_bg)) + } +} + +#Preview { + NavigationStack { + CachedArticlesPreviewView() + .environmentObject(AppSettings()) + } +} diff --git a/readeck/UI/Settings/CachedArticlesPreviewViewModel.swift b/readeck/UI/Settings/CachedArticlesPreviewViewModel.swift new file mode 100644 index 0000000..9f85415 --- /dev/null +++ b/readeck/UI/Settings/CachedArticlesPreviewViewModel.swift @@ -0,0 +1,53 @@ +// +// CachedArticlesPreviewViewModel.swift +// readeck +// +// Created by Claude on 30.11.25. +// + +import Foundation +import SwiftUI + +@Observable +class CachedArticlesPreviewViewModel { + + // MARK: - Dependencies + + private let getCachedBookmarksUseCase: PGetCachedBookmarksUseCase + + // MARK: - Published State + + var cachedBookmarks: [Bookmark] = [] + var isLoading = false + var errorMessage: String? + + // MARK: - Initialization + + init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + self.getCachedBookmarksUseCase = factory.makeGetCachedBookmarksUseCase() + } + + // MARK: - Public Methods + + @MainActor + func loadCachedBookmarks() async { + isLoading = true + errorMessage = nil + + do { + Logger.viewModel.info("📱 CachedArticlesPreviewViewModel: Loading cached bookmarks...") + cachedBookmarks = try await getCachedBookmarksUseCase.execute() + Logger.viewModel.info("✅ Loaded \(cachedBookmarks.count) cached bookmarks for preview") + } catch { + Logger.viewModel.error("❌ Failed to load cached bookmarks: \(error.localizedDescription)") + errorMessage = "Failed to load cached articles" + } + + isLoading = false + } + + @MainActor + func refreshList() async { + await loadCachedBookmarks() + } +} diff --git a/readeck/UI/Settings/OfflineSettingsView.swift b/readeck/UI/Settings/OfflineReadingDetailView.swift similarity index 75% rename from readeck/UI/Settings/OfflineSettingsView.swift rename to readeck/UI/Settings/OfflineReadingDetailView.swift index b26631a..66a2d0c 100644 --- a/readeck/UI/Settings/OfflineSettingsView.swift +++ b/readeck/UI/Settings/OfflineReadingDetailView.swift @@ -1,5 +1,5 @@ // -// OfflineSettingsView.swift +// OfflineReadingDetailView.swift // readeck // // Created by Claude on 17.11.25. @@ -7,12 +7,12 @@ import SwiftUI -struct OfflineSettingsView: View { +struct OfflineReadingDetailView: View { @State private var viewModel = OfflineSettingsViewModel() @EnvironmentObject var appSettings: AppSettings var body: some View { - Group { + List { Section { VStack(alignment: .leading, spacing: 4) { Toggle("Enable Offline Reading", isOn: $viewModel.offlineSettings.enabled) @@ -67,7 +67,21 @@ struct OfflineSettingsView: View { .foregroundColor(.secondary) .padding(.top, 2) } + } + } header: { + Text("Settings") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .textCase(nil) + } footer: { + Text("VPN connections are detected as active internet connections.") + .font(.caption) + .foregroundColor(.secondary) + } + if viewModel.offlineSettings.enabled { + Section { // Sync button Button(action: { Task { @@ -103,20 +117,27 @@ struct OfflineSettingsView: View { } .disabled(viewModel.isSyncing) - // Cache stats + // Cache stats with preview link if viewModel.cachedArticlesCount > 0 { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Cached Articles") - Text("\(viewModel.cachedArticlesCount) articles (\(viewModel.cacheSize))") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() + SettingsRowNavigationLink( + icon: "doc.text.magnifyingglass", + iconColor: .green, + title: "Preview Cached Articles", + subtitle: "\(viewModel.cachedArticlesCount) articles (\(viewModel.cacheSize))" + ) { + CachedArticlesPreviewView() } } + } header: { + Text("Synchronization") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .textCase(nil) + } - #if DEBUG + #if DEBUG + Section { // Debug: Toggle offline mode simulation VStack(alignment: .leading, spacing: 4) { Toggle(isOn: Binding( @@ -139,12 +160,19 @@ struct OfflineSettingsView: View { } } } - #endif + } header: { + Text("Debug") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .textCase(nil) } - } header: { - Text("Offline Reading") + #endif } } + .listStyle(.insetGrouped) + .navigationTitle("Offline Reading") + .navigationBarTitleDisplayMode(.inline) .task { await viewModel.loadSettings() } diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index ae74d88..467bb44 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -8,6 +8,7 @@ import SwiftUI struct SettingsContainerView: View { + @State private var offlineViewModel = OfflineSettingsViewModel() private var appVersion: String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" @@ -19,13 +20,67 @@ struct SettingsContainerView: View { List { AppearanceSettingsView() - ReadingSettingsView() + Section { + Toggle("Enable Offline Reading", isOn: $offlineViewModel.offlineSettings.enabled) + .onChange(of: offlineViewModel.offlineSettings.enabled) { + Task { + await offlineViewModel.saveSettings() + } + } + + if offlineViewModel.offlineSettings.enabled { + Button(action: { + Task { + await offlineViewModel.syncNow() + } + }) { + HStack { + if offlineViewModel.isSyncing { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.clockwise") + .foregroundColor(.blue) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Sync Now") + .foregroundColor(offlineViewModel.isSyncing ? .secondary : .blue) + + if let progress = offlineViewModel.syncProgress { + Text(progress) + .font(.caption) + .foregroundColor(.secondary) + } else if let lastSync = offlineViewModel.offlineSettings.lastSyncDate { + Text("Last synced: \(lastSync.formatted(.relative(presentation: .named)))") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + } + } + .disabled(offlineViewModel.isSyncing) + + SettingsRowNavigationLink( + icon: "arrow.down.circle.fill", + iconColor: .blue, + title: "Offline Reading", + subtitle: offlineViewModel.cachedArticlesCount > 0 ? "\(offlineViewModel.cachedArticlesCount) articles cached" : nil + ) { + OfflineReadingDetailView() + } + } + } header: { + Text("Offline Reading") + } footer: { + Text("Automatically download articles for offline use. VPN connections are detected as active internet connections.") + } CacheSettingsView() - SyncSettingsView() - - OfflineSettingsView() + ReadingSettingsView() SettingsServerView() @@ -44,11 +99,23 @@ struct SettingsContainerView: View { .listStyle(.insetGrouped) .navigationTitle("Settings") .navigationBarTitleDisplayMode(.large) + .task { + await offlineViewModel.loadSettings() + } } @ViewBuilder private var debugSettingsSection: some View { Section { + SettingsRowNavigationLink( + icon: "wrench.and.screwdriver.fill", + iconColor: .orange, + title: "Debug Menu", + subtitle: "Network simulation, data management & more" + ) { + DebugMenuView() + } + SettingsRowNavigationLink( icon: "list.bullet.rectangle", iconColor: .blue, diff --git a/readeck/UI/Settings/SettingsGeneralView.swift b/readeck/UI/Settings/SettingsGeneralView.swift index 6d93175..75991ca 100644 --- a/readeck/UI/Settings/SettingsGeneralView.swift +++ b/readeck/UI/Settings/SettingsGeneralView.swift @@ -45,16 +45,7 @@ struct SettingsGeneralView: View { Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.") } - #if DEBUG - Section { - Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled) - if viewModel.autoSyncEnabled { - Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60) - } - } header: { - Text("Sync Settings") - } - + #if DEBUG Section { Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)