Theme-Wechsel, Reconnect-Fehler
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DisplayOptionsView: View {
|
||||
let profile: ServerProfile
|
||||
|
||||
@Environment(ServerProfileStore.self) private var profileStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Local state — mirrors profile values, saved on every change
|
||||
@State private var appTheme: String = "system"
|
||||
@State private var accentThemeRaw: String = AccentTheme.ocean.rawValue
|
||||
@State private var appTextColor: Color = .primary
|
||||
@State private var hasCustomTextColor: Bool = false
|
||||
@State private var appBgColor: Color = Color(.systemBackground)
|
||||
@State private var hasCustomBgColor: Bool = false
|
||||
@State private var editorFontSize: Double = 16
|
||||
@State private var readerFontSize: Double = 16
|
||||
@State private var showResetConfirm = false
|
||||
@State private var showResetAppearanceConfirm = false
|
||||
@AppStorage("displayOptionsInfoSeen") private var infoSeen = false
|
||||
@State private var showInfoAlert = false
|
||||
|
||||
private var selectedTheme: AccentTheme {
|
||||
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
hintSection
|
||||
appearanceSection
|
||||
editorSection
|
||||
readerSection
|
||||
resetSection
|
||||
previewSection
|
||||
}
|
||||
.navigationTitle(L("display.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(L("display.reset.all"), role: .destructive) {
|
||||
showResetConfirm = true
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(L("display.reset.all.confirm"),
|
||||
isPresented: $showResetConfirm,
|
||||
titleVisibility: .visible) {
|
||||
Button(L("display.reset.all.button"), role: .destructive) { resetAll() }
|
||||
Button(L("common.cancel"), role: .cancel) {}
|
||||
}
|
||||
.confirmationDialog(L("display.reset.appearance.confirm"),
|
||||
isPresented: $showResetAppearanceConfirm,
|
||||
titleVisibility: .visible) {
|
||||
Button(L("display.reset.all.button"), role: .destructive) { resetAppearance() }
|
||||
Button(L("common.cancel"), role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
loadFromProfile()
|
||||
if !infoSeen {
|
||||
showInfoAlert = true
|
||||
infoSeen = true
|
||||
}
|
||||
}
|
||||
.alert(L("display.info.title"), isPresented: $showInfoAlert) {
|
||||
Button(L("common.ok"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(L("display.info.message"))
|
||||
}
|
||||
.onChange(of: appTheme) { persist() }
|
||||
.onChange(of: accentThemeRaw) { persist() }
|
||||
.onChange(of: appTextColor) { persist() }
|
||||
.onChange(of: hasCustomTextColor) { persist() }
|
||||
.onChange(of: appBgColor) { persist() }
|
||||
.onChange(of: hasCustomBgColor) { persist() }
|
||||
.onChange(of: editorFontSize) { persist() }
|
||||
.onChange(of: readerFontSize) { persist() }
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var hintSection: some View {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.top, 2)
|
||||
Text(String(format: L("display.hint"), profile.name))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.listRowBackground(Color(.secondarySystemBackground))
|
||||
}
|
||||
}
|
||||
|
||||
private var appearanceSection: some View {
|
||||
Section(L("settings.appearance")) {
|
||||
// Light / Dark / System picker
|
||||
Picker(L("settings.appearance.theme"), selection: $appTheme) {
|
||||
Text(L("settings.appearance.theme.system")).tag("system")
|
||||
Text(L("settings.appearance.theme.light")).tag("light")
|
||||
Text(L("settings.appearance.theme.dark")).tag("dark")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
// Accent colour swatches
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(L("settings.appearance.accent"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 8), spacing: 10) {
|
||||
ForEach(AccentTheme.allCases) { theme in
|
||||
Button {
|
||||
accentThemeRaw = theme.rawValue
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(theme.shelfColor)
|
||||
.frame(width: 32, height: 32)
|
||||
if selectedTheme == theme {
|
||||
Circle()
|
||||
.strokeBorder(.white, lineWidth: 2)
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(theme.displayName)
|
||||
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// App text colour
|
||||
colorRow(
|
||||
label: L("display.app.textcolor"),
|
||||
color: $appTextColor,
|
||||
hasCustom: $hasCustomTextColor,
|
||||
resetColor: .primary
|
||||
)
|
||||
|
||||
// App background colour
|
||||
colorRow(
|
||||
label: L("display.app.bgcolor"),
|
||||
color: $appBgColor,
|
||||
hasCustom: $hasCustomBgColor,
|
||||
resetColor: Color(.systemBackground)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var editorSection: some View {
|
||||
Section(L("display.editor")) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(L("display.editor.fontsize"))
|
||||
Spacer()
|
||||
Text(String(format: L("display.fontsize.pt"), editorFontSize))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Slider(value: $editorFontSize, in: 10...28, step: 1)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var readerSection: some View {
|
||||
Section(L("display.reader")) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(L("display.reader.fontsize"))
|
||||
Spacer()
|
||||
Text(String(format: L("display.fontsize.pt"), readerFontSize))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Slider(value: $readerFontSize, in: 10...28, step: 1)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func colorRow(label: String, color: Binding<Color>, hasCustom: Binding<Bool>, resetColor: Color) -> some View {
|
||||
HStack {
|
||||
if hasCustom.wrappedValue {
|
||||
ColorPicker(label, selection: color, supportsOpacity: false)
|
||||
Button {
|
||||
hasCustom.wrappedValue = false
|
||||
color.wrappedValue = resetColor
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Text(label)
|
||||
Spacer()
|
||||
Text(L("display.color.system"))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.footnote)
|
||||
Button {
|
||||
hasCustom.wrappedValue = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resetSection: some View {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showResetAppearanceConfirm = true
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(L("display.reset.appearance"))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
Section(L("display.preview")) {
|
||||
previewCard
|
||||
.listRowInsets(.init(top: 12, leading: 12, bottom: 12, trailing: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Card
|
||||
|
||||
private var previewCard: some View {
|
||||
let bgColor: Color = hasCustomBgColor ? appBgColor : Color(.systemBackground)
|
||||
let textColor: Color = hasCustomTextColor ? appTextColor : Color(.label)
|
||||
let fontSize = CGFloat(readerFontSize)
|
||||
let editorSize = CGFloat(editorFontSize)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
// App / Reader preview
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L("display.preview.reader"), systemImage: "doc.text")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 2)
|
||||
|
||||
Text("Sample Heading")
|
||||
.font(.system(size: fontSize + 4, weight: .bold))
|
||||
.foregroundStyle(textColor)
|
||||
Text("This is a preview of how your pages will look in the reader.")
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundStyle(textColor)
|
||||
.lineSpacing(fontSize * 0.3)
|
||||
Text("let x = 42")
|
||||
.font(.system(size: fontSize - 2, design: .monospaced))
|
||||
.foregroundStyle(textColor.opacity(0.85))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(textColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(bgColor, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color(.separator), lineWidth: 0.5))
|
||||
|
||||
// Editor preview
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L("display.preview.editor"), systemImage: "pencil")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 2)
|
||||
|
||||
Text("## My Page\n\nStart writing your **Markdown** here…")
|
||||
.font(.system(size: editorSize, design: .monospaced))
|
||||
.foregroundStyle(Color(.label))
|
||||
.lineSpacing(editorSize * 0.2)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color(.separator), lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func loadFromProfile() {
|
||||
let p = profileStore.profiles.first { $0.id == profile.id } ?? profile
|
||||
appTheme = p.appTheme
|
||||
accentThemeRaw = p.accentTheme
|
||||
editorFontSize = p.editorFontSize
|
||||
readerFontSize = p.readerFontSize
|
||||
if let hex = p.appTextColor, let c = Color(hex: hex) {
|
||||
appTextColor = c
|
||||
hasCustomTextColor = true
|
||||
}
|
||||
if let hex = p.appBackgroundColor, let c = Color(hex: hex) {
|
||||
appBgColor = c
|
||||
hasCustomBgColor = true
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let textHex = hasCustomTextColor ? appTextColor.toHexString() : nil
|
||||
let bgHex = hasCustomBgColor ? appBgColor.toHexString() : nil
|
||||
profileStore.updateDisplayOptions(
|
||||
for: profile,
|
||||
editorFontSize: editorFontSize,
|
||||
readerFontSize: readerFontSize,
|
||||
appTextColor: textHex,
|
||||
appBackgroundColor: bgHex,
|
||||
appTheme: appTheme,
|
||||
accentTheme: accentThemeRaw
|
||||
)
|
||||
}
|
||||
|
||||
private func resetAppearance() {
|
||||
appTheme = "system"
|
||||
accentThemeRaw = AccentTheme.ocean.rawValue
|
||||
hasCustomTextColor = false
|
||||
hasCustomBgColor = false
|
||||
appTextColor = .primary
|
||||
appBgColor = Color(.systemBackground)
|
||||
persist()
|
||||
}
|
||||
|
||||
private func resetAll() {
|
||||
appTheme = "system"
|
||||
accentThemeRaw = AccentTheme.ocean.rawValue
|
||||
hasCustomTextColor = false
|
||||
hasCustomBgColor = false
|
||||
appTextColor = .primary
|
||||
appBgColor = Color(.systemBackground)
|
||||
editorFontSize = 16
|
||||
readerFontSize = 16
|
||||
persist()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user