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,58 +3,116 @@ 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)
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()
}
}
// Theme Section
VStack(alignment: .leading, spacing: 12) {
Text("Theme")
.font(.headline)
// 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) {
// Card Layout als NavigationLink
NavigationLink {
CardLayoutSelectionView(
selectedCardLayout: $selectedCardLayout,
onSave: saveCardLayoutSettings
)
} label: {
HStack {
Text("Card Layout")
.font(.headline)
Spacer()
Text(selectedCardLayout.displayName)
.foregroundColor(.secondary)
}
}
VStack(spacing: 16) {
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
CardLayoutPreview(
layout: layout,
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()
}
}
@ -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)
)
}
.buttonStyle(.plain)
}
}
#Preview {
NavigationStack {
List {
AppearanceSettingsView()
.cardStyle()
.padding()
}
.listStyle(.insetGrouped)
}
}

View File

@ -8,15 +8,10 @@ struct CacheSettingsView: View {
@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) {
Section {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Current Cache Size")
.foregroundColor(.primary)
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
.font(.caption)
.foregroundColor(.secondary)
@ -29,12 +24,9 @@ struct CacheSettingsView: View {
.foregroundColor(.blue)
}
Divider()
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Max Cache Size")
.foregroundColor(.primary)
Spacer()
Text("\(Int(maxCacheSize)) MB")
.font(.caption)
@ -47,11 +39,8 @@ struct CacheSettingsView: View {
.onChange(of: maxCacheSize) { _, newValue in
updateMaxCacheSize(newValue)
}
.accentColor(.blue)
}
Divider()
Button(action: {
showClearAlert = true
}) {
@ -59,11 +48,9 @@ struct CacheSettingsView: View {
if isClearing {
ProgressView()
.scaleEffect(0.8)
.frame(width: 24)
} else {
Image(systemName: "trash")
.foregroundColor(.red)
.frame(width: 24)
}
VStack(alignment: .leading, spacing: 2) {
@ -78,7 +65,8 @@ struct CacheSettingsView: View {
}
}
.disabled(isClearing)
}
} header: {
Text("Cache Settings")
}
.onAppear {
updateCacheSize()
@ -142,7 +130,8 @@ struct CacheSettingsView: View {
}
#Preview {
List {
CacheSettingsView()
.cardStyle()
.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

@ -15,52 +15,35 @@ struct FontSettingsView: View {
}
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()
}
}
}
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())
.pickerStyle(.segmented)
.onChange(of: viewModel.selectedFontSize) {
Task {
await viewModel.saveFontSettings()
}
}
} header: {
Text("Font Settings")
}
// Font Preview
VStack(alignment: .leading, spacing: 8) {
Text("Preview")
.font(.caption)
.foregroundColor(.secondary)
Section {
VStack(alignment: .leading, spacing: 6) {
Text("readeck Bookmark Title")
.font(viewModel.previewTitleFont)
@ -75,16 +58,11 @@ struct FontSettingsView: View {
.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 {
List {
FontSettingsView(viewModel: .init(
factory: MockUseCaseFactory())
)
}
.listStyle(.insetGrouped)
}

View File

@ -5,61 +5,36 @@ struct LegalPrivacySettingsView: View {
@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)
} header: {
Text("Legal & Privacy")
}
Divider()
.padding(.vertical, 8)
// Support Section
VStack(spacing: 12) {
// Report an Issue
Section {
Button(action: {
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
UIApplication.shared.open(url)
@ -67,23 +42,13 @@ struct LegalPrivacySettingsView: View {
}) {
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)
// Contact Support
Button(action: {
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
UIApplication.shared.open(url)
@ -91,22 +56,14 @@ struct LegalPrivacySettingsView: View {
}) {
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)
}
} header: {
Text("Support")
}
}
.sheet(isPresented: $showingPrivacyPolicy) {
@ -119,7 +76,8 @@ struct LegalPrivacySettingsView: View {
}
#Preview {
List {
LegalPrivacySettingsView()
.cardStyle()
.padding()
}
.listStyle(.insetGrouped)
}

View File

@ -16,92 +16,62 @@ struct SettingsContainerView: View {
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
FontSettingsView()
.cardStyle()
List {
AppearanceSettingsView()
.cardStyle()
CacheSettingsView()
.cardStyle()
SettingsGeneralView()
.cardStyle()
SettingsServerView()
.cardStyle()
LegalPrivacySettingsView()
.cardStyle()
// Debug-only Logging Configuration
#if DEBUG
if Bundle.main.isDebugBuild {
debugSettingsSection
}
}
.padding()
.background(Color(.systemGroupedBackground))
#endif
AppInfo()
Spacer()
// 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)
.foregroundColor(.secondary)
}
}
}
.cardStyle()
}
@ViewBuilder
func AppInfo() -> some View {
VStack(spacing: 4) {
private var appInfoSection: some View {
Section {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(.secondary)
@ -109,10 +79,10 @@ struct SettingsContainerView: View {
.font(.footnote)
.foregroundColor(.secondary)
}
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: "person.crop.circle")
.foregroundColor(.secondary)
HStack(spacing: 4) {
Text("Developer:")
.font(.footnote)
.foregroundColor(.secondary)
@ -123,9 +93,8 @@ struct SettingsContainerView: View {
}
.font(.footnote)
.foregroundColor(.blue)
.underline()
}
}
HStack(spacing: 8) {
Image(systemName: "globe")
.foregroundColor(.secondary)
@ -135,14 +104,12 @@ struct SettingsContainerView: View {
}
}
.frame(maxWidth: .infinity)
.padding(.top, 16)
.padding(.bottom, 4)
.multilineTextAlignment(.center)
.opacity(0.7)
.padding(.vertical, 8)
}
}
}
// 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 {
NavigationStack {
SettingsContainerView()
}
}

View File

@ -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 {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(successMessage)
.foregroundColor(.green)
.font(.caption)
}
}
}
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
}
#endif
}
.sheet(isPresented: $showReleaseNotes) {
ReleaseNotesView()
@ -127,7 +94,10 @@ struct SettingsGeneralView: View {
}
#Preview {
List {
SettingsGeneralView(viewModel: .init(
MockUseCaseFactory()
))
}
.listStyle(.insetGrouped)
}

View File

@ -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: {
SettingsRowButton(
icon: "rectangle.portrait.and.arrow.right",
iconColor: .red,
title: "Logout",
subtitle: nil,
destructive: true
) {
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) {
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)
}

View File

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