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
This commit is contained in:
Ilyas Hallak 2025-10-31 23:39:59 +01:00
parent 589fcdb2b4
commit 4b93c605f1
11 changed files with 1031 additions and 749 deletions

View File

@ -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<Destination: View>: 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<Bool>
) {
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<T: Hashable>: View {
let icon: String?
let iconColor: Color
let title: String
let selection: Binding<T>
let options: [(value: T, label: String)]
init(
icon: String? = nil,
iconColor: Color = .accentColor,
title: String,
selection: Binding<T>,
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)
}

View File

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

View File

@ -3,62 +3,120 @@ import SwiftUI
struct AppearanceSettingsView: View { struct AppearanceSettingsView: View {
@State private var selectedCardLayout: CardLayoutStyle = .magazine @State private var selectedCardLayout: CardLayoutStyle = .magazine
@State private var selectedTheme: Theme = .system @State private var selectedTheme: Theme = .system
@State private var fontViewModel: FontSettingsViewModel
@State private var generalViewModel: SettingsGeneralViewModel
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
private let settingsRepository: PSettingsRepository 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.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase() self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
self.settingsRepository = SettingsRepository() self.settingsRepository = SettingsRepository()
self.fontViewModel = fontViewModel
self.generalViewModel = generalViewModel
} }
var body: some View { var body: some View {
VStack(spacing: 20) { Group {
SectionHeader(title: "Appearance".localized, icon: "paintbrush") Section {
.padding(.bottom, 4) // Font Family
Picker("Font family", selection: $fontViewModel.selectedFontFamily) {
// Theme Section ForEach(FontFamily.allCases, id: \.self) { family in
VStack(alignment: .leading, spacing: 12) { Text(family.displayName).tag(family)
Text("Theme") }
.font(.headline) }
.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) { Picker("Theme", selection: $selectedTheme) {
ForEach(Theme.allCases, id: \.self) { theme in ForEach(Theme.allCases, id: \.self) { theme in
Text(theme.displayName).tag(theme) Text(theme.displayName).tag(theme)
} }
} }
.pickerStyle(.segmented)
.onChange(of: selectedTheme) { .onChange(of: selectedTheme) {
saveThemeSettings() saveThemeSettings()
} }
}
// Card Layout als NavigationLink
Divider() NavigationLink {
CardLayoutSelectionView(
// Card Layout Section selectedCardLayout: $selectedCardLayout,
VStack(alignment: .leading, spacing: 12) { onSave: saveCardLayoutSettings
Text("Card Layout") )
.font(.headline) } label: {
HStack {
VStack(spacing: 16) { Text("Card Layout")
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in Spacer()
CardLayoutPreview( Text(selectedCardLayout.displayName)
layout: layout, .foregroundColor(.secondary)
isSelected: selectedCardLayout == layout
) {
selectedCardLayout = layout
saveCardLayoutSettings()
}
} }
} }
// 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() loadSettings()
} }
} }
private func loadSettings() { private func loadSettings() {
Task { Task {
// Load both theme and card layout from repository // Load both theme and card layout from repository
@ -70,21 +128,21 @@ struct AppearanceSettingsView: View {
selectedCardLayout = await loadCardLayoutUseCase.execute() selectedCardLayout = await loadCardLayoutUseCase.execute()
} }
} }
private func saveThemeSettings() { private func saveThemeSettings() {
Task { Task {
// Load current settings, update theme, and save back // Load current settings, update theme, and save back
var settings = (try? await settingsRepository.loadSettings()) ?? Settings() var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
settings.theme = selectedTheme settings.theme = selectedTheme
try? await settingsRepository.saveSettings(settings) try? await settingsRepository.saveSettings(settings)
// Notify app about theme change // Notify app about theme change
await MainActor.run { await MainActor.run {
NotificationCenter.default.post(name: .settingsChanged, object: nil) NotificationCenter.default.post(name: .settingsChanged, object: nil)
} }
} }
} }
private func saveCardLayoutSettings() { private func saveCardLayoutSettings() {
Task { Task {
await saveCardLayoutUseCase.execute(layout: selectedCardLayout) await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
@ -96,139 +154,11 @@ struct AppearanceSettingsView: View {
} }
} }
#Preview {
struct CardLayoutPreview: View { NavigationStack {
let layout: CardLayoutStyle List {
let isSelected: Bool AppearanceSettingsView()
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)
)
} }
.buttonStyle(.plain) .listStyle(.insetGrouped)
} }
} }
#Preview {
AppearanceSettingsView()
.cardStyle()
.padding()
}

View File

@ -6,79 +6,67 @@ struct CacheSettingsView: View {
@State private var maxCacheSize: Double = 200 @State private var maxCacheSize: Double = 200
@State private var isClearing: Bool = false @State private var isClearing: Bool = false
@State private var showClearAlert: Bool = false @State private var showClearAlert: Bool = false
var body: some View { var body: some View {
VStack(spacing: 20) { Section {
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive") HStack {
.padding(.bottom, 4) VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size")
VStack(spacing: 12) { Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
HStack { .font(.caption)
VStack(alignment: .leading, spacing: 2) { .foregroundColor(.secondary)
Text("Current Cache Size")
.foregroundColor(.primary)
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Refresh") {
updateCacheSize()
}
.font(.caption)
.foregroundColor(.blue)
} }
Spacer()
Divider() Button("Refresh") {
updateCacheSize()
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)
} }
.font(.caption)
Divider() .foregroundColor(.blue)
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)
} }
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 { .onAppear {
updateCacheSize() updateCacheSize()
@ -93,7 +81,7 @@ struct CacheSettingsView: View {
Text("This will remove all cached images. They will be downloaded again when needed.") Text("This will remove all cached images. They will be downloaded again when needed.")
} }
} }
private func updateCacheSize() { private func updateCacheSize() {
KingfisherManager.shared.cache.calculateDiskStorageSize { result in KingfisherManager.shared.cache.calculateDiskStorageSize { result in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -107,7 +95,7 @@ struct CacheSettingsView: View {
} }
} }
} }
private func loadMaxCacheSize() { private func loadMaxCacheSize() {
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
if let savedSize = savedSize { if let savedSize = savedSize {
@ -120,29 +108,30 @@ struct CacheSettingsView: View {
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize") UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
} }
} }
private func updateMaxCacheSize(_ newSize: Double) { private func updateMaxCacheSize(_ newSize: Double) {
let bytes = UInt(newSize * 1024 * 1024) let bytes = UInt(newSize * 1024 * 1024)
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize") UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
} }
private func clearCache() { private func clearCache() {
isClearing = true isClearing = true
KingfisherManager.shared.cache.clearDiskCache { KingfisherManager.shared.cache.clearDiskCache {
DispatchQueue.main.async { DispatchQueue.main.async {
self.isClearing = false self.isClearing = false
self.updateCacheSize() self.updateCacheSize()
} }
} }
KingfisherManager.shared.cache.clearMemoryCache() KingfisherManager.shared.cache.clearMemoryCache()
} }
} }
#Preview { #Preview {
CacheSettingsView() List {
.cardStyle() CacheSettingsView()
.padding() }
} .listStyle(.insetGrouped)
}

View File

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

View File

@ -9,82 +9,60 @@ import SwiftUI
struct FontSettingsView: View { struct FontSettingsView: View {
@State private var viewModel: FontSettingsViewModel @State private var viewModel: FontSettingsViewModel
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) { init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
self.viewModel = viewModel self.viewModel = viewModel
} }
var body: some View { var body: some View {
VStack(spacing: 20) { Group {
SectionHeader(title: "Font Settings".localized, icon: "textformat") Section {
.padding(.bottom, 4)
// Font Family Picker
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text("Font family")
.font(.headline)
Picker("Font family", selection: $viewModel.selectedFontFamily) { Picker("Font family", selection: $viewModel.selectedFontFamily) {
ForEach(FontFamily.allCases, id: \.self) { family in ForEach(FontFamily.allCases, id: \.self) { family in
Text(family.displayName).tag(family) Text(family.displayName).tag(family)
} }
} }
.pickerStyle(MenuPickerStyle())
.onChange(of: viewModel.selectedFontFamily) { .onChange(of: viewModel.selectedFontFamily) {
Task { Task {
await viewModel.saveFontSettings() 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) { Section {
VStack(alignment: .leading, spacing: 6) {
// Font Size Picker Text("readeck Bookmark Title")
VStack(alignment: .leading, spacing: 8) { .font(viewModel.previewTitleFont)
Text("Font size") .fontWeight(.semibold)
.font(.headline) .lineLimit(1)
Picker("Font size", selection: $viewModel.selectedFontSize) {
ForEach(FontSize.allCases, id: \.self) { size in Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
Text(size.displayName).tag(size) .font(viewModel.previewBodyFont)
} .lineLimit(3)
}
.pickerStyle(SegmentedPickerStyle()) Text("12 min • Today • example.com")
.onChange(of: viewModel.selectedFontSize) { .font(viewModel.previewCaptionFont)
Task {
await viewModel.saveFontSettings()
}
}
}
// Font Preview
VStack(alignment: .leading, spacing: 8) {
Text("Preview")
.font(.caption)
.foregroundColor(.secondary) .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 { .task {
await viewModel.loadFontSettings() await viewModel.loadFontSettings()
} }
@ -92,7 +70,10 @@ struct FontSettingsView: View {
} }
#Preview { #Preview {
FontSettingsView(viewModel: .init( List {
factory: MockUseCaseFactory()) FontSettingsView(viewModel: .init(
) factory: MockUseCaseFactory())
)
}
.listStyle(.insetGrouped)
} }

View File

@ -3,110 +3,67 @@ import SwiftUI
struct LegalPrivacySettingsView: View { struct LegalPrivacySettingsView: View {
@State private var showingPrivacyPolicy = false @State private var showingPrivacyPolicy = false
@State private var showingLegalNotice = false @State private var showingLegalNotice = false
var body: some View { var body: some View {
VStack(spacing: 20) { Group {
SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text") Section {
.padding(.bottom, 4)
VStack(spacing: 16) {
// Privacy Policy
Button(action: { Button(action: {
showingPrivacyPolicy = true showingPrivacyPolicy = true
}) { }) {
HStack { HStack {
Text(NSLocalizedString("Privacy Policy", comment: "")) Text(NSLocalizedString("Privacy Policy", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain)
// Legal Notice
Button(action: { Button(action: {
showingLegalNotice = true showingLegalNotice = true
}) { }) {
HStack { HStack {
Text(NSLocalizedString("Legal Notice", comment: "")) Text(NSLocalizedString("Legal Notice", comment: ""))
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
.buttonStyle(.plain) } header: {
Text("Legal & Privacy")
Divider() }
.padding(.vertical, 8)
Section {
// Support Section Button(action: {
VStack(spacing: 12) { if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
// Report an Issue UIApplication.shared.open(url)
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))
} }
.buttonStyle(.plain) }) {
HStack {
// Contact Support Text(NSLocalizedString("Report an Issue", comment: ""))
Button(action: { Spacer()
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") { Image(systemName: "arrow.up.right")
UIApplication.shared.open(url) .font(.caption)
} .foregroundColor(.secondary)
}) {
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))
} }
.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) { .sheet(isPresented: $showingPrivacyPolicy) {
@ -119,7 +76,8 @@ struct LegalPrivacySettingsView: View {
} }
#Preview { #Preview {
LegalPrivacySettingsView() List {
.cardStyle() LegalPrivacySettingsView()
.padding() }
} .listStyle(.insetGrouped)
}

View File

@ -8,111 +8,81 @@
import SwiftUI import SwiftUI
struct SettingsContainerView: View { struct SettingsContainerView: View {
private var appVersion: String { private var appVersion: String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
return "v\(version) (\(build))" return "v\(version) (\(build))"
} }
var body: some View { var body: some View {
ScrollView { List {
LazyVStack(spacing: 20) { AppearanceSettingsView()
FontSettingsView()
.cardStyle() CacheSettingsView()
AppearanceSettingsView() SettingsGeneralView()
.cardStyle()
SettingsServerView()
CacheSettingsView()
.cardStyle() LegalPrivacySettingsView()
SettingsGeneralView() // Debug-only Logging Configuration
.cardStyle() #if DEBUG
if Bundle.main.isDebugBuild {
SettingsServerView() debugSettingsSection
.cardStyle()
LegalPrivacySettingsView()
.cardStyle()
// Debug-only Logging Configuration
if Bundle.main.isDebugBuild {
debugSettingsSection
}
} }
.padding() #endif
.background(Color(.systemGroupedBackground))
// App Info Section
AppInfo() appInfoSection
Spacer()
} }
.background(Color(.systemGroupedBackground)) .listStyle(.insetGrouped)
.navigationTitle("Settings") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} }
@ViewBuilder @ViewBuilder
private var debugSettingsSection: some View { 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 { HStack {
Image(systemName: "ant.fill")
.foregroundColor(.orange)
Text("Debug Settings") Text("Debug Settings")
.font(.headline)
.foregroundColor(.primary)
Spacer() Spacer()
Text("DEBUG BUILD") Text("DEBUG BUILD")
.font(.caption) .font(.caption2)
.padding(.horizontal, 8) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 2)
.background(Color.orange.opacity(0.2)) .background(Color.orange.opacity(0.2))
.foregroundColor(.orange) .foregroundColor(.orange)
.clipShape(Capsule()) .clipShape(Capsule())
} }
}
NavigationLink { }
LoggingConfigurationView()
} label: { @ViewBuilder
HStack { private var appInfoSection: some View {
Image(systemName: "doc.text.magnifyingglass") Section {
.foregroundColor(.blue) VStack(spacing: 8) {
.frame(width: 24) HStack(spacing: 8) {
Image(systemName: "info.circle")
VStack(alignment: .leading, spacing: 2) { .foregroundColor(.secondary)
Text("Logging Configuration") Text("Version \(appVersion)")
.foregroundColor(.primary) .font(.footnote)
Text("Configure log levels and categories")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary) .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) { HStack(spacing: 4) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
Text("Developer:") Text("Developer:")
.font(.footnote) .font(.footnote)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -123,26 +93,23 @@ struct SettingsContainerView: View {
} }
.font(.footnote) .font(.footnote)
.foregroundColor(.blue) .foregroundColor(.blue)
.underline() }
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundColor(.secondary)
Text("From Bremen with 💚")
.font(.footnote)
.foregroundColor(.secondary)
} }
} }
HStack(spacing: 8) { .frame(maxWidth: .infinity)
Image(systemName: "globe") .padding(.vertical, 8)
.foregroundColor(.secondary)
Text("From Bremen with 💚")
.font(.footnote)
.foregroundColor(.secondary)
}
} }
.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 { extension View {
func cardStyle() -> some View { func cardStyle() -> some View {
self self
@ -154,5 +121,7 @@ extension View {
} }
#Preview { #Preview {
SettingsContainerView() NavigationStack {
} SettingsContainerView()
}
}

View File

@ -16,15 +16,8 @@ struct SettingsGeneralView: View {
} }
var body: some View { var body: some View {
VStack(spacing: 20) { Group {
SectionHeader(title: "General Settings".localized, icon: "gear") Section {
.padding(.bottom, 4)
VStack(alignment: .leading, spacing: 12) {
Text("General")
.font(.headline)
// What's New Button
Button(action: { Button(action: {
showReleaseNotes = true showReleaseNotes = true
}) { }) {
@ -39,83 +32,57 @@ struct SettingsGeneralView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
.buttonStyle(.plain)
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS) Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
.toggleStyle(.switch)
.onChange(of: viewModel.enableTTS) { .onChange(of: viewModel.enableTTS) {
Task { Task {
await viewModel.saveGeneralSettings() 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.") 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 #if DEBUG
// Sync Settings Section {
VStack(alignment: .leading, spacing: 12) {
Text("Sync Settings")
.font(.headline)
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled) Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
.toggleStyle(SwitchToggleStyle())
if viewModel.autoSyncEnabled { if viewModel.autoSyncEnabled {
HStack { Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
Text("Sync interval")
Spacer()
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
}
} }
} header: {
Text("Sync Settings")
} }
// Reading Settings Section {
VStack(alignment: .leading, spacing: 12) {
Text("Reading Settings")
.font(.headline)
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode) Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
.toggleStyle(SwitchToggleStyle())
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead) Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
.toggleStyle(SwitchToggleStyle()) } header: {
Text("Reading Settings")
} }
// Messages
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
HStack { Section {
Image(systemName: "checkmark.circle.fill") HStack {
.foregroundColor(.green) Image(systemName: "checkmark.circle.fill")
Text(successMessage) .foregroundColor(.green)
.foregroundColor(.green) Text(successMessage)
.font(.caption) .foregroundColor(.green)
}
} }
} }
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
HStack { Section {
Image(systemName: "exclamationmark.triangle.fill") HStack {
.foregroundColor(.red) Image(systemName: "exclamationmark.triangle.fill")
Text(errorMessage) .foregroundColor(.red)
.foregroundColor(.red) Text(errorMessage)
.font(.caption) .foregroundColor(.red)
}
} }
} }
#endif #endif
} }
.sheet(isPresented: $showReleaseNotes) { .sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView() ReleaseNotesView()
@ -127,7 +94,10 @@ struct SettingsGeneralView: View {
} }
#Preview { #Preview {
SettingsGeneralView(viewModel: .init( List {
MockUseCaseFactory() SettingsGeneralView(viewModel: .init(
)) MockUseCaseFactory()
))
}
.listStyle(.insetGrouped)
} }

View File

@ -11,189 +11,33 @@ struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel() @State private var viewModel = SettingsServerViewModel()
@State private var showingLogoutAlert = false @State private var showingLogoutAlert = false
init(showingLogoutAlert: Bool = false) {
self.showingLogoutAlert = showingLogoutAlert
}
var body: some View { var body: some View {
VStack(spacing: 20) { Section {
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack") SettingsRowValue(
.padding(.bottom, 4) icon: "server.rack",
title: "Server",
value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint
)
Text(viewModel.isSetupMode ? SettingsRowValue(
"Enter your Readeck server details to get started." : icon: "person.circle.fill",
"Your current server connection and login credentials.") title: "Username",
.font(.body) value: viewModel.username.isEmpty ? "Not set" : viewModel.username
.foregroundColor(.secondary) )
.multilineTextAlignment(.center)
.padding(.bottom, 8)
// Form SettingsRowButton(
VStack(spacing: 16) { icon: "rectangle.portrait.and.arrow.right",
// Server Endpoint iconColor: .red,
VStack(alignment: .leading, spacing: 8) { title: "Logout",
if viewModel.isSetupMode { subtitle: nil,
TextField("", destructive: true
text: $viewModel.endpoint, ) {
prompt: Text("Server Endpoint").foregroundColor(.secondary)) showingLogoutAlert = true
.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)
}
} }
} header: {
Text("Server Connection")
} footer: {
Text("Your current server connection and login credentials.")
} }
.alert("Logout", isPresented: $showingLogoutAlert) { .alert("Logout", isPresented: $showingLogoutAlert) {
Button("Cancel", role: .cancel) { } Button("Cancel", role: .cancel) { }
@ -211,22 +55,9 @@ struct SettingsServerView: View {
} }
} }
// MARK: - Quick Input Chip Component #Preview {
List {
struct QuickInputChip: View { SettingsServerView()
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)
}
} }
.listStyle(.insetGrouped)
} }

View File

@ -20,7 +20,7 @@ struct readeckApp: App {
if appViewModel.hasFinishedSetup { if appViewModel.hasFinishedSetup {
MainTabView() MainTabView()
} else { } else {
SettingsServerView() OnboardingServerView()
.padding() .padding()
} }
} }