Files
Calendarr-IOS/Calendarr iOS/Views/SettingsView.swift
2026-05-25 11:53:02 +02:00

360 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct SettingsView: View {
let api: CalendarrAPI
@State private var settings = AppSettings()
@State private var isLoading = true
@State private var isSaving = false
@State private var toast = ""
@State private var showToast = false
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("primaryColor") private var primaryHex = "#4285f4"
@AppStorage("accentColor") private var accentHex = "#ea4335"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView(L10n.t("settings.loading", appLang))
} else {
Form {
liquidGlassSection
cacheSection
spracheSection
farbenSection
schriftSection
linienSection
ansichtSection
stundenSection
}
}
}
.navigationTitle(L10n.t("settings.title", appLang))
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await save() }
} label: {
if isSaving {
ProgressView()
} else {
Text(L10n.t("settings.save", appLang)).bold()
}
}
.disabled(isSaving)
}
}
.overlay(alignment: .bottom) {
if showToast {
Text(toast)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.bottom, 20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut, value: showToast)
}
.task { await load() }
// Live-update widgets the moment any appearance value changes, so the
// user sees the new colours without having to wait for the next event
// sync or save the settings.
.onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly() }
.onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly() }
.onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly() }
.onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly() }
.onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly() }
.onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly() }
.onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
}
// MARK: Liquid Glass
var liquidGlassSection: some View {
Section {
Toggle(isOn: $liquidGlass) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.liquidglass", appLang))
Text(L10n.t("settings.liquidglass.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "sparkles")
.foregroundStyle(.blue)
}
}
.tint(Color.accentColor)
} header: {
Text(L10n.t("settings.appdesign", appLang))
} footer: {
Text(L10n.t("settings.liquidglass.footer", appLang))
.font(.caption)
}
}
// MARK: Cache
var cacheSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.cache.title", appLang))
Text(L10n.t("settings.cache.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.down.circle")
.foregroundStyle(.green)
}
Picker(L10n.t("settings.cache.range", appLang), selection: $cacheMonths) {
Text(L10n.t("settings.cache.1m", appLang)).tag(1)
Text(L10n.t("settings.cache.3m", appLang)).tag(3)
Text(L10n.t("settings.cache.6m", appLang)).tag(6)
Text(L10n.t("settings.cache.1y", appLang)).tag(12)
}
.pickerStyle(.segmented)
}
.padding(.vertical, 4)
} header: {
Text(L10n.t("settings.cache.header", appLang))
} footer: {
Text(L10n.t("settings.cache.footer", appLang))
.font(.caption)
}
}
// MARK: Sprache
var spracheSection: some View {
Section(L10n.t("settings.language", appLang)) {
Picker(L10n.t("settings.language", appLang), selection: $appLang) {
Text(L10n.t("lang.system", appLang)).tag("system")
Text(L10n.t("lang.german", appLang)).tag("de")
Text(L10n.t("lang.english", appLang)).tag("en")
}
}
}
// MARK: Farben
var farbenSection: some View {
Section(L10n.t("settings.colors", appLang)) {
ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $primaryHex)
ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $accentHex)
ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex)
ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex)
ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex)
ColorPickerRow(label: L10n.t("settings.color.line", appLang), hex: $lineHex)
ColorPickerRow(label: L10n.t("settings.color.divider", appLang), hex: $dividerHex)
ColorPickerRow(label: L10n.t("settings.color.label", appLang), hex: $labelHex)
}
}
// MARK: Schriftkontrast
var schriftSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(L10n.t("settings.textcontrast", appLang))
.font(.headline)
Text(L10n.t("settings.textcontrast.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.textContrast,
options: [
(1, L10n.t("settings.contrast.dark", appLang)),
(2, L10n.t("settings.contrast.medium", appLang)),
(3, L10n.t("settings.contrast.bright", appLang)),
(4, L10n.t("settings.contrast.max", appLang))
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Linienkontrast
var linienSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(L10n.t("settings.linecontrast", appLang))
.font(.headline)
Text(L10n.t("settings.linecontrast.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.lineContrast,
options: [
(1, L10n.t("settings.linecontrast.barely", appLang)),
(2, L10n.t("settings.linecontrast.subtle", appLang)),
(3, L10n.t("settings.linecontrast.normal", appLang)),
(4, L10n.t("settings.linecontrast.strong", appLang))
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Ansicht
var ansichtSection: some View {
Section(L10n.t("settings.calview", appLang)) {
Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) {
Text(L10n.t("view.month", appLang)).tag("month")
Text(L10n.t("view.week", appLang)).tag("week")
Text(L10n.t("view.day", appLang)).tag("day")
Text(L10n.t("view.quarter", appLang)).tag("quarter")
Text(L10n.t("view.agenda", appLang)).tag("agenda")
}
Picker(L10n.t("settings.firstweekday", appLang), selection: $settings.weekStartDay) {
Text(L10n.t("settings.monday", appLang)).tag("monday")
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
}
Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents)
.tint(Color.accentColor)
}
}
// MARK: Stundenhöhe
var stundenSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(L10n.t("settings.hourheight", appLang))
.font(.headline)
Text(L10n.t("settings.hourheight.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.hourHeight,
options: [
(28, L10n.t("settings.hourheight.compact", appLang)),
(44, L10n.t("settings.hourheight.normal", appLang)),
(60, L10n.t("settings.hourheight.comfort", appLang)),
(80, L10n.t("settings.hourheight.large", appLang))
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Actions
private func load() async {
isLoading = true
defer { isLoading = false }
if let s = try? await api.getSettings() {
settings = s
// Mirror server-side color settings so calendar views (which read AppStorage) see them.
dividerHex = s.monthDividerColor
labelHex = s.monthLabelColor
todayHex = s.todayColor
textHex = s.textColor
bgHex = s.backgroundColor
lineHex = s.lineColor
primaryHex = s.primaryColor
accentHex = s.accentColor
}
}
private func save() async {
isSaving = true
defer { isSaving = false }
// Push local AppStorage colors back into the settings struct before saving.
settings.monthDividerColor = dividerHex
settings.monthLabelColor = labelHex
settings.todayColor = todayHex
settings.textColor = textHex
settings.backgroundColor = bgHex
settings.lineColor = lineHex
settings.primaryColor = primaryHex
settings.accentColor = accentHex
do {
try await api.updateSettings(settings)
showNotice(L10n.t("settings.saved", appLang))
} catch {
showNotice(error.localizedDescription)
}
}
private func showNotice(_ msg: String) {
toast = msg
withAnimation { showToast = true }
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation { showToast = false }
}
}
}
// MARK: Reusable Components
struct ColorPickerRow: View {
let label: String
@Binding var hex: String
var color: Binding<Color> {
Binding(
get: { Color(hex: hex) },
set: { hex = $0.toHex() }
)
}
var body: some View {
HStack {
Text(label)
Spacer()
ColorPicker("", selection: color, supportsOpacity: false)
.labelsHidden()
Text(hex.uppercased())
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 68, alignment: .trailing)
}
}
}
struct ContrastSelector<T: Hashable & Equatable>: View {
@Binding var value: T
let options: [(T, String)]
var body: some View {
HStack(spacing: 8) {
ForEach(Array(options.enumerated()), id: \.offset) { _, opt in
Button {
value = opt.0
} label: {
Text(opt.1)
.font(.caption.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(value == opt.0 ? Color.accentColor : Color(.systemGray5))
.foregroundStyle(value == opt.0 ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
}