Files
Calendarr-IOS/Calendarr iOS/Views/SettingsView.swift
2026-05-17 08:32:34 +02:00

317 lines
10 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
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Lade Einstellungen…")
} else {
Form {
liquidGlassSection
cacheSection
spracheSection
farbenSection
schriftSection
linienSection
ansichtSection
stundenSection
}
}
}
.navigationTitle("Darstellung")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await save() }
} label: {
if isSaving {
ProgressView()
} else {
Text("Speichern").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() }
}
// MARK: Liquid Glass
var liquidGlassSection: some View {
Section {
Toggle(isOn: $liquidGlass) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Liquid Glass")
Text("Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "sparkles")
.foregroundStyle(.blue)
}
}
.tint(Color.accentColor)
} header: {
Text("App-Design")
} footer: {
Text("Änderung wirkt sofort kein Neustart nötig.")
.font(.caption)
}
}
// MARK: Cache
var cacheSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Vorladen")
Text("Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.down.circle")
.foregroundStyle(.green)
}
Picker("Zeitraum", selection: $cacheMonths) {
Text("±1 Monat").tag(1)
Text("±3 Monate").tag(3)
Text("±6 Monate").tag(6)
Text("±1 Jahr").tag(12)
}
.pickerStyle(.segmented)
}
.padding(.vertical, 4)
} header: {
Text("Vorladen")
} footer: {
Text("Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.")
.font(.caption)
}
}
// MARK: Sprache
var spracheSection: some View {
Section("Sprache") {
Picker("Sprache", selection: $settings.language) {
Text("Deutsch").tag("de")
Text("English").tag("en")
}
}
}
// MARK: Farben
var farbenSection: some View {
Section("Farben") {
ColorPickerRow(label: "Primärfarbe", hex: $settings.primaryColor)
ColorPickerRow(label: "Akzentfarbe", hex: $settings.accentColor)
ColorPickerRow(label: "Heutige-Tag-Farbe", hex: $settings.todayColor)
ColorPickerRow(label: "Monatswechsel-Linie", hex: $settings.monthDividerColor)
ColorPickerRow(label: "Monatskürzel", hex: $settings.monthLabelColor)
}
}
// MARK: Schriftkontrast
var schriftSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Schriftkontrast")
.font(.headline)
Text("Helligkeit der Beschriftungen und Texte")
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.textContrast,
options: [
(1, "Dunkel"),
(2, "Mittel"),
(3, "Hell"),
(4, "Maximum")
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Linienkontrast
var linienSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Linienkontrast")
.font(.headline)
Text("Sichtbarkeit von Trennlinien und Rahmen")
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.lineContrast,
options: [
(1, "Kaum"),
(2, "Subtil"),
(3, "Normal"),
(4, "Stark")
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Ansicht
var ansichtSection: some View {
Section("Kalenderansicht") {
Picker("Standardansicht", selection: $settings.defaultView) {
Text("Monat").tag("month")
Text("Woche").tag("week")
Text("Tag").tag("day")
Text("Quartal").tag("quarter")
Text("Termine").tag("agenda")
}
Picker("Erster Wochentag", selection: $settings.weekStartDay) {
Text("Montag").tag("monday")
Text("Sonntag").tag("sunday")
}
Toggle("Vergangene Termine ausgrauen", isOn: $settings.dimPastEvents)
.tint(Color.accentColor)
}
}
// MARK: Stundenhöhe
var stundenSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Stundenhöhe")
.font(.headline)
Text("Platz pro Stunde in der Wochen- & Tagesansicht")
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.hourHeight,
options: [
(28, "Kompakt"),
(44, "Normal"),
(60, "Komfort"),
(80, "Gross")
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Actions
private func load() async {
isLoading = true
defer { isLoading = false }
if let s = try? await api.getSettings() { settings = s }
}
private func save() async {
isSaving = true
defer { isSaving = false }
do {
try await api.updateSettings(settings)
showNotice("Gespeichert")
} 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)
}
}
}
}