Settings Refactor: Unified card design for server and general settings, centralized SectionHeader component, setup mode now only shows server card, automatic view switching after login/logout, consistent button styles and spacing, removed code duplication.

This commit is contained in:
Ilyas Hallak 2025-06-30 22:21:19 +02:00
parent 0f66b15b01
commit cc08b2cc1b
18 changed files with 982 additions and 359 deletions

16
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug",
"program": "${workspaceFolder}/<executable file>",
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

View File

@ -9,6 +9,7 @@ struct Settings {
var fontFamily: FontFamily? = nil
var fontSize: FontSize? = nil
var hasFinishedSetup: Bool = false
var isLoggedIn: Bool {
token != nil && !token!.isEmpty
@ -24,10 +25,15 @@ protocol PSettingsRepository {
func loadSettings() async throws -> Settings?
func clearSettings() async throws
func saveToken(_ token: String) async throws
func saveUsername(_ username: String) async throws
func savePassword(_ password: String) async throws
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
var hasFinishedSetup: Bool { get }
}
class SettingsRepository: PSettingsRepository {
private let coreDataManager = CoreDataManager.shared
private let userDefault = UserDefaults.standard
func saveSettings(_ settings: Settings) async throws {
let context = coreDataManager.context
@ -141,11 +147,47 @@ class SettingsRepository: PSettingsRepository {
if let settingEntity = settingEntities.first {
settingEntity.token = token
} else {
// Fallback: Neue Einstellung erstellen (sollte normalerweise nicht passieren)
let settingEntity = SettingEntity(context: context)
settingEntity.token = token
}
try context.save()
// Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren
if !token.isEmpty {
self.hasFinishedSetup = true
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
}
}
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func saveUsername(_ username: String) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
if let settingEntity = settingEntities.first {
settingEntity.username = username
} else {
let settingEntity = SettingEntity(context: context)
settingEntity.username = username
}
try context.save()
continuation.resume()
} catch {
@ -154,4 +196,51 @@ class SettingsRepository: PSettingsRepository {
}
}
}
func savePassword(_ password: String) async throws {
let context = coreDataManager.context
return try await withCheckedThrowingContinuation { continuation in
context.perform {
do {
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
fetchRequest.fetchLimit = 1
let settingEntities = try context.fetch(fetchRequest)
if let settingEntity = settingEntities.first {
settingEntity.password = password
} else {
let settingEntity = SettingEntity(context: context)
settingEntity.password = password
}
try context.save()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws {
return try await withCheckedThrowingContinuation { continuation in
self.hasFinishedSetup = hasFinishedSetup
// Notification senden, dass sich der Setup-Status geändert hat
DispatchQueue.main.async {
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
}
continuation.resume()
}
}
var hasFinishedSetup: Bool {
get {
return userDefault.value(forKey: "hasFinishedSetup") as? Bool ?? false
}
set {
userDefault.set(newValue, forKey: "hasFinishedSetup")
}
}
}

View File

@ -34,12 +34,13 @@ class TokenManager {
}
}
func clearToken() async {
func clearToken() async throws {
do {
try await settingsRepository.clearSettings()
cachedSettings = nil
try await settingsRepository.saveToken("")
cachedSettings?.token = ""
} catch {
print("Failed to clear settings: \(error)")
print("Failed to clear token: \(error)")
throw error
}
}
}

View File

@ -0,0 +1,43 @@
//
// LogoutUseCase.swift
// readeck
//
// Created by Ilyas Hallak on 29.06.25.
//
import Foundation
protocol LogoutUseCaseProtocol {
func execute() async throws
}
class LogoutUseCase: LogoutUseCaseProtocol {
private let settingsRepository: SettingsRepository
private let tokenManager: TokenManager
init(
settingsRepository: SettingsRepository = SettingsRepository(),
tokenManager: TokenManager = TokenManager.shared
) {
self.settingsRepository = settingsRepository
self.tokenManager = tokenManager
}
func execute() async throws {
// Clear the token
try await tokenManager.clearToken()
// Reset hasFinishedSetup to false
try await settingsRepository.saveHasFinishedSetup(false)
// Clear user session data
try await settingsRepository.saveToken("")
try await settingsRepository.saveUsername("")
try await settingsRepository.savePassword("")
// Note: We keep the endpoint for potential re-login
// but clear the authentication data
print("LogoutUseCase: User logged out successfully")
}
}

View File

@ -17,6 +17,17 @@ class SaveSettingsUseCase {
)
}
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws {
try await settingsRepository.saveSettings(
.init(
endpoint: endpoint,
username: username,
password: password,
hasFinishedSetup: hasFinishedSetup
)
)
}
func execute(token: String) async throws {
try await settingsRepository.saveSettings(
.init(

View File

@ -5,6 +5,7 @@ struct BookmarkDetailView: View {
let bookmarkId: String
@State private var viewModel = BookmarkDetailViewModel()
@State private var webViewHeight: CGFloat = 300
@State private var showingFontSettings = false
var body: some View {
ScrollView {
@ -51,6 +52,29 @@ struct BookmarkDetailView: View {
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingFontSettings = true
}) {
Image(systemName: "textformat")
}
}
}
.sheet(isPresented: $showingFontSettings) {
NavigationView {
FontSettingsView()
.navigationTitle("Schrift-Einstellungen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Fertig") {
showingFontSettings = false
}
}
}
}
}
.task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)

View File

@ -10,6 +10,7 @@ protocol UseCaseFactory {
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase
func makeLogoutUseCase() -> LogoutUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
@ -51,6 +52,10 @@ class DefaultUseCaseFactory: UseCaseFactory {
return UpdateBookmarkUseCase(repository: bookmarksRepository)
}
func makeLogoutUseCase() -> LogoutUseCase {
return LogoutUseCase()
}
// Nicht mehr nötig - Token wird automatisch geladen
func refreshConfiguration() async {
// Optional: Cache löschen falls nötig

View File

@ -0,0 +1,18 @@
//
// UIDeviceExtension.swift
// readeck
//
// Created by Ilyas Hallak on 29.06.25.
//
import UIKit
extension UIDevice {
static var isPad: Bool {
return UIDevice.current.userInterfaceIdiom == .pad
}
static var isPhone: Bool {
return UIDevice.current.userInterfaceIdiom == .phone
}
}

View File

@ -0,0 +1,104 @@
//
// FontSettingsView.swift
// readeck
//
// Created by Ilyas Hallak on 29.06.25.
//
import SwiftUI
struct FontSettingsView: View {
@State private var viewModel = FontSettingsViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 8) {
Image(systemName: "textformat")
.font(.title2)
.foregroundColor(.accentColor)
Text("Schrift")
.font(.title2)
.fontWeight(.bold)
}
Spacer()
// Font Family Picker
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text("Schriftart")
.font(.headline)
Picker("Schriftart", selection: $viewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family)
}
}
.pickerStyle(MenuPickerStyle())
.onChange(of: viewModel.selectedFontFamily) {
Task {
await viewModel.saveFontSettings()
}
}
}
Spacer()
VStack(spacing: 16) {
// Font Size Picker
VStack(alignment: .leading, spacing: 8) {
Text("Schriftgröße")
.font(.headline)
Picker("Schriftgröße", selection: $viewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
.pickerStyle(SegmentedPickerStyle())
.onChange(of: viewModel.selectedFontSize) {
Task {
await viewModel.saveFontSettings()
}
}
}
// Font Preview
VStack(alignment: .leading, spacing: 8) {
Text("Vorschau")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 6) {
Text("readeck Bookmark Title")
.font(viewModel.previewTitleFont)
.fontWeight(.semibold)
.lineLimit(1)
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
.font(viewModel.previewBodyFont)
.lineLimit(3)
Text("12 min • Today • example.com")
.font(viewModel.previewCaptionFont)
.foregroundColor(.secondary)
}
.padding(4)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
.task {
await viewModel.loadFontSettings()
}
}
}
#Preview {
FontSettingsView()
}

View File

@ -0,0 +1,147 @@
//
// FontSettingsViewModel.swift
// readeck
//
// Created by Ilyas Hallak on 29.06.25.
//
import Foundation
import Observation
import SwiftUI
@Observable
class FontSettingsViewModel {
private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
// MARK: - Font Settings
var selectedFontFamily: FontFamily = .system
var selectedFontSize: FontSize = .medium
// MARK: - Messages
var errorMessage: String?
var successMessage: String?
// MARK: - Computed Font Properties for Preview
var previewTitleFont: Font {
switch selectedFontFamily {
case .system:
return selectedFontSize.systemFont.weight(.semibold)
case .serif:
return Font.custom("Times New Roman", size: selectedFontSize.size).weight(.semibold)
case .sansSerif:
return Font.custom("Helvetica Neue", size: selectedFontSize.size).weight(.semibold)
case .monospace:
return Font.custom("Menlo", size: selectedFontSize.size).weight(.semibold)
}
}
var previewBodyFont: Font {
switch selectedFontFamily {
case .system:
return selectedFontSize.systemFont
case .serif:
return Font.custom("Times New Roman", size: selectedFontSize.size)
case .sansSerif:
return Font.custom("Helvetica Neue", size: selectedFontSize.size)
case .monospace:
return Font.custom("Menlo", size: selectedFontSize.size)
}
}
var previewCaptionFont: Font {
let captionSize = selectedFontSize.size * 0.85
switch selectedFontFamily {
case .system:
return Font.system(size: captionSize)
case .serif:
return Font.custom("Times New Roman", size: captionSize)
case .sansSerif:
return Font.custom("Helvetica Neue", size: captionSize)
case .monospace:
return Font.custom("Menlo", size: captionSize)
}
}
init() {
let factory = DefaultUseCaseFactory.shared
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
}
@MainActor
func loadFontSettings() async {
do {
if let settings = try await loadSettingsUseCase.execute() {
selectedFontFamily = settings.fontFamily ?? .system
selectedFontSize = settings.fontSize ?? .medium
}
} catch {
errorMessage = "Fehler beim Laden der Schrift-Einstellungen"
}
}
@MainActor
func saveFontSettings() async {
do {
try await saveSettingsUseCase.execute(
selectedFontFamily: selectedFontFamily,
selectedFontSize: selectedFontSize
)
successMessage = "Schrift-Einstellungen gespeichert"
} catch {
errorMessage = "Fehler beim Speichern der Schrift-Einstellungen"
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
}
// MARK: - Font Enums (moved from SettingsViewModel)
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -0,0 +1,17 @@
import SwiftUI
struct SectionHeader: View {
let title: String
let icon: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(.accentColor)
Text(title)
.font(.title2)
.fontWeight(.bold)
}
}
}

View File

@ -0,0 +1,52 @@
//
// SettingsContainerView.swift
// readeck
//
// Created by Ilyas Hallak on 29.06.25.
//
import SwiftUI
struct SettingsContainerView: View {
@State private var viewModel = SettingsViewModel()
var body: some View {
NavigationView {
ScrollView {
LazyVStack(spacing: 20) {
// Server-Card immer anzeigen
SettingsServerView(viewModel: viewModel)
.cardStyle()
// Allgemeine Einstellungen nur im normalen Modus anzeigen
if !viewModel.isSetupMode {
SettingsGeneralView(viewModel: viewModel)
.cardStyle()
}
}
.padding()
.background(Color(.systemGroupedBackground))
}
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.large)
}
.task {
await viewModel.loadSettings()
}
}
}
// Card Modifier für einheitlichen Look
extension View {
func cardStyle() -> some View {
self
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.shadow(color: Color.black.opacity(0.06), radius: 4, x: 0, y: 2)
}
}
#Preview {
SettingsContainerView()
}

View File

@ -0,0 +1,166 @@
//
// SettingsGeneralView.swift
// readeck
//
// Created by Ilyas Hallak on 29.06.25.
//
import SwiftUI
// SectionHeader wird jetzt zentral importiert
struct SettingsGeneralView: View {
@State var viewModel: SettingsViewModel
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: "Allgemeine Einstellungen", icon: "gear")
.padding(.bottom, 4)
// Theme
VStack(alignment: .leading, spacing: 12) {
Text("Theme")
.font(.headline)
Picker("Theme", selection: $viewModel.selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme)
}
}
.pickerStyle(.segmented)
}
// Font Settings
FontSettingsView()
.padding(.vertical, 4)
// Sync Settings
VStack(alignment: .leading, spacing: 12) {
Text("Sync-Einstellungen")
.font(.headline)
Toggle("Automatischer Sync", isOn: $viewModel.autoSyncEnabled)
.toggleStyle(SwitchToggleStyle())
if viewModel.autoSyncEnabled {
HStack {
Text("Sync-Intervall")
Spacer()
Stepper("\(viewModel.syncInterval) Minuten", value: $viewModel.syncInterval, in: 1...60)
}
}
}
// Reading Settings
VStack(alignment: .leading, spacing: 12) {
Text("Leseeinstellungen")
.font(.headline)
Toggle("Safari Reader Modus", isOn: $viewModel.enableReaderMode)
.toggleStyle(SwitchToggleStyle())
Toggle("Externe Links in In-App Safari öffnen", isOn: $viewModel.openExternalLinksInApp)
.toggleStyle(SwitchToggleStyle())
Toggle("Artikel automatisch als gelesen markieren", isOn: $viewModel.autoMarkAsRead)
.toggleStyle(SwitchToggleStyle())
}
// Data Management
VStack(alignment: .leading, spacing: 12) {
Text("Datenmanagement")
.font(.headline)
Button(role: .destructive) {
Task {
// await viewModel.clearCache()
}
} label: {
HStack {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Cache leeren")
.foregroundColor(.red)
Spacer()
}
}
Button(role: .destructive) {
Task {
// await viewModel.resetSettings()
}
} label: {
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.red)
Text("Einstellungen zurücksetzen")
.foregroundColor(.red)
Spacer()
}
}
}
// App Info
VStack(alignment: .leading, spacing: 12) {
Text("Über die App")
.font(.headline)
HStack {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
Text("Version \(viewModel.appVersion)")
Spacer()
}
HStack {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
Text("Entwickler: \(viewModel.developerName)")
Spacer()
}
HStack {
Image(systemName: "globe")
.foregroundColor(.secondary)
Link("Website", destination: URL(string: "https://example.com")!)
Spacer()
}
}
// Save Button
Button(action: {
Task {
await viewModel.saveSettings()
}
}) {
HStack {
if viewModel.isSaving {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isSaving ? "Speichere..." : "Einstellungen speichern")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canSave ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canSave || viewModel.isSaving)
// Messages
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
}
}
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
}
}
}
#Preview {
SettingsGeneralView(viewModel: SettingsViewModel())
}

View File

@ -0,0 +1,225 @@
//
// SettingsServerView.swift
// readeck
//
// Created by Ilyas Hallak on 29.06.25.
//
import SwiftUI
// SectionHeader wird jetzt zentral importiert
struct SettingsServerView: View {
@State var viewModel = SettingsViewModel()
@State private var isTesting: Bool = false
@State private var connectionTestSuccess: Bool = false
@State private var showingLogoutAlert = false
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: viewModel.isSetupMode ? "Server-Einstellungen" : "Server-Verbindung", icon: "server.rack")
.padding(.bottom, 4)
Text(viewModel.isSetupMode ?
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." :
"Ihre aktuelle Server-Verbindung und Anmeldedaten.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 8)
// Form
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("Server-Endpunkt")
.font(.headline)
TextField("https://readeck.example.com", text: $viewModel.endpoint)
.textFieldStyle(.roundedBorder)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
.disabled(!viewModel.isSetupMode)
.onChange(of: viewModel.endpoint) {
if viewModel.isSetupMode {
viewModel.clearMessages()
connectionTestSuccess = false
}
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Benutzername")
.font(.headline)
TextField("Ihr Benutzername", text: $viewModel.username)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
.disabled(!viewModel.isSetupMode)
.onChange(of: viewModel.username) {
if viewModel.isSetupMode {
viewModel.clearMessages()
connectionTestSuccess = false
}
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Passwort")
.font(.headline)
SecureField("Ihr Passwort", text: $viewModel.password)
.textFieldStyle(.roundedBorder)
.disabled(!viewModel.isSetupMode)
.onChange(of: viewModel.password) {
if viewModel.isSetupMode {
viewModel.clearMessages()
connectionTestSuccess = false
}
}
}
}
// Connection Status
if viewModel.isLoggedIn {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Erfolgreich angemeldet")
.foregroundColor(.green)
.font(.caption)
}
}
// Messages
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
}
}
// Action Buttons
if viewModel.isSetupMode {
VStack(spacing: 10) {
Button(action: {
Task {
await testConnection()
}
}) {
HStack {
if isTesting {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isTesting ? "Teste Verbindung..." : "Verbindung testen")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canLogin || isTesting || viewModel.isLoading)
Button(action: {
Task {
await viewModel.login()
}
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Anmelde..." : (viewModel.isLoggedIn ? "Erneut anmelden" : "Anmelden"))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background((viewModel.canLogin && connectionTestSuccess) ? Color.blue : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canLogin || !connectionTestSuccess || viewModel.isLoading || isTesting)
Button("Debug-Anmeldung") {
viewModel.username = "admin"
viewModel.password = "Diggah123"
viewModel.endpoint = "https://readeck.mnk.any64.de"
}
.font(.caption)
.foregroundColor(.secondary)
}
} else {
Button(action: {
showingLogoutAlert = true
}) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
Text("Abmelden")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
.alert("Abmelden", isPresented: $showingLogoutAlert) {
Button("Abbrechen", role: .cancel) { }
Button("Abmelden", role: .destructive) {
Task {
await viewModel.logout()
}
}
} message: {
Text("Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen.")
}
}
private func testConnection() async {
guard viewModel.canLogin else {
viewModel.errorMessage = "Bitte füllen Sie alle Felder aus."
return
}
isTesting = true
viewModel.clearMessages()
connectionTestSuccess = false
do {
// Test login without saving settings
let _ = try await viewModel.loginUseCase.execute(
username: viewModel.username.trimmingCharacters(in: .whitespacesAndNewlines),
password: viewModel.password
)
// If we get here, the test was successful
connectionTestSuccess = true
viewModel.successMessage = "Verbindung erfolgreich getestet! ✓"
} catch {
connectionTestSuccess = false
viewModel.errorMessage = "Verbindungstest fehlgeschlagen: \(error.localizedDescription)"
}
isTesting = false
}
}
#Preview {
SettingsServerView(viewModel: SettingsViewModel())
}

View File

@ -1,232 +1,8 @@
import SwiftUI
struct SettingsView: View {
@State private var viewModel = SettingsViewModel()
@State var selectedTheme: Theme = .system
@State var selectedFontSize: FontSize = .medium
var body: some View {
NavigationView {
Form {
Section("Server-Einstellungen") {
TextField("Endpoint URL", text: $viewModel.endpoint)
.textContentType(.URL)
.keyboardType(.URL)
.autocapitalization(.none)
TextField("Benutzername", text: $viewModel.username)
.textContentType(.username)
.autocapitalization(.none)
SecureField("Passwort", text: $viewModel.password)
.textContentType(.password)
Button {
Task {
await viewModel.login()
}
} label: {
HStack {
if viewModel.isLoading {
ProgressView()
.scaleEffect(0.8)
}
Text(viewModel.isLoggedIn ? "Erneut anmelden" : "Anmelden")
}
}
.disabled(!viewModel.canLogin || viewModel.isLoading)
Button("Debug-Anmeldung") {
viewModel.username = "admin"
viewModel.password = "Diggah123"
}
if viewModel.isLoggedIn {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Erfolgreich angemeldet")
}
}
}
Section {
Button {
Task {
await viewModel.saveSettings()
}
} label: {
HStack {
if viewModel.isSaving {
ProgressView()
.scaleEffect(0.8)
}
Text("Einstellungen speichern")
}
}
.disabled(!viewModel.canSave || viewModel.isSaving)
}
Section("Erscheinungsbild") {
Picker("Theme", selection: $viewModel.selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme)
}
}
// Font Settings with Preview
VStack(alignment: .leading, spacing: 12) {
Text("Schrift-Einstellungen")
.font(.headline)
HStack {
VStack(alignment: .leading, spacing: 8) {
Picker("Schriftart", selection: $viewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family)
}
}
.pickerStyle(MenuPickerStyle())
.onChange(of: viewModel.selectedFontFamily) {
Task {
await viewModel.saveFontSettings()
}
}
Picker("Schriftgröße", selection: $viewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in
Text(size.displayName).tag(size)
}
}
.pickerStyle(SegmentedPickerStyle())
.onChange(of: viewModel.selectedFontSize) {
Task {
await viewModel.saveFontSettings()
}
}
}
Spacer()
}
// Font Preview
VStack(alignment: .leading, spacing: 8) {
Text("Vorschau")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 6) {
Text("readeck Bookmark Title")
.font(viewModel.previewTitleFont)
.fontWeight(.semibold)
.lineLimit(1)
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
.font(viewModel.previewBodyFont)
.lineLimit(3)
Text("12 min • Today • example.com")
.font(viewModel.previewCaptionFont)
.foregroundColor(.secondary)
}
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.vertical, 8)
}
Section("Sync-Einstellungen") {
Toggle("Automatischer Sync", isOn: $viewModel.autoSyncEnabled)
.toggleStyle(SwitchToggleStyle())
if viewModel.autoSyncEnabled {
Stepper("Sync-Intervall: \(viewModel.syncInterval) Minuten", value: $viewModel.syncInterval, in: 1...60)
.padding(.vertical, 8)
}
}
Section("Leseeinstellungen") {
Toggle("Safari Reader Modus", isOn: $viewModel.enableReaderMode)
.toggleStyle(SwitchToggleStyle())
Toggle("Externe Links in In-App Safari öffnen", isOn: $viewModel.openExternalLinksInApp)
.toggleStyle(SwitchToggleStyle())
Toggle("Artikel automatisch als gelesen markieren", isOn: $viewModel.autoMarkAsRead)
.toggleStyle(SwitchToggleStyle())
}
Section("Datenmanagement") {
Button(role: .destructive) {
Task {
// await viewModel.clearCache()
}
} label: {
HStack {
Image(systemName: "trash")
.foregroundColor(.red)
Text("Cache leeren")
}
}
Button(role: .destructive) {
Task {
// await viewModel.resetSettings()
}
} label: {
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.red)
Text("Einstellungen zurücksetzen")
}
}
}
Section("Über die App") {
HStack {
Image(systemName: "info.circle")
Text("Version \(viewModel.appVersion)")
}
HStack {
Image(systemName: "person.crop.circle")
Text("Entwickler: \(viewModel.developerName)")
}
HStack {
Image(systemName: "globe")
Link("Website", destination: URL(string: "https://example.com")!)
}
}
// Success/Error Messages
if let successMessage = viewModel.successMessage {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
}
}
}
}
.navigationTitle("Einstellungen")
.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK", role: .cancel) {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
.task {
await viewModel.loadSettings()
}
}
SettingsContainerView()
}
}

View File

@ -4,9 +4,11 @@ import SwiftUI
@Observable
class SettingsViewModel {
private let loginUseCase: LoginUseCase
private let _loginUseCase: LoginUseCase
private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let logoutUseCase: LogoutUseCase
private let settingsRepository: SettingsRepository
// MARK: - Server Settings
var endpoint = ""
@ -37,56 +39,17 @@ class SettingsViewModel {
var errorMessage: String?
var successMessage: String?
// MARK: - Font Settings
var selectedFontFamily: FontFamily = .system
var selectedFontSize: FontSize = .medium
// MARK: - Computed Font Properties for Preview
var previewTitleFont: Font {
switch selectedFontFamily {
case .system:
return selectedFontSize.systemFont.weight(.semibold)
case .serif:
return Font.custom("Times New Roman", size: selectedFontSize.size).weight(.semibold)
case .sansSerif:
return Font.custom("Helvetica Neue", size: selectedFontSize.size).weight(.semibold)
case .monospace:
return Font.custom("Menlo", size: selectedFontSize.size).weight(.semibold)
}
}
var previewBodyFont: Font {
switch selectedFontFamily {
case .system:
return selectedFontSize.systemFont
case .serif:
return Font.custom("Times New Roman", size: selectedFontSize.size)
case .sansSerif:
return Font.custom("Helvetica Neue", size: selectedFontSize.size)
case .monospace:
return Font.custom("Menlo", size: selectedFontSize.size)
}
}
var previewCaptionFont: Font {
let captionSize = selectedFontSize.size * 0.85
switch selectedFontFamily {
case .system:
return Font.system(size: captionSize)
case .serif:
return Font.custom("Times New Roman", size: captionSize)
case .sansSerif:
return Font.custom("Helvetica Neue", size: captionSize)
case .monospace:
return Font.custom("Menlo", size: captionSize)
}
}
init() {
let factory = DefaultUseCaseFactory.shared
self.loginUseCase = factory.makeLoginUseCase()
self._loginUseCase = factory.makeLoginUseCase()
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.logoutUseCase = factory.makeLogoutUseCase()
self.settingsRepository = SettingsRepository()
}
var isSetupMode: Bool {
!settingsRepository.hasFinishedSetup
}
@MainActor
@ -103,17 +66,6 @@ class SettingsViewModel {
}
}
@MainActor
func saveFontSettings() async {
do {
try await saveSettingsUseCase.execute(
selectedFontFamily: selectedFontFamily, selectedFontSize: selectedFontSize
)
} catch {
errorMessage = "Fehler beim Speichern der Font-Einstellungen"
}
}
@MainActor
func saveSettings() async {
isSaving = true
@ -145,11 +97,17 @@ class SettingsViewModel {
successMessage = nil
do {
let user = try await loginUseCase.execute(username: username, password: password)
let user = try await _loginUseCase.execute(username: username, password: password)
isLoggedIn = true
successMessage = "Erfolgreich angemeldet"
// Setup als abgeschlossen markieren
try await settingsRepository.saveHasFinishedSetup(true)
// Notification senden, dass sich der Setup-Status geändert hat
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
// Factory-Konfiguration aktualisieren (Token wird automatisch gespeichert)
await DefaultUseCaseFactory.shared.refreshConfiguration()
@ -164,15 +122,23 @@ class SettingsViewModel {
@MainActor
func logout() async {
do {
// Hier könntest du eine Logout-UseCase hinzufügen
// try await logoutUseCase.execute()
try await logoutUseCase.execute()
isLoggedIn = false
successMessage = "Abgemeldet"
// Notification senden, dass sich der Setup-Status geändert hat
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
} catch {
errorMessage = "Fehler beim Abmelden"
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
var canSave: Bool {
!endpoint.isEmpty && !username.isEmpty && !password.isEmpty
}
@ -180,6 +146,11 @@ class SettingsViewModel {
var canLogin: Bool {
!username.isEmpty && !password.isEmpty
}
// Expose loginUseCase for testing purposes
var loginUseCase: LoginUseCase {
return _loginUseCase
}
}
@ -196,49 +167,3 @@ enum Theme: String, CaseIterable {
}
}
}
// MARK: - Font Enums
enum FontFamily: String, CaseIterable {
case system = "system"
case serif = "serif"
case sansSerif = "sansSerif"
case monospace = "monospace"
var displayName: String {
switch self {
case .system: return "System"
case .serif: return "Serif"
case .sansSerif: return "Sans Serif"
case .monospace: return "Monospace"
}
}
}
enum FontSize: String, CaseIterable {
case small = "small"
case medium = "medium"
case large = "large"
case extraLarge = "extraLarge"
var displayName: String {
switch self {
case .small: return "S"
case .medium: return "M"
case .large: return "L"
case .extraLarge: return "XL"
}
}
var size: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .extraLarge: return 20
}
}
var systemFont: Font {
return Font.system(size: size)
}
}

View File

@ -142,15 +142,3 @@ struct NavigationSplitViewContainer: View {
#Preview {
MainTabView()
}
extension UIDevice {
static var isPad: Bool {
return UIDevice.current.userInterfaceIdiom == .pad
}
static var isPhone: Bool {
return UIDevice.current.userInterfaceIdiom == .phone
}
}

View File

@ -11,21 +11,37 @@ import netfox
@main
struct readeckApp: App {
let persistenceController = PersistenceController.shared
@State private var hasFinishedSetup = false
var body: some Scene {
WindowGroup {
MainTabView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.onOpenURL { url in
handleIncomingURL(url)
}
.onAppear {
#if DEBUG
NFX.sharedInstance().start()
#endif
Group {
if hasFinishedSetup {
MainTabView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
} else {
SettingsContainerView()
}
}
.onOpenURL { url in
handleIncomingURL(url)
}
.onAppear {
#if DEBUG
NFX.sharedInstance().start()
#endif
loadSetupStatus()
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SetupStatusChanged"))) { _ in
loadSetupStatus()
}
}
}
private func loadSetupStatus() {
let settingsRepository = SettingsRepository()
hasFinishedSetup = settingsRepository.hasFinishedSetup
}
private func handleIncomingURL(_ url: URL) {
guard url.scheme == "readeck",