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:
Ilyas Hallak 2025-12-01 21:56:13 +01:00
parent d3e15c6352
commit 4fd55ef5d0
15 changed files with 677 additions and 97 deletions

View File

@ -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")

View File

@ -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?

View 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()
}
}

View 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()
}
}

View 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()
}
}

View 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)
}
}

View File

@ -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)
}
}

View File

@ -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 {}
}

View File

@ -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 {

View 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
}
}

View 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())
}
}

View 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()
}
}

View File

@ -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()
}

View File

@ -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,

View File

@ -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)