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
This commit is contained in:
parent
d3e15c6352
commit
4fd55ef5d0
@ -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")
|
||||
|
||||
@ -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?
|
||||
|
||||
17
readeck/Domain/UseCase/ClearCacheUseCase.swift
Normal file
17
readeck/Domain/UseCase/ClearCacheUseCase.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
17
readeck/Domain/UseCase/GetCacheSizeUseCase.swift
Normal file
17
readeck/Domain/UseCase/GetCacheSizeUseCase.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
17
readeck/Domain/UseCase/GetMaxCacheSizeUseCase.swift
Normal file
17
readeck/Domain/UseCase/GetMaxCacheSizeUseCase.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
17
readeck/Domain/UseCase/UpdateMaxCacheSizeUseCase.swift
Normal file
17
readeck/Domain/UseCase/UpdateMaxCacheSizeUseCase.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
92
readeck/UI/Settings/CacheSettingsViewModel.swift
Normal file
92
readeck/UI/Settings/CacheSettingsViewModel.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
200
readeck/UI/Settings/CachedArticlesPreviewView.swift
Normal file
200
readeck/UI/Settings/CachedArticlesPreviewView.swift
Normal file
@ -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<String?>(
|
||||
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())
|
||||
}
|
||||
}
|
||||
53
readeck/UI/Settings/CachedArticlesPreviewViewModel.swift
Normal file
53
readeck/UI/Settings/CachedArticlesPreviewViewModel.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user