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:
parent
589fcdb2b4
commit
4b93c605f1
308
readeck/UI/Components/SettingsRow.swift
Normal file
308
readeck/UI/Components/SettingsRow.swift
Normal 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)
|
||||
}
|
||||
175
readeck/UI/Onboarding/OnboardingServerView.swift
Normal file
175
readeck/UI/Onboarding/OnboardingServerView.swift
Normal 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()
|
||||
}
|
||||
@ -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) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -8,77 +8,65 @@ 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) {
|
||||
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()
|
||||
@ -142,7 +130,8 @@ struct CacheSettingsView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CacheSettingsView()
|
||||
.cardStyle()
|
||||
.padding()
|
||||
List {
|
||||
CacheSettingsView()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
171
readeck/UI/Settings/CardLayoutSelectionView.swift
Normal file
171
readeck/UI/Settings/CardLayoutSelectionView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -15,76 +15,54 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Text("readeck Bookmark Title")
|
||||
.font(viewModel.previewTitleFont)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
// Font Preview
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Preview")
|
||||
.font(.caption)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -5,108 +5,65 @@ 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
|
||||
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))
|
||||
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()
|
||||
List {
|
||||
LegalPrivacySettingsView()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
@ -16,103 +16,73 @@ struct SettingsContainerView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 20) {
|
||||
FontSettingsView()
|
||||
.cardStyle()
|
||||
List {
|
||||
AppearanceSettingsView()
|
||||
|
||||
AppearanceSettingsView()
|
||||
.cardStyle()
|
||||
CacheSettingsView()
|
||||
|
||||
CacheSettingsView()
|
||||
.cardStyle()
|
||||
SettingsGeneralView()
|
||||
|
||||
SettingsGeneralView()
|
||||
.cardStyle()
|
||||
SettingsServerView()
|
||||
|
||||
SettingsServerView()
|
||||
.cardStyle()
|
||||
LegalPrivacySettingsView()
|
||||
|
||||
LegalPrivacySettingsView()
|
||||
.cardStyle()
|
||||
|
||||
// Debug-only Logging Configuration
|
||||
if Bundle.main.isDebugBuild {
|
||||
debugSettingsSection
|
||||
}
|
||||
// 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) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
||||
}
|
||||
} header: {
|
||||
Text("Sync Settings")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||
} header: {
|
||||
Text("Reading Settings")
|
||||
}
|
||||
|
||||
if let successMessage = viewModel.successMessage {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Sync interval")
|
||||
Spacer()
|
||||
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text(successMessage)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reading Settings
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Reading Settings")
|
||||
.font(.headline)
|
||||
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||
.toggleStyle(SwitchToggleStyle())
|
||||
}
|
||||
|
||||
// Messages
|
||||
if let successMessage = viewModel.successMessage {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text(successMessage)
|
||||
.foregroundColor(.green)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ struct readeckApp: App {
|
||||
if appViewModel.hasFinishedSetup {
|
||||
MainTabView()
|
||||
} else {
|
||||
SettingsServerView()
|
||||
OnboardingServerView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user