diff --git a/bookstax/Views/Settings/DisplayOptionsView.swift b/bookstax/Views/Settings/DisplayOptionsView.swift new file mode 100644 index 0000000..70c5fb6 --- /dev/null +++ b/bookstax/Views/Settings/DisplayOptionsView.swift @@ -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, hasCustom: Binding, 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() + } +}