From 4b93c605f1896bf360d34da44300c35dbe0519f3 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Fri, 31 Oct 2025 23:39:59 +0100 Subject: [PATCH] Redesign settings screen with native iOS style - Refactor all settings views to use List with .insetGrouped style - Create reusable SettingsRow components for consistent UI - Separate onboarding flow into dedicated OnboardingServerView - Consolidate font, theme, and card layout into unified Appearance section - Add visual card layout previews in dedicated selection screen - Move "Open links in" option to Appearance with descriptive footer - Improve settings organization and native iOS feel --- readeck/UI/Components/SettingsRow.swift | 308 ++++++++++++++++++ .../UI/Onboarding/OnboardingServerView.swift | 175 ++++++++++ .../UI/Settings/AppearanceSettingsView.swift | 268 ++++++--------- readeck/UI/Settings/CacheSettingsView.swift | 147 ++++----- .../UI/Settings/CardLayoutSelectionView.swift | 171 ++++++++++ readeck/UI/Settings/FontSettingsView.swift | 101 +++--- .../Settings/LegalPrivacySettingsView.swift | 122 +++---- .../UI/Settings/SettingsContainerView.swift | 163 ++++----- readeck/UI/Settings/SettingsGeneralView.swift | 100 ++---- readeck/UI/Settings/SettingsServerView.swift | 223 ++----------- readeck/UI/readeckApp.swift | 2 +- 11 files changed, 1031 insertions(+), 749 deletions(-) create mode 100644 readeck/UI/Components/SettingsRow.swift create mode 100644 readeck/UI/Onboarding/OnboardingServerView.swift create mode 100644 readeck/UI/Settings/CardLayoutSelectionView.swift diff --git a/readeck/UI/Components/SettingsRow.swift b/readeck/UI/Components/SettingsRow.swift new file mode 100644 index 0000000..5c7f903 --- /dev/null +++ b/readeck/UI/Components/SettingsRow.swift @@ -0,0 +1,308 @@ +// +// SettingsRow.swift +// readeck +// +// Created by Ilyas Hallak on 31.10.25. +// + +import SwiftUI + +// MARK: - Settings Row with Navigation Link +struct SettingsRowNavigationLink: View { + let icon: String? + let iconColor: Color + let title: String + let subtitle: String? + let destination: Destination + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + subtitle: String? = nil, + @ViewBuilder destination: () -> Destination + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self.destination = destination() + } + + var body: some View { + NavigationLink(destination: destination) { + SettingsRowLabel( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: subtitle + ) + } + } +} + +// MARK: - Settings Row with Toggle +struct SettingsRowToggle: View { + let icon: String? + let iconColor: Color + let title: String + let subtitle: String? + @Binding var isOn: Bool + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + subtitle: String? = nil, + isOn: Binding + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self._isOn = isOn + } + + var body: some View { + HStack { + SettingsRowLabel( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: subtitle + ) + Toggle("", isOn: $isOn) + .labelsHidden() + } + } +} + +// MARK: - Settings Row with Value Display +struct SettingsRowValue: View { + let icon: String? + let iconColor: Color + let title: String + let value: String + let valueColor: Color + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + value: String, + valueColor: Color = .secondary + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.value = value + self.valueColor = valueColor + } + + var body: some View { + HStack { + SettingsRowLabel( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: nil + ) + Spacer() + Text(value) + .foregroundColor(valueColor) + } + } +} + +// MARK: - Settings Row Button (for actions) +struct SettingsRowButton: View { + let icon: String? + let iconColor: Color + let title: String + let subtitle: String? + let destructive: Bool + let action: () -> Void + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + subtitle: String? = nil, + destructive: Bool = false, + action: @escaping () -> Void + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self.destructive = destructive + self.action = action + } + + var body: some View { + Button(action: action) { + SettingsRowLabel( + icon: icon, + iconColor: destructive ? .red : iconColor, + title: title, + subtitle: subtitle, + titleColor: destructive ? .red : .primary + ) + } + } +} + +// MARK: - Settings Row with Picker +struct SettingsRowPicker: View { + let icon: String? + let iconColor: Color + let title: String + let selection: Binding + let options: [(value: T, label: String)] + + init( + icon: String? = nil, + iconColor: Color = .accentColor, + title: String, + selection: Binding, + options: [(value: T, label: String)] + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.selection = selection + self.options = options + } + + var body: some View { + HStack { + SettingsRowLabel( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: nil + ) + Spacer() + Picker("", selection: selection) { + ForEach(options, id: \.value) { option in + Text(option.label).tag(option.value) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + } +} + +// MARK: - Settings Row Label (internal component) +struct SettingsRowLabel: View { + let icon: String? + let iconColor: Color + let title: String + let subtitle: String? + let titleColor: Color + + init( + icon: String?, + iconColor: Color, + title: String, + subtitle: String?, + titleColor: Color = .primary + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self.titleColor = titleColor + } + + var body: some View { + HStack(spacing: 12) { + if let icon = icon { + Image(systemName: icon) + .foregroundColor(iconColor) + .frame(width: 24) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .foregroundColor(titleColor) + + if let subtitle = subtitle { + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } +} + +// MARK: - Previews +#Preview("Navigation Link") { + List { + SettingsRowNavigationLink( + icon: "paintbrush", + title: "App Icon", + subtitle: nil + ) { + Text("Detail View") + } + } + .listStyle(.insetGrouped) +} + +#Preview("Toggle") { + List { + SettingsRowToggle( + icon: "speaker.wave.2", + title: "Read Aloud Feature", + subtitle: "Text-to-Speech functionality", + isOn: .constant(true) + ) + } + .listStyle(.insetGrouped) +} + +#Preview("Value Display") { + List { + SettingsRowValue( + icon: "paintbrush.fill", + iconColor: .purple, + title: "Tint Color", + value: "Purple" + ) + } + .listStyle(.insetGrouped) +} + +#Preview("Button") { + List { + SettingsRowButton( + icon: "trash", + iconColor: .red, + title: "Clear Cache", + subtitle: "Remove all cached images", + destructive: true + ) { + print("Clear cache tapped") + } + } + .listStyle(.insetGrouped) +} + +#Preview("Picker") { + List { + SettingsRowPicker( + icon: "textformat", + title: "Font Family", + selection: .constant("System"), + options: [ + ("System", "System"), + ("Serif", "Serif"), + ("Monospace", "Monospace") + ] + ) + } + .listStyle(.insetGrouped) +} diff --git a/readeck/UI/Onboarding/OnboardingServerView.swift b/readeck/UI/Onboarding/OnboardingServerView.swift new file mode 100644 index 0000000..af812d1 --- /dev/null +++ b/readeck/UI/Onboarding/OnboardingServerView.swift @@ -0,0 +1,175 @@ +// +// OnboardingServerView.swift +// readeck +// +// Created by Ilyas Hallak on 31.10.25. +// + +import SwiftUI + +struct OnboardingServerView: View { + @State private var viewModel = SettingsServerViewModel() + + var body: some View { + VStack(spacing: 20) { + SectionHeader(title: "Server Settings".localized, icon: "server.rack") + .padding(.bottom, 4) + + Text("Enter your Readeck server details to get started.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.bottom, 8) + + // Form + VStack(spacing: 16) { + // Server Endpoint + VStack(alignment: .leading, spacing: 8) { + TextField("", + text: $viewModel.endpoint, + prompt: Text("Server Endpoint").foregroundColor(.secondary)) + .textFieldStyle(.roundedBorder) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: viewModel.endpoint) { + viewModel.clearMessages() + } + + // Quick Input Chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + QuickInputChip(text: "http://", action: { + if !viewModel.endpoint.starts(with: "http") { + viewModel.endpoint = "http://" + viewModel.endpoint + } + }) + QuickInputChip(text: "https://", action: { + if !viewModel.endpoint.starts(with: "http") { + viewModel.endpoint = "https://" + viewModel.endpoint + } + }) + QuickInputChip(text: "192.168.", action: { + if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" { + if viewModel.endpoint.starts(with: "http") { + viewModel.endpoint += "192.168." + } else { + viewModel.endpoint = "http://192.168." + } + } + }) + QuickInputChip(text: ":8000", action: { + if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") { + viewModel.endpoint += ":8000" + } + }) + } + .padding(.horizontal, 1) + } + + Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.") + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + // Username + VStack(alignment: .leading, spacing: 8) { + TextField("", + text: $viewModel.username, + prompt: Text("Username").foregroundColor(.secondary)) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: viewModel.username) { + viewModel.clearMessages() + } + } + + // Password + VStack(alignment: .leading, spacing: 8) { + SecureField("", + text: $viewModel.password, + prompt: Text("Password").foregroundColor(.secondary)) + .textFieldStyle(.roundedBorder) + .onChange(of: viewModel.password) { + viewModel.clearMessages() + } + } + } + + // 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) + } + } + + VStack(spacing: 10) { + Button(action: { + Task { + await viewModel.saveServerSettings() + } + }) { + HStack { + if viewModel.isLoading { + ProgressView() + .scaleEffect(0.8) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save")) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(viewModel.canLogin ? Color.accentColor : Color.gray) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(!viewModel.canLogin || viewModel.isLoading) + } + } + .task { + await viewModel.loadServerSettings() + } + } +} + +// MARK: - Quick Input Chip Component + +struct QuickInputChip: View { + let text: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(text) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(.systemGray5)) + .foregroundColor(.secondary) + .cornerRadius(12) + } + } +} + +#Preview { + OnboardingServerView() + .padding() +} diff --git a/readeck/UI/Settings/AppearanceSettingsView.swift b/readeck/UI/Settings/AppearanceSettingsView.swift index 8f3f453..c5780c5 100644 --- a/readeck/UI/Settings/AppearanceSettingsView.swift +++ b/readeck/UI/Settings/AppearanceSettingsView.swift @@ -3,62 +3,120 @@ import SwiftUI struct AppearanceSettingsView: View { @State private var selectedCardLayout: CardLayoutStyle = .magazine @State private var selectedTheme: Theme = .system - + @State private var fontViewModel: FontSettingsViewModel + @State private var generalViewModel: SettingsGeneralViewModel + private let loadCardLayoutUseCase: PLoadCardLayoutUseCase private let saveCardLayoutUseCase: PSaveCardLayoutUseCase private let settingsRepository: PSettingsRepository - - init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + + init( + factory: UseCaseFactory = DefaultUseCaseFactory.shared, + fontViewModel: FontSettingsViewModel = FontSettingsViewModel(), + generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel() + ) { self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase() self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase() self.settingsRepository = SettingsRepository() + self.fontViewModel = fontViewModel + self.generalViewModel = generalViewModel } - + var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "Appearance".localized, icon: "paintbrush") - .padding(.bottom, 4) - - // Theme Section - VStack(alignment: .leading, spacing: 12) { - Text("Theme") - .font(.headline) + Group { + Section { + // Font Family + Picker("Font family", selection: $fontViewModel.selectedFontFamily) { + ForEach(FontFamily.allCases, id: \.self) { family in + Text(family.displayName).tag(family) + } + } + .onChange(of: fontViewModel.selectedFontFamily) { + Task { + await fontViewModel.saveFontSettings() + } + } + + // Font Size + Picker("Font size", selection: $fontViewModel.selectedFontSize) { + ForEach(FontSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + .pickerStyle(.segmented) + .onChange(of: fontViewModel.selectedFontSize) { + Task { + await fontViewModel.saveFontSettings() + } + } + + // Font Preview - direkt in der gleichen Section + VStack(alignment: .leading, spacing: 6) { + Text("readeck Bookmark Title") + .font(fontViewModel.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(fontViewModel.previewBodyFont) + .lineLimit(3) + + Text("12 min • Today • example.com") + .font(fontViewModel.previewCaptionFont) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + .listRowBackground(Color(.systemGray6)) + + // Theme Picker (Menu statt Segmented) Picker("Theme", selection: $selectedTheme) { ForEach(Theme.allCases, id: \.self) { theme in Text(theme.displayName).tag(theme) } } - .pickerStyle(.segmented) .onChange(of: selectedTheme) { saveThemeSettings() } - } - - Divider() - - // Card Layout Section - VStack(alignment: .leading, spacing: 12) { - Text("Card Layout") - .font(.headline) - - VStack(spacing: 16) { - ForEach(CardLayoutStyle.allCases, id: \.self) { layout in - CardLayoutPreview( - layout: layout, - isSelected: selectedCardLayout == layout - ) { - selectedCardLayout = layout - saveCardLayoutSettings() - } + + // Card Layout als NavigationLink + NavigationLink { + CardLayoutSelectionView( + selectedCardLayout: $selectedCardLayout, + onSave: saveCardLayoutSettings + ) + } label: { + HStack { + Text("Card Layout") + Spacer() + Text(selectedCardLayout.displayName) + .foregroundColor(.secondary) } } + + // Open external links in + Picker("Open links in", selection: $generalViewModel.urlOpener) { + ForEach(UrlOpener.allCases, id: \.self) { urlOpener in + Text(urlOpener.displayName).tag(urlOpener) + } + } + .onChange(of: generalViewModel.urlOpener) { + Task { + await generalViewModel.saveGeneralSettings() + } + } + } header: { + Text("Appearance") + } footer: { + Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.") } } - .onAppear { + .task { + await fontViewModel.loadFontSettings() + await generalViewModel.loadGeneralSettings() loadSettings() } } - + private func loadSettings() { Task { // Load both theme and card layout from repository @@ -70,21 +128,21 @@ struct AppearanceSettingsView: View { selectedCardLayout = await loadCardLayoutUseCase.execute() } } - + private func saveThemeSettings() { Task { // Load current settings, update theme, and save back var settings = (try? await settingsRepository.loadSettings()) ?? Settings() settings.theme = selectedTheme try? await settingsRepository.saveSettings(settings) - + // Notify app about theme change await MainActor.run { NotificationCenter.default.post(name: .settingsChanged, object: nil) } } } - + private func saveCardLayoutSettings() { Task { await saveCardLayoutUseCase.execute(layout: selectedCardLayout) @@ -96,139 +154,11 @@ struct AppearanceSettingsView: View { } } - -struct CardLayoutPreview: View { - let layout: CardLayoutStyle - let isSelected: Bool - let onSelect: () -> Void - - var body: some View { - Button(action: onSelect) { - HStack(spacing: 12) { - // Visual Preview - switch layout { - case .compact: - // Compact: Small image on left, content on right - HStack(spacing: 8) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.blue.opacity(0.6)) - .frame(width: 24, height: 24) - - VStack(alignment: .leading, spacing: 2) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.8)) - .frame(height: 6) - .frame(maxWidth: .infinity) - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.6)) - .frame(height: 4) - .frame(maxWidth: 60) - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.4)) - .frame(height: 4) - .frame(maxWidth: 40) - } - } - .padding(8) - .background(Color.gray.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(width: 80, height: 50) - - case .magazine: - VStack(spacing: 4) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.blue.opacity(0.6)) - .frame(height: 24) - - VStack(alignment: .leading, spacing: 2) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.8)) - .frame(height: 5) - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.6)) - .frame(height: 4) - .frame(maxWidth: 40) - - Text("Fixed 140px") - .font(.system(size: 7)) - .foregroundColor(.secondary) - .padding(.top, 1) - } - .padding(.horizontal, 4) - } - .padding(6) - .background(Color.gray.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(width: 80, height: 65) - .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) - - case .natural: - VStack(spacing: 3) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.blue.opacity(0.6)) - .frame(height: 38) - - VStack(alignment: .leading, spacing: 2) { - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.8)) - .frame(height: 5) - RoundedRectangle(cornerRadius: 2) - .fill(Color.primary.opacity(0.6)) - .frame(height: 4) - .frame(maxWidth: 35) - - Text("Original ratio") - .font(.system(size: 7)) - .foregroundColor(.secondary) - .padding(.top, 1) - } - .padding(.horizontal, 4) - } - .padding(6) - .background(Color.gray.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(width: 80, height: 75) // Höher als Magazine - .shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1) - } - - VStack(alignment: .leading, spacing: 4) { - Text(layout.displayName) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.primary) - - Text(layout.description) - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - } - - Spacer() - - if isSelected { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.blue) - .font(.title2) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) - ) +#Preview { + NavigationStack { + List { + AppearanceSettingsView() } - .buttonStyle(.plain) + .listStyle(.insetGrouped) } } - - -#Preview { - AppearanceSettingsView() - .cardStyle() - .padding() -} diff --git a/readeck/UI/Settings/CacheSettingsView.swift b/readeck/UI/Settings/CacheSettingsView.swift index b0ad22e..53b98b6 100644 --- a/readeck/UI/Settings/CacheSettingsView.swift +++ b/readeck/UI/Settings/CacheSettingsView.swift @@ -6,79 +6,67 @@ struct CacheSettingsView: View { @State private var maxCacheSize: Double = 200 @State private var isClearing: Bool = false @State private var showClearAlert: Bool = false - + var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "Cache Settings".localized, icon: "internaldrive") - .padding(.bottom, 4) - - VStack(spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Current Cache Size") - .foregroundColor(.primary) - Text("\(cacheSize) / \(Int(maxCacheSize)) MB max") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Button("Refresh") { - updateCacheSize() - } - .font(.caption) - .foregroundColor(.blue) + Section { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Current Cache Size") + Text("\(cacheSize) / \(Int(maxCacheSize)) MB max") + .font(.caption) + .foregroundColor(.secondary) } - - Divider() - - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Max Cache Size") - .foregroundColor(.primary) - Spacer() - Text("\(Int(maxCacheSize)) MB") - .font(.caption) - .foregroundColor(.secondary) - } - - Slider(value: $maxCacheSize, in: 50...1200, step: 50) { - Text("Max Cache Size") - } - .onChange(of: maxCacheSize) { _, newValue in - updateMaxCacheSize(newValue) - } - .accentColor(.blue) + Spacer() + Button("Refresh") { + updateCacheSize() } - - Divider() - - Button(action: { - showClearAlert = true - }) { - HStack { - if isClearing { - ProgressView() - .scaleEffect(0.8) - .frame(width: 24) - } else { - Image(systemName: "trash") - .foregroundColor(.red) - .frame(width: 24) - } - - VStack(alignment: .leading, spacing: 2) { - Text("Clear Cache") - .foregroundColor(isClearing ? .secondary : .red) - Text("Remove all cached images") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - } - } - .disabled(isClearing) + .font(.caption) + .foregroundColor(.blue) } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Max Cache Size") + Spacer() + Text("\(Int(maxCacheSize)) MB") + .font(.caption) + .foregroundColor(.secondary) + } + + Slider(value: $maxCacheSize, in: 50...1200, step: 50) { + Text("Max Cache Size") + } + .onChange(of: maxCacheSize) { _, newValue in + updateMaxCacheSize(newValue) + } + } + + Button(action: { + showClearAlert = true + }) { + HStack { + if isClearing { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "trash") + .foregroundColor(.red) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Clear Cache") + .foregroundColor(isClearing ? .secondary : .red) + Text("Remove all cached images") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + } + .disabled(isClearing) + } header: { + Text("Cache Settings") } .onAppear { updateCacheSize() @@ -93,7 +81,7 @@ struct CacheSettingsView: View { 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 { @@ -107,7 +95,7 @@ struct CacheSettingsView: View { } } } - + private func loadMaxCacheSize() { let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt if let savedSize = savedSize { @@ -120,29 +108,30 @@ struct CacheSettingsView: View { 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 { - CacheSettingsView() - .cardStyle() - .padding() -} \ No newline at end of file + List { + CacheSettingsView() + } + .listStyle(.insetGrouped) +} diff --git a/readeck/UI/Settings/CardLayoutSelectionView.swift b/readeck/UI/Settings/CardLayoutSelectionView.swift new file mode 100644 index 0000000..d3e7cce --- /dev/null +++ b/readeck/UI/Settings/CardLayoutSelectionView.swift @@ -0,0 +1,171 @@ +// +// CardLayoutSelectionView.swift +// readeck +// +// Created by Ilyas Hallak on 31.10.25. +// + +import SwiftUI + +struct CardLayoutSelectionView: View { + @Binding var selectedCardLayout: CardLayoutStyle + @Environment(\.dismiss) private var dismiss + + let onSave: () -> Void + + var body: some View { + List { + ForEach(CardLayoutStyle.allCases, id: \.self) { layout in + CardLayoutPreview( + layout: layout, + isSelected: selectedCardLayout == layout + ) { + selectedCardLayout = layout + onSave() + dismiss() + } + } + } + .listStyle(.plain) + .navigationTitle("Card Layout") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct CardLayoutPreview: View { + let layout: CardLayoutStyle + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 12) { + // Visual Preview + switch layout { + case .compact: + // Compact: Small image on left, content on right + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.blue.opacity(0.6)) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 6) + .frame(maxWidth: .infinity) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 60) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.4)) + .frame(height: 4) + .frame(maxWidth: 40) + } + } + .padding(8) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 50) + + case .magazine: + VStack(spacing: 4) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.blue.opacity(0.6)) + .frame(height: 24) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 5) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 40) + + Text("Fixed 140px") + .font(.system(size: 7)) + .foregroundColor(.secondary) + .padding(.top, 1) + } + .padding(.horizontal, 4) + } + .padding(6) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 65) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + + case .natural: + VStack(spacing: 3) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.blue.opacity(0.6)) + .frame(height: 38) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 5) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 35) + + Text("Original ratio") + .font(.system(size: 7)) + .foregroundColor(.secondary) + .padding(.top, 1) + } + .padding(.horizontal, 4) + } + .padding(6) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 75) + .shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1) + } + + VStack(alignment: .leading, spacing: 4) { + Text(layout.displayName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + + Text(layout.description) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + .font(.title2) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} + +#Preview { + NavigationStack { + CardLayoutSelectionView( + selectedCardLayout: .constant(.magazine), + onSave: {} + ) + } +} diff --git a/readeck/UI/Settings/FontSettingsView.swift b/readeck/UI/Settings/FontSettingsView.swift index 5dfd593..98d3d52 100644 --- a/readeck/UI/Settings/FontSettingsView.swift +++ b/readeck/UI/Settings/FontSettingsView.swift @@ -9,82 +9,60 @@ import SwiftUI struct FontSettingsView: View { @State private var viewModel: FontSettingsViewModel - + init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) { self.viewModel = viewModel } - + var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "Font Settings".localized, icon: "textformat") - .padding(.bottom, 4) - - // Font Family Picker - HStack(alignment: .firstTextBaseline, spacing: 16) { - Text("Font family") - .font(.headline) + Group { + Section { Picker("Font family", 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("Font size", selection: $viewModel.selectedFontSize) { + ForEach(FontSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + .pickerStyle(.segmented) + .onChange(of: viewModel.selectedFontSize) { + Task { + await viewModel.saveFontSettings() + } + } + } header: { + Text("Font Settings") } - - VStack(spacing: 16) { - - // Font Size Picker - VStack(alignment: .leading, spacing: 8) { - Text("Font size") - .font(.headline) - Picker("Font size", 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("Preview") - .font(.caption) + + Section { + 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) - - 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(.vertical, 4) + } header: { + Text("Preview") } } - .padding() - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) .task { await viewModel.loadFontSettings() } @@ -92,7 +70,10 @@ struct FontSettingsView: View { } #Preview { - FontSettingsView(viewModel: .init( - factory: MockUseCaseFactory()) - ) + List { + FontSettingsView(viewModel: .init( + factory: MockUseCaseFactory()) + ) + } + .listStyle(.insetGrouped) } diff --git a/readeck/UI/Settings/LegalPrivacySettingsView.swift b/readeck/UI/Settings/LegalPrivacySettingsView.swift index 45d35d1..aae51df 100644 --- a/readeck/UI/Settings/LegalPrivacySettingsView.swift +++ b/readeck/UI/Settings/LegalPrivacySettingsView.swift @@ -3,110 +3,67 @@ import SwiftUI struct LegalPrivacySettingsView: View { @State private var showingPrivacyPolicy = false @State private var showingLegalNotice = false - + var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text") - .padding(.bottom, 4) - - VStack(spacing: 16) { - // Privacy Policy + Group { + Section { Button(action: { showingPrivacyPolicy = true }) { HStack { Text(NSLocalizedString("Privacy Policy", comment: "")) - .font(.headline) - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) } - .buttonStyle(.plain) - - // Legal Notice + Button(action: { showingLegalNotice = true }) { HStack { Text(NSLocalizedString("Legal Notice", comment: "")) - .font(.headline) - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") .font(.caption) .foregroundColor(.secondary) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) } - .buttonStyle(.plain) - - Divider() - .padding(.vertical, 8) - - // Support Section - VStack(spacing: 12) { - // Report an Issue - Button(action: { - if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") { - UIApplication.shared.open(url) - } - }) { - HStack { - Text(NSLocalizedString("Report an Issue", comment: "")) - .font(.headline) - .foregroundColor(.primary) - - Spacer() - - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + } header: { + Text("Legal & Privacy") + } + + Section { + Button(action: { + if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") { + UIApplication.shared.open(url) } - .buttonStyle(.plain) - - // Contact Support - Button(action: { - if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") { - UIApplication.shared.open(url) - } - }) { - HStack { - Text(NSLocalizedString("Contact Support", comment: "")) - .font(.headline) - .foregroundColor(.primary) - - Spacer() - - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 10)) + }) { + HStack { + Text(NSLocalizedString("Report an Issue", comment: "")) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundColor(.secondary) } - .buttonStyle(.plain) } + + Button(action: { + if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") { + UIApplication.shared.open(url) + } + }) { + HStack { + Text(NSLocalizedString("Contact Support", comment: "")) + Spacer() + Image(systemName: "arrow.up.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + } header: { + Text("Support") } } .sheet(isPresented: $showingPrivacyPolicy) { @@ -119,7 +76,8 @@ struct LegalPrivacySettingsView: View { } #Preview { - LegalPrivacySettingsView() - .cardStyle() - .padding() -} \ No newline at end of file + List { + LegalPrivacySettingsView() + } + .listStyle(.insetGrouped) +} diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index 739b8d5..039db50 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -8,111 +8,81 @@ import SwiftUI struct SettingsContainerView: View { - + private var appVersion: String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" return "v\(version) (\(build))" } - + var body: some View { - ScrollView { - LazyVStack(spacing: 20) { - FontSettingsView() - .cardStyle() - - AppearanceSettingsView() - .cardStyle() - - CacheSettingsView() - .cardStyle() - - SettingsGeneralView() - .cardStyle() - - SettingsServerView() - .cardStyle() - - LegalPrivacySettingsView() - .cardStyle() - - // Debug-only Logging Configuration - if Bundle.main.isDebugBuild { - debugSettingsSection - } + List { + AppearanceSettingsView() + + CacheSettingsView() + + SettingsGeneralView() + + SettingsServerView() + + LegalPrivacySettingsView() + + // Debug-only Logging Configuration + #if DEBUG + if Bundle.main.isDebugBuild { + debugSettingsSection } - .padding() - .background(Color(.systemGroupedBackground)) - - AppInfo() - - Spacer() + #endif + + // App Info Section + appInfoSection } - .background(Color(.systemGroupedBackground)) + .listStyle(.insetGrouped) .navigationTitle("Settings") .navigationBarTitleDisplayMode(.large) } - + @ViewBuilder private var debugSettingsSection: some View { - VStack(alignment: .leading, spacing: 16) { + Section { + SettingsRowNavigationLink( + icon: "doc.text.magnifyingglass", + iconColor: .blue, + title: "Logging Configuration", + subtitle: "Configure log levels and categories" + ) { + LoggingConfigurationView() + } + } header: { HStack { - Image(systemName: "ant.fill") - .foregroundColor(.orange) Text("Debug Settings") - .font(.headline) - .foregroundColor(.primary) Spacer() Text("DEBUG BUILD") - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) .background(Color.orange.opacity(0.2)) .foregroundColor(.orange) .clipShape(Capsule()) } - - NavigationLink { - LoggingConfigurationView() - } label: { - HStack { - Image(systemName: "doc.text.magnifyingglass") - .foregroundColor(.blue) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text("Logging Configuration") - .foregroundColor(.primary) - Text("Configure log levels and categories") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) + } + } + + @ViewBuilder + private var appInfoSection: some View { + Section { + VStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("Version \(appVersion)") + .font(.footnote) .foregroundColor(.secondary) } - } - } - .cardStyle() - } - - @ViewBuilder - func AppInfo() -> some View { - VStack(spacing: 4) { - HStack(spacing: 8) { - Image(systemName: "info.circle") - .foregroundColor(.secondary) - Text("Version \(appVersion)") - .font(.footnote) - .foregroundColor(.secondary) - } - HStack(spacing: 8) { - Image(systemName: "person.crop.circle") - .foregroundColor(.secondary) + HStack(spacing: 4) { + Image(systemName: "person.crop.circle") + .foregroundColor(.secondary) Text("Developer:") .font(.footnote) .foregroundColor(.secondary) @@ -123,26 +93,23 @@ struct SettingsContainerView: View { } .font(.footnote) .foregroundColor(.blue) - .underline() + } + + HStack(spacing: 8) { + Image(systemName: "globe") + .foregroundColor(.secondary) + Text("From Bremen with 💚") + .font(.footnote) + .foregroundColor(.secondary) } } - HStack(spacing: 8) { - Image(systemName: "globe") - .foregroundColor(.secondary) - Text("From Bremen with 💚") - .font(.footnote) - .foregroundColor(.secondary) - } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) } - .frame(maxWidth: .infinity) - .padding(.top, 16) - .padding(.bottom, 4) - .multilineTextAlignment(.center) - .opacity(0.7) } } -// Card Modifier für einheitlichen Look +// Card Modifier für einheitlichen Look (kept for backwards compatibility with other views) extension View { func cardStyle() -> some View { self @@ -154,5 +121,7 @@ extension View { } #Preview { - SettingsContainerView() -} + NavigationStack { + SettingsContainerView() + } +} diff --git a/readeck/UI/Settings/SettingsGeneralView.swift b/readeck/UI/Settings/SettingsGeneralView.swift index 0911db4..6d93175 100644 --- a/readeck/UI/Settings/SettingsGeneralView.swift +++ b/readeck/UI/Settings/SettingsGeneralView.swift @@ -16,15 +16,8 @@ struct SettingsGeneralView: View { } var body: some View { - VStack(spacing: 20) { - SectionHeader(title: "General Settings".localized, icon: "gear") - .padding(.bottom, 4) - - VStack(alignment: .leading, spacing: 12) { - Text("General") - .font(.headline) - - // What's New Button + Group { + Section { Button(action: { showReleaseNotes = true }) { @@ -39,83 +32,57 @@ struct SettingsGeneralView: View { .foregroundColor(.secondary) } } - .buttonStyle(.plain) Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS) - .toggleStyle(.switch) .onChange(of: viewModel.enableTTS) { Task { await viewModel.saveGeneralSettings() } } + } header: { + Text("General") + } footer: { Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.") - .font(.footnote) } - - // Reading Settings - VStack(alignment: .leading, spacing: 12) { - Text("Open external links in".localized) - .font(.headline) - Picker("urlOpener", selection: $viewModel.urlOpener) { - ForEach(UrlOpener.allCases, id: \.self) { urlOpener in - Text(urlOpener.displayName.localized).tag(urlOpener) - } - } - .pickerStyle(.segmented) - .onChange(of: viewModel.urlOpener) { - Task { - await viewModel.saveGeneralSettings() - } - } - } - + #if DEBUG - // Sync Settings - VStack(alignment: .leading, spacing: 12) { - Text("Sync Settings") - .font(.headline) + Section { Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled) - .toggleStyle(SwitchToggleStyle()) if viewModel.autoSyncEnabled { - HStack { - Text("Sync interval") - Spacer() - Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60) - } + Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60) } + } header: { + Text("Sync Settings") } - - // Reading Settings - VStack(alignment: .leading, spacing: 12) { - Text("Reading Settings") - .font(.headline) + + Section { Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) - .toggleStyle(SwitchToggleStyle()) Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead) - .toggleStyle(SwitchToggleStyle()) + } header: { + Text("Reading Settings") } - - // Messages + if let successMessage = viewModel.successMessage { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text(successMessage) - .foregroundColor(.green) - .font(.caption) + Section { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(successMessage) + .foregroundColor(.green) + } } } if let errorMessage = viewModel.errorMessage { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.red) - Text(errorMessage) - .foregroundColor(.red) - .font(.caption) + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + } } } #endif - } .sheet(isPresented: $showReleaseNotes) { ReleaseNotesView() @@ -127,7 +94,10 @@ struct SettingsGeneralView: View { } #Preview { - SettingsGeneralView(viewModel: .init( - MockUseCaseFactory() - )) + List { + SettingsGeneralView(viewModel: .init( + MockUseCaseFactory() + )) + } + .listStyle(.insetGrouped) } diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index 618ee6a..5c95566 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -11,189 +11,33 @@ struct SettingsServerView: View { @State private var viewModel = SettingsServerViewModel() @State private var showingLogoutAlert = false - init(showingLogoutAlert: Bool = false) { - self.showingLogoutAlert = showingLogoutAlert - } - var body: some View { - VStack(spacing: 20) { - SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack") - .padding(.bottom, 4) + Section { + SettingsRowValue( + icon: "server.rack", + title: "Server", + value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint + ) - Text(viewModel.isSetupMode ? - "Enter your Readeck server details to get started." : - "Your current server connection and login credentials.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.bottom, 8) + SettingsRowValue( + icon: "person.circle.fill", + title: "Username", + value: viewModel.username.isEmpty ? "Not set" : viewModel.username + ) - // Form - VStack(spacing: 16) { - // Server Endpoint - VStack(alignment: .leading, spacing: 8) { - if viewModel.isSetupMode { - TextField("", - text: $viewModel.endpoint, - prompt: Text("Server Endpoint").foregroundColor(.secondary)) - .textFieldStyle(.roundedBorder) - .keyboardType(.URL) - .autocapitalization(.none) - .disableAutocorrection(true) - .onChange(of: viewModel.endpoint) { - viewModel.clearMessages() - } - - // Quick Input Chips - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - QuickInputChip(text: "http://", action: { - if !viewModel.endpoint.starts(with: "http") { - viewModel.endpoint = "http://" + viewModel.endpoint - } - }) - QuickInputChip(text: "https://", action: { - if !viewModel.endpoint.starts(with: "http") { - viewModel.endpoint = "https://" + viewModel.endpoint - } - }) - QuickInputChip(text: "192.168.", action: { - if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" { - if viewModel.endpoint.starts(with: "http") { - viewModel.endpoint += "192.168." - } else { - viewModel.endpoint = "http://192.168." - } - } - }) - QuickInputChip(text: ":8000", action: { - if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") { - viewModel.endpoint += ":8000" - } - }) - } - .padding(.horizontal, 1) - } - - Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.") - .font(.caption) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else { - HStack { - Image(systemName: "server.rack") - .foregroundColor(.accentColor) - Text(viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint) - .foregroundColor(viewModel.endpoint.isEmpty ? .secondary : .primary) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - } - - // Username - VStack(alignment: .leading, spacing: 8) { - if viewModel.isSetupMode { - TextField("", - text: $viewModel.username, - prompt: Text("Username").foregroundColor(.secondary)) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .disableAutocorrection(true) - .onChange(of: viewModel.username) { - viewModel.clearMessages() - } - } else { - HStack { - Image(systemName: "person.circle.fill") - .foregroundColor(.accentColor) - Text(viewModel.username.isEmpty ? "Not set" : viewModel.username) - .foregroundColor(viewModel.username.isEmpty ? .secondary : .primary) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - } - - // Password - if viewModel.isSetupMode { - VStack(alignment: .leading, spacing: 8) { - SecureField("", - text: $viewModel.password, - prompt: Text("Password").foregroundColor(.secondary)) - .textFieldStyle(.roundedBorder) - .onChange(of: viewModel.password) { - viewModel.clearMessages() - } - } - } - } - - // 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) - } - } - - if viewModel.isSetupMode { - VStack(spacing: 10) { - Button(action: { - Task { - await viewModel.saveServerSettings() - } - }) { - HStack { - if viewModel.isLoading { - ProgressView() - .scaleEffect(0.8) - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } - Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save")) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .background(viewModel.canLogin ? Color.accentColor : Color.gray) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(!viewModel.canLogin || viewModel.isLoading) - } - } else { - Button(action: { - showingLogoutAlert = true - }) { - HStack(spacing: 6) { - Image(systemName: "rectangle.portrait.and.arrow.right") - .font(.caption) - Text("Logout") - .font(.caption) - .fontWeight(.medium) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color(.systemGray5)) - .foregroundColor(.secondary) - .cornerRadius(8) - } + SettingsRowButton( + icon: "rectangle.portrait.and.arrow.right", + iconColor: .red, + title: "Logout", + subtitle: nil, + destructive: true + ) { + showingLogoutAlert = true } + } header: { + Text("Server Connection") + } footer: { + Text("Your current server connection and login credentials.") } .alert("Logout", isPresented: $showingLogoutAlert) { Button("Cancel", role: .cancel) { } @@ -211,22 +55,9 @@ struct SettingsServerView: View { } } -// MARK: - Quick Input Chip Component - -struct QuickInputChip: View { - let text: String - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(text) - .font(.caption) - .fontWeight(.medium) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color(.systemGray5)) - .foregroundColor(.secondary) - .cornerRadius(12) - } +#Preview { + List { + SettingsServerView() } + .listStyle(.insetGrouped) } diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 1f50ad0..7723755 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -20,7 +20,7 @@ struct readeckApp: App { if appViewModel.hasFinishedSetup { MainTabView() } else { - SettingsServerView() + OnboardingServerView() .padding() } }