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,62 +3,120 @@ import SwiftUI
|
|||||||
struct AppearanceSettingsView: View {
|
struct AppearanceSettingsView: View {
|
||||||
@State private var selectedCardLayout: CardLayoutStyle = .magazine
|
@State private var selectedCardLayout: CardLayoutStyle = .magazine
|
||||||
@State private var selectedTheme: Theme = .system
|
@State private var selectedTheme: Theme = .system
|
||||||
|
@State private var fontViewModel: FontSettingsViewModel
|
||||||
|
@State private var generalViewModel: SettingsGeneralViewModel
|
||||||
|
|
||||||
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
|
||||||
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
private let saveCardLayoutUseCase: PSaveCardLayoutUseCase
|
||||||
private let settingsRepository: PSettingsRepository
|
private let settingsRepository: PSettingsRepository
|
||||||
|
|
||||||
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
init(
|
||||||
|
factory: UseCaseFactory = DefaultUseCaseFactory.shared,
|
||||||
|
fontViewModel: FontSettingsViewModel = FontSettingsViewModel(),
|
||||||
|
generalViewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()
|
||||||
|
) {
|
||||||
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase()
|
||||||
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase()
|
||||||
self.settingsRepository = SettingsRepository()
|
self.settingsRepository = SettingsRepository()
|
||||||
|
self.fontViewModel = fontViewModel
|
||||||
|
self.generalViewModel = generalViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Appearance".localized, icon: "paintbrush")
|
Section {
|
||||||
.padding(.bottom, 4)
|
// Font Family
|
||||||
|
Picker("Font family", selection: $fontViewModel.selectedFontFamily) {
|
||||||
// Theme Section
|
ForEach(FontFamily.allCases, id: \.self) { family in
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
Text(family.displayName).tag(family)
|
||||||
Text("Theme")
|
}
|
||||||
.font(.headline)
|
}
|
||||||
|
.onChange(of: fontViewModel.selectedFontFamily) {
|
||||||
|
Task {
|
||||||
|
await fontViewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font Size
|
||||||
|
Picker("Font size", selection: $fontViewModel.selectedFontSize) {
|
||||||
|
ForEach(FontSize.allCases, id: \.self) { size in
|
||||||
|
Text(size.displayName).tag(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: fontViewModel.selectedFontSize) {
|
||||||
|
Task {
|
||||||
|
await fontViewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font Preview - direkt in der gleichen Section
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("readeck Bookmark Title")
|
||||||
|
.font(fontViewModel.previewTitleFont)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
||||||
|
.font(fontViewModel.previewBodyFont)
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
Text("12 min • Today • example.com")
|
||||||
|
.font(fontViewModel.previewCaptionFont)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.listRowBackground(Color(.systemGray6))
|
||||||
|
|
||||||
|
// Theme Picker (Menu statt Segmented)
|
||||||
Picker("Theme", selection: $selectedTheme) {
|
Picker("Theme", selection: $selectedTheme) {
|
||||||
ForEach(Theme.allCases, id: \.self) { theme in
|
ForEach(Theme.allCases, id: \.self) { theme in
|
||||||
Text(theme.displayName).tag(theme)
|
Text(theme.displayName).tag(theme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.onChange(of: selectedTheme) {
|
.onChange(of: selectedTheme) {
|
||||||
saveThemeSettings()
|
saveThemeSettings()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Card Layout als NavigationLink
|
||||||
Divider()
|
NavigationLink {
|
||||||
|
CardLayoutSelectionView(
|
||||||
// Card Layout Section
|
selectedCardLayout: $selectedCardLayout,
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
onSave: saveCardLayoutSettings
|
||||||
Text("Card Layout")
|
)
|
||||||
.font(.headline)
|
} label: {
|
||||||
|
HStack {
|
||||||
VStack(spacing: 16) {
|
Text("Card Layout")
|
||||||
ForEach(CardLayoutStyle.allCases, id: \.self) { layout in
|
Spacer()
|
||||||
CardLayoutPreview(
|
Text(selectedCardLayout.displayName)
|
||||||
layout: layout,
|
.foregroundColor(.secondary)
|
||||||
isSelected: selectedCardLayout == layout
|
|
||||||
) {
|
|
||||||
selectedCardLayout = layout
|
|
||||||
saveCardLayoutSettings()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open external links in
|
||||||
|
Picker("Open links in", selection: $generalViewModel.urlOpener) {
|
||||||
|
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
|
||||||
|
Text(urlOpener.displayName).tag(urlOpener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: generalViewModel.urlOpener) {
|
||||||
|
Task {
|
||||||
|
await generalViewModel.saveGeneralSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Appearance")
|
||||||
|
} footer: {
|
||||||
|
Text("Choose where external links should open: In-App Browser keeps you in readeck, Default Browser opens in Safari or your default browser.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.task {
|
||||||
|
await fontViewModel.loadFontSettings()
|
||||||
|
await generalViewModel.loadGeneralSettings()
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSettings() {
|
private func loadSettings() {
|
||||||
Task {
|
Task {
|
||||||
// Load both theme and card layout from repository
|
// Load both theme and card layout from repository
|
||||||
@ -70,21 +128,21 @@ struct AppearanceSettingsView: View {
|
|||||||
selectedCardLayout = await loadCardLayoutUseCase.execute()
|
selectedCardLayout = await loadCardLayoutUseCase.execute()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveThemeSettings() {
|
private func saveThemeSettings() {
|
||||||
Task {
|
Task {
|
||||||
// Load current settings, update theme, and save back
|
// Load current settings, update theme, and save back
|
||||||
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
var settings = (try? await settingsRepository.loadSettings()) ?? Settings()
|
||||||
settings.theme = selectedTheme
|
settings.theme = selectedTheme
|
||||||
try? await settingsRepository.saveSettings(settings)
|
try? await settingsRepository.saveSettings(settings)
|
||||||
|
|
||||||
// Notify app about theme change
|
// Notify app about theme change
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveCardLayoutSettings() {
|
private func saveCardLayoutSettings() {
|
||||||
Task {
|
Task {
|
||||||
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
await saveCardLayoutUseCase.execute(layout: selectedCardLayout)
|
||||||
@ -96,139 +154,11 @@ struct AppearanceSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
struct CardLayoutPreview: View {
|
NavigationStack {
|
||||||
let layout: CardLayoutStyle
|
List {
|
||||||
let isSelected: Bool
|
AppearanceSettingsView()
|
||||||
let onSelect: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: onSelect) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
// Visual Preview
|
|
||||||
switch layout {
|
|
||||||
case .compact:
|
|
||||||
// Compact: Small image on left, content on right
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(width: 24, height: 24)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 6)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 60)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.4))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 50)
|
|
||||||
|
|
||||||
case .magazine:
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(height: 24)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 5)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 40)
|
|
||||||
|
|
||||||
Text("Fixed 140px")
|
|
||||||
.font(.system(size: 7))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
.padding(6)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 65)
|
|
||||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
|
||||||
|
|
||||||
case .natural:
|
|
||||||
VStack(spacing: 3) {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.blue.opacity(0.6))
|
|
||||||
.frame(height: 38)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.8))
|
|
||||||
.frame(height: 5)
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
|
||||||
.fill(Color.primary.opacity(0.6))
|
|
||||||
.frame(height: 4)
|
|
||||||
.frame(maxWidth: 35)
|
|
||||||
|
|
||||||
Text("Original ratio")
|
|
||||||
.font(.system(size: 7))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 1)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
|
||||||
.padding(6)
|
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.frame(width: 80, height: 75) // Höher als Magazine
|
|
||||||
.shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(layout.displayName)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Text(layout.description)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if isSelected {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
.font(.title2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
AppearanceSettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,79 +6,67 @@ struct CacheSettingsView: View {
|
|||||||
@State private var maxCacheSize: Double = 200
|
@State private var maxCacheSize: Double = 200
|
||||||
@State private var isClearing: Bool = false
|
@State private var isClearing: Bool = false
|
||||||
@State private var showClearAlert: Bool = false
|
@State private var showClearAlert: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Section {
|
||||||
SectionHeader(title: "Cache Settings".localized, icon: "internaldrive")
|
HStack {
|
||||||
.padding(.bottom, 4)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Current Cache Size")
|
||||||
VStack(spacing: 12) {
|
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
||||||
HStack {
|
.font(.caption)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.foregroundColor(.secondary)
|
||||||
Text("Current Cache Size")
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text("\(cacheSize) / \(Int(maxCacheSize)) MB max")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button("Refresh") {
|
|
||||||
updateCacheSize()
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
Divider()
|
Button("Refresh") {
|
||||||
|
updateCacheSize()
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack {
|
|
||||||
Text("Max Cache Size")
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(maxCacheSize)) MB")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
|
||||||
Text("Max Cache Size")
|
|
||||||
}
|
|
||||||
.onChange(of: maxCacheSize) { _, newValue in
|
|
||||||
updateMaxCacheSize(newValue)
|
|
||||||
}
|
|
||||||
.accentColor(.blue)
|
|
||||||
}
|
}
|
||||||
|
.font(.caption)
|
||||||
Divider()
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showClearAlert = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if isClearing {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
.frame(width: 24)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.frame(width: 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Clear Cache")
|
|
||||||
.foregroundColor(isClearing ? .secondary : .red)
|
|
||||||
Text("Remove all cached images")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(isClearing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(maxCacheSize)) MB")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(value: $maxCacheSize, in: 50...1200, step: 50) {
|
||||||
|
Text("Max Cache Size")
|
||||||
|
}
|
||||||
|
.onChange(of: maxCacheSize) { _, newValue in
|
||||||
|
updateMaxCacheSize(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showClearAlert = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
if isClearing {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Clear Cache")
|
||||||
|
.foregroundColor(isClearing ? .secondary : .red)
|
||||||
|
Text("Remove all cached images")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isClearing)
|
||||||
|
} header: {
|
||||||
|
Text("Cache Settings")
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateCacheSize()
|
updateCacheSize()
|
||||||
@ -93,7 +81,7 @@ struct CacheSettingsView: View {
|
|||||||
Text("This will remove all cached images. They will be downloaded again when needed.")
|
Text("This will remove all cached images. They will be downloaded again when needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCacheSize() {
|
private func updateCacheSize() {
|
||||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -107,7 +95,7 @@ struct CacheSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadMaxCacheSize() {
|
private func loadMaxCacheSize() {
|
||||||
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt
|
||||||
if let savedSize = savedSize {
|
if let savedSize = savedSize {
|
||||||
@ -120,29 +108,30 @@ struct CacheSettingsView: View {
|
|||||||
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMaxCacheSize(_ newSize: Double) {
|
private func updateMaxCacheSize(_ newSize: Double) {
|
||||||
let bytes = UInt(newSize * 1024 * 1024)
|
let bytes = UInt(newSize * 1024 * 1024)
|
||||||
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes
|
||||||
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearCache() {
|
private func clearCache() {
|
||||||
isClearing = true
|
isClearing = true
|
||||||
|
|
||||||
KingfisherManager.shared.cache.clearDiskCache {
|
KingfisherManager.shared.cache.clearDiskCache {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isClearing = false
|
self.isClearing = false
|
||||||
self.updateCacheSize()
|
self.updateCacheSize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
KingfisherManager.shared.cache.clearMemoryCache()
|
KingfisherManager.shared.cache.clearMemoryCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CacheSettingsView()
|
List {
|
||||||
.cardStyle()
|
CacheSettingsView()
|
||||||
.padding()
|
}
|
||||||
}
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|||||||
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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,82 +9,60 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FontSettingsView: View {
|
struct FontSettingsView: View {
|
||||||
@State private var viewModel: FontSettingsViewModel
|
@State private var viewModel: FontSettingsViewModel
|
||||||
|
|
||||||
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
|
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Font Settings".localized, icon: "textformat")
|
Section {
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
// Font Family Picker
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
|
||||||
Text("Font family")
|
|
||||||
.font(.headline)
|
|
||||||
Picker("Font family", selection: $viewModel.selectedFontFamily) {
|
Picker("Font family", selection: $viewModel.selectedFontFamily) {
|
||||||
ForEach(FontFamily.allCases, id: \.self) { family in
|
ForEach(FontFamily.allCases, id: \.self) { family in
|
||||||
Text(family.displayName).tag(family)
|
Text(family.displayName).tag(family)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(MenuPickerStyle())
|
|
||||||
.onChange(of: viewModel.selectedFontFamily) {
|
.onChange(of: viewModel.selectedFontFamily) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.saveFontSettings()
|
await viewModel.saveFontSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
||||||
|
ForEach(FontSize.allCases, id: \.self) { size in
|
||||||
|
Text(size.displayName).tag(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.selectedFontSize) {
|
||||||
|
Task {
|
||||||
|
await viewModel.saveFontSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Font Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
// Font Size Picker
|
Text("readeck Bookmark Title")
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.font(viewModel.previewTitleFont)
|
||||||
Text("Font size")
|
.fontWeight(.semibold)
|
||||||
.font(.headline)
|
.lineLimit(1)
|
||||||
Picker("Font size", selection: $viewModel.selectedFontSize) {
|
|
||||||
ForEach(FontSize.allCases, id: \.self) { size in
|
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
||||||
Text(size.displayName).tag(size)
|
.font(viewModel.previewBodyFont)
|
||||||
}
|
.lineLimit(3)
|
||||||
}
|
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
Text("12 min • Today • example.com")
|
||||||
.onChange(of: viewModel.selectedFontSize) {
|
.font(viewModel.previewCaptionFont)
|
||||||
Task {
|
|
||||||
await viewModel.saveFontSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font Preview
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Preview")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("readeck Bookmark Title")
|
|
||||||
.font(viewModel.previewTitleFont)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text("This is how your bookmark descriptions and article text will appear in the app. The quick brown fox jumps over the lazy dog.")
|
|
||||||
.font(viewModel.previewBodyFont)
|
|
||||||
.lineLimit(3)
|
|
||||||
|
|
||||||
Text("12 min • Today • example.com")
|
|
||||||
.font(viewModel.previewCaptionFont)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(4)
|
|
||||||
.background(Color(.systemGray6))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} header: {
|
||||||
|
Text("Preview")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
|
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadFontSettings()
|
await viewModel.loadFontSettings()
|
||||||
}
|
}
|
||||||
@ -92,7 +70,10 @@ struct FontSettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
FontSettingsView(viewModel: .init(
|
List {
|
||||||
factory: MockUseCaseFactory())
|
FontSettingsView(viewModel: .init(
|
||||||
)
|
factory: MockUseCaseFactory())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,110 +3,67 @@ import SwiftUI
|
|||||||
struct LegalPrivacySettingsView: View {
|
struct LegalPrivacySettingsView: View {
|
||||||
@State private var showingPrivacyPolicy = false
|
@State private var showingPrivacyPolicy = false
|
||||||
@State private var showingLegalNotice = false
|
@State private var showingLegalNotice = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "Legal & Privacy".localized, icon: "doc.text")
|
Section {
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
// Privacy Policy
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingPrivacyPolicy = true
|
showingPrivacyPolicy = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(NSLocalizedString("Privacy Policy", comment: ""))
|
Text(NSLocalizedString("Privacy Policy", comment: ""))
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
// Legal Notice
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingLegalNotice = true
|
showingLegalNotice = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(NSLocalizedString("Legal Notice", comment: ""))
|
Text(NSLocalizedString("Legal Notice", comment: ""))
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
} header: {
|
||||||
|
Text("Legal & Privacy")
|
||||||
Divider()
|
}
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
Section {
|
||||||
// Support Section
|
Button(action: {
|
||||||
VStack(spacing: 12) {
|
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
||||||
// Report an Issue
|
UIApplication.shared.open(url)
|
||||||
Button(action: {
|
|
||||||
if let url = URL(string: "https://github.com/ilyas-hallak/readeck-ios/issues") {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Report an Issue", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
}) {
|
||||||
|
HStack {
|
||||||
// Contact Support
|
Text(NSLocalizedString("Report an Issue", comment: ""))
|
||||||
Button(action: {
|
Spacer()
|
||||||
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
Image(systemName: "arrow.up.right")
|
||||||
UIApplication.shared.open(url)
|
.font(.caption)
|
||||||
}
|
.foregroundColor(.secondary)
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Text(NSLocalizedString("Contact Support", comment: ""))
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "arrow.up.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "mailto:hi@ilyashallak.de?subject=readeck%20iOS") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text(NSLocalizedString("Contact Support", comment: ""))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Support")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingPrivacyPolicy) {
|
.sheet(isPresented: $showingPrivacyPolicy) {
|
||||||
@ -119,7 +76,8 @@ struct LegalPrivacySettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
LegalPrivacySettingsView()
|
List {
|
||||||
.cardStyle()
|
LegalPrivacySettingsView()
|
||||||
.padding()
|
}
|
||||||
}
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|||||||
@ -8,111 +8,81 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsContainerView: View {
|
struct SettingsContainerView: View {
|
||||||
|
|
||||||
private var appVersion: String {
|
private var appVersion: String {
|
||||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
||||||
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
||||||
return "v\(version) (\(build))"
|
return "v\(version) (\(build))"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
List {
|
||||||
LazyVStack(spacing: 20) {
|
AppearanceSettingsView()
|
||||||
FontSettingsView()
|
|
||||||
.cardStyle()
|
CacheSettingsView()
|
||||||
|
|
||||||
AppearanceSettingsView()
|
SettingsGeneralView()
|
||||||
.cardStyle()
|
|
||||||
|
SettingsServerView()
|
||||||
CacheSettingsView()
|
|
||||||
.cardStyle()
|
LegalPrivacySettingsView()
|
||||||
|
|
||||||
SettingsGeneralView()
|
// Debug-only Logging Configuration
|
||||||
.cardStyle()
|
#if DEBUG
|
||||||
|
if Bundle.main.isDebugBuild {
|
||||||
SettingsServerView()
|
debugSettingsSection
|
||||||
.cardStyle()
|
|
||||||
|
|
||||||
LegalPrivacySettingsView()
|
|
||||||
.cardStyle()
|
|
||||||
|
|
||||||
// Debug-only Logging Configuration
|
|
||||||
if Bundle.main.isDebugBuild {
|
|
||||||
debugSettingsSection
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
#endif
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
|
// App Info Section
|
||||||
AppInfo()
|
appInfoSection
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.listStyle(.insetGrouped)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var debugSettingsSection: some View {
|
private var debugSettingsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
Section {
|
||||||
|
SettingsRowNavigationLink(
|
||||||
|
icon: "doc.text.magnifyingglass",
|
||||||
|
iconColor: .blue,
|
||||||
|
title: "Logging Configuration",
|
||||||
|
subtitle: "Configure log levels and categories"
|
||||||
|
) {
|
||||||
|
LoggingConfigurationView()
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "ant.fill")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text("Debug Settings")
|
Text("Debug Settings")
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("DEBUG BUILD")
|
Text("DEBUG BUILD")
|
||||||
.font(.caption)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 2)
|
||||||
.background(Color.orange.opacity(0.2))
|
.background(Color.orange.opacity(0.2))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
NavigationLink {
|
}
|
||||||
LoggingConfigurationView()
|
|
||||||
} label: {
|
@ViewBuilder
|
||||||
HStack {
|
private var appInfoSection: some View {
|
||||||
Image(systemName: "doc.text.magnifyingglass")
|
Section {
|
||||||
.foregroundColor(.blue)
|
VStack(spacing: 8) {
|
||||||
.frame(width: 24)
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.foregroundColor(.secondary)
|
||||||
Text("Logging Configuration")
|
Text("Version \(appVersion)")
|
||||||
.foregroundColor(.primary)
|
.font(.footnote)
|
||||||
Text("Configure log levels and categories")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.cardStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func AppInfo() -> some View {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("Version \(appVersion)")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "person.crop.circle")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "person.crop.circle")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
Text("Developer:")
|
Text("Developer:")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@ -123,26 +93,23 @@ struct SettingsContainerView: View {
|
|||||||
}
|
}
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
.underline()
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("From Bremen with 💚")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
.frame(maxWidth: .infinity)
|
||||||
Image(systemName: "globe")
|
.padding(.vertical, 8)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("From Bremen with 💚")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.top, 16)
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card Modifier für einheitlichen Look
|
// Card Modifier für einheitlichen Look (kept for backwards compatibility with other views)
|
||||||
extension View {
|
extension View {
|
||||||
func cardStyle() -> some View {
|
func cardStyle() -> some View {
|
||||||
self
|
self
|
||||||
@ -154,5 +121,7 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsContainerView()
|
NavigationStack {
|
||||||
}
|
SettingsContainerView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -16,15 +16,8 @@ struct SettingsGeneralView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Group {
|
||||||
SectionHeader(title: "General Settings".localized, icon: "gear")
|
Section {
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("General")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
// What's New Button
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showReleaseNotes = true
|
showReleaseNotes = true
|
||||||
}) {
|
}) {
|
||||||
@ -39,83 +32,57 @@ struct SettingsGeneralView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
Toggle("Read Aloud Feature", isOn: $viewModel.enableTTS)
|
||||||
.toggleStyle(.switch)
|
|
||||||
.onChange(of: viewModel.enableTTS) {
|
.onChange(of: viewModel.enableTTS) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.saveGeneralSettings()
|
await viewModel.saveGeneralSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("General")
|
||||||
|
} footer: {
|
||||||
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
|
Text("Activate the Read Aloud Feature to read aloud your articles. This is a really early preview and might not work perfectly.")
|
||||||
.font(.footnote)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reading Settings
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Open external links in".localized)
|
|
||||||
.font(.headline)
|
|
||||||
Picker("urlOpener", selection: $viewModel.urlOpener) {
|
|
||||||
ForEach(UrlOpener.allCases, id: \.self) { urlOpener in
|
|
||||||
Text(urlOpener.displayName.localized).tag(urlOpener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.onChange(of: viewModel.urlOpener) {
|
|
||||||
Task {
|
|
||||||
await viewModel.saveGeneralSettings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Sync Settings
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Sync Settings")
|
|
||||||
.font(.headline)
|
|
||||||
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
Toggle("Automatic sync", isOn: $viewModel.autoSyncEnabled)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
|
||||||
if viewModel.autoSyncEnabled {
|
if viewModel.autoSyncEnabled {
|
||||||
HStack {
|
Stepper("Sync interval: \(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
||||||
Text("Sync interval")
|
|
||||||
Spacer()
|
|
||||||
Stepper("\(viewModel.syncInterval) minutes", value: $viewModel.syncInterval, in: 1...60)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Sync Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reading Settings
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Reading Settings")
|
|
||||||
.font(.headline)
|
|
||||||
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
Toggle("Safari Reader Mode", isOn: $viewModel.enableReaderMode)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
|
||||||
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
Toggle("Automatically mark articles as read", isOn: $viewModel.autoMarkAsRead)
|
||||||
.toggleStyle(SwitchToggleStyle())
|
} header: {
|
||||||
|
Text("Reading Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages
|
|
||||||
if let successMessage = viewModel.successMessage {
|
if let successMessage = viewModel.successMessage {
|
||||||
HStack {
|
Section {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
HStack {
|
||||||
.foregroundColor(.green)
|
Image(systemName: "checkmark.circle.fill")
|
||||||
Text(successMessage)
|
.foregroundColor(.green)
|
||||||
.foregroundColor(.green)
|
Text(successMessage)
|
||||||
.font(.caption)
|
.foregroundColor(.green)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
HStack {
|
Section {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
HStack {
|
||||||
.foregroundColor(.red)
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
Text(errorMessage)
|
.foregroundColor(.red)
|
||||||
.foregroundColor(.red)
|
Text(errorMessage)
|
||||||
.font(.caption)
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showReleaseNotes) {
|
.sheet(isPresented: $showReleaseNotes) {
|
||||||
ReleaseNotesView()
|
ReleaseNotesView()
|
||||||
@ -127,7 +94,10 @@ struct SettingsGeneralView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsGeneralView(viewModel: .init(
|
List {
|
||||||
MockUseCaseFactory()
|
SettingsGeneralView(viewModel: .init(
|
||||||
))
|
MockUseCaseFactory()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,189 +11,33 @@ struct SettingsServerView: View {
|
|||||||
@State private var viewModel = SettingsServerViewModel()
|
@State private var viewModel = SettingsServerViewModel()
|
||||||
@State private var showingLogoutAlert = false
|
@State private var showingLogoutAlert = false
|
||||||
|
|
||||||
init(showingLogoutAlert: Bool = false) {
|
|
||||||
self.showingLogoutAlert = showingLogoutAlert
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
Section {
|
||||||
SectionHeader(title: viewModel.isSetupMode ? "Server Settings".localized : "Server Connection".localized, icon: "server.rack")
|
SettingsRowValue(
|
||||||
.padding(.bottom, 4)
|
icon: "server.rack",
|
||||||
|
title: "Server",
|
||||||
|
value: viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint
|
||||||
|
)
|
||||||
|
|
||||||
Text(viewModel.isSetupMode ?
|
SettingsRowValue(
|
||||||
"Enter your Readeck server details to get started." :
|
icon: "person.circle.fill",
|
||||||
"Your current server connection and login credentials.")
|
title: "Username",
|
||||||
.font(.body)
|
value: viewModel.username.isEmpty ? "Not set" : viewModel.username
|
||||||
.foregroundColor(.secondary)
|
)
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
|
|
||||||
// Form
|
SettingsRowButton(
|
||||||
VStack(spacing: 16) {
|
icon: "rectangle.portrait.and.arrow.right",
|
||||||
// Server Endpoint
|
iconColor: .red,
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
title: "Logout",
|
||||||
if viewModel.isSetupMode {
|
subtitle: nil,
|
||||||
TextField("",
|
destructive: true
|
||||||
text: $viewModel.endpoint,
|
) {
|
||||||
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
showingLogoutAlert = true
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.keyboardType(.URL)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.onChange(of: viewModel.endpoint) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick Input Chips
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
QuickInputChip(text: "http://", action: {
|
|
||||||
if !viewModel.endpoint.starts(with: "http") {
|
|
||||||
viewModel.endpoint = "http://" + viewModel.endpoint
|
|
||||||
}
|
|
||||||
})
|
|
||||||
QuickInputChip(text: "https://", action: {
|
|
||||||
if !viewModel.endpoint.starts(with: "http") {
|
|
||||||
viewModel.endpoint = "https://" + viewModel.endpoint
|
|
||||||
}
|
|
||||||
})
|
|
||||||
QuickInputChip(text: "192.168.", action: {
|
|
||||||
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
|
|
||||||
if viewModel.endpoint.starts(with: "http") {
|
|
||||||
viewModel.endpoint += "192.168."
|
|
||||||
} else {
|
|
||||||
viewModel.endpoint = "http://192.168."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
QuickInputChip(text: ":8000", action: {
|
|
||||||
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
|
|
||||||
viewModel.endpoint += ":8000"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
} else {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "server.rack")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text(viewModel.endpoint.isEmpty ? "Not set" : viewModel.endpoint)
|
|
||||||
.foregroundColor(viewModel.endpoint.isEmpty ? .secondary : .primary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Username
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
if viewModel.isSetupMode {
|
|
||||||
TextField("",
|
|
||||||
text: $viewModel.username,
|
|
||||||
prompt: Text("Username").foregroundColor(.secondary))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.onChange(of: viewModel.username) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "person.circle.fill")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
Text(viewModel.username.isEmpty ? "Not set" : viewModel.username)
|
|
||||||
.foregroundColor(viewModel.username.isEmpty ? .secondary : .primary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password
|
|
||||||
if viewModel.isSetupMode {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
SecureField("",
|
|
||||||
text: $viewModel.password,
|
|
||||||
prompt: Text("Password").foregroundColor(.secondary))
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.onChange(of: viewModel.password) {
|
|
||||||
viewModel.clearMessages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Messages
|
|
||||||
if let errorMessage = viewModel.errorMessage {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let successMessage = viewModel.successMessage {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.green)
|
|
||||||
Text(successMessage)
|
|
||||||
.foregroundColor(.green)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.isSetupMode {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Button(action: {
|
|
||||||
Task {
|
|
||||||
await viewModel.saveServerSettings()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
}
|
|
||||||
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(action: {
|
|
||||||
showingLogoutAlert = true
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
|
||||||
.font(.caption)
|
|
||||||
Text("Logout")
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(Color(.systemGray5))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Server Connection")
|
||||||
|
} footer: {
|
||||||
|
Text("Your current server connection and login credentials.")
|
||||||
}
|
}
|
||||||
.alert("Logout", isPresented: $showingLogoutAlert) {
|
.alert("Logout", isPresented: $showingLogoutAlert) {
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
@ -211,22 +55,9 @@ struct SettingsServerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Quick Input Chip Component
|
#Preview {
|
||||||
|
List {
|
||||||
struct QuickInputChip: View {
|
SettingsServerView()
|
||||||
let text: String
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
Text(text)
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color(.systemGray5))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ struct readeckApp: App {
|
|||||||
if appViewModel.hasFinishedSetup {
|
if appViewModel.hasFinishedSetup {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
} else {
|
} else {
|
||||||
SettingsServerView()
|
OnboardingServerView()
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user