diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..10efcb2 --- /dev/null +++ b/.vscode/launch.json @@ -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}/", + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/readeck/Data/Repository/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift index 8876a13..79685fe 100644 --- a/readeck/Data/Repository/SettingsRepository.swift +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -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.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.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") + } + } } diff --git a/readeck/Data/TokenManager.swift b/readeck/Data/TokenManager.swift index 4b558d2..532c670 100644 --- a/readeck/Data/TokenManager.swift +++ b/readeck/Data/TokenManager.swift @@ -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 } } } \ No newline at end of file diff --git a/readeck/Domain/UseCase/LogoutUseCase.swift b/readeck/Domain/UseCase/LogoutUseCase.swift new file mode 100644 index 0000000..55f3425 --- /dev/null +++ b/readeck/Domain/UseCase/LogoutUseCase.swift @@ -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") + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/SaveSettingsUseCase.swift b/readeck/Domain/UseCase/SaveSettingsUseCase.swift index e987b7c..c8733a4 100644 --- a/readeck/Domain/UseCase/SaveSettingsUseCase.swift +++ b/readeck/Domain/UseCase/SaveSettingsUseCase.swift @@ -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( diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 56fb904..3c70ee8 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -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) diff --git a/readeck/UI/DefaultUseCaseFactory.swift b/readeck/UI/DefaultUseCaseFactory.swift index 64f1044..2e0b60a 100644 --- a/readeck/UI/DefaultUseCaseFactory.swift +++ b/readeck/UI/DefaultUseCaseFactory.swift @@ -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 diff --git a/readeck/UI/Extension/UIDeviceExtension.swift b/readeck/UI/Extension/UIDeviceExtension.swift new file mode 100644 index 0000000..b143eea --- /dev/null +++ b/readeck/UI/Extension/UIDeviceExtension.swift @@ -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 + } +} diff --git a/readeck/UI/Settings/FontSettingsView.swift b/readeck/UI/Settings/FontSettingsView.swift new file mode 100644 index 0000000..1b17955 --- /dev/null +++ b/readeck/UI/Settings/FontSettingsView.swift @@ -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() +} diff --git a/readeck/UI/Settings/FontSettingsViewModel.swift b/readeck/UI/Settings/FontSettingsViewModel.swift new file mode 100644 index 0000000..d872582 --- /dev/null +++ b/readeck/UI/Settings/FontSettingsViewModel.swift @@ -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) + } +} diff --git a/readeck/UI/Settings/SectionHeader.swift b/readeck/UI/Settings/SectionHeader.swift new file mode 100644 index 0000000..0d3af97 --- /dev/null +++ b/readeck/UI/Settings/SectionHeader.swift @@ -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) + } + } +} \ No newline at end of file diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift new file mode 100644 index 0000000..4698336 --- /dev/null +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -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() +} diff --git a/readeck/UI/Settings/SettingsGeneralView.swift b/readeck/UI/Settings/SettingsGeneralView.swift new file mode 100644 index 0000000..0b06e44 --- /dev/null +++ b/readeck/UI/Settings/SettingsGeneralView.swift @@ -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()) +} diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift new file mode 100644 index 0000000..6560e8a --- /dev/null +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -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()) +} diff --git a/readeck/UI/Settings/SettingsView.swift b/readeck/UI/Settings/SettingsView.swift index 47e71cf..1824cfd 100644 --- a/readeck/UI/Settings/SettingsView.swift +++ b/readeck/UI/Settings/SettingsView.swift @@ -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() } } diff --git a/readeck/UI/Settings/SettingsViewModel.swift b/readeck/UI/Settings/SettingsViewModel.swift index 53c54fb..b2551cd 100644 --- a/readeck/UI/Settings/SettingsViewModel.swift +++ b/readeck/UI/Settings/SettingsViewModel.swift @@ -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) - } -} diff --git a/readeck/UI/TabView.swift b/readeck/UI/TabView.swift index 7990aff..0315e01 100644 --- a/readeck/UI/TabView.swift +++ b/readeck/UI/TabView.swift @@ -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 - } -} diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 03a9e3b..1b319b9 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -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",