- Add two-way settings sync (SettingsSync) with toggle, app-start/foreground/ 10-min pull and debounced push; server wins; view/week-start/dim-past always sync. Wire previously-ignored settings (hour height, contrasts, week start, default view, dim past) into the actual UI. - Make AppSettings decoding resilient (decodeIfPresent) so getSettings no longer fails on iOS-only fields the server omits; keep text/bg/line colors local-only; month divider/label colors now sync. - Auto-refresh after create/edit (cache-busting) and optimistic removal on delete; switch delete confirm to a centered alert. Add HA event deletion. - Calendar visibility: fix inverted hide/show toggle; normalize calendar keys so local filtering works for all sources; sync banish with server sidebar_hidden (CalDAV/Google/HA), refetch on un-banish. - Manual "sync with server" button in the menu. - Upcoming widget shows next 5 days (renamed). - Week/Day view: route multi-day timed events to the all-day strip so they no longer render as a full-height block. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
311 lines
13 KiB
Swift
311 lines
13 KiB
Swift
import SwiftUI
|
||
|
||
struct SettingsView: View {
|
||
let api: CalendarrAPI
|
||
@AppStorage("liquidGlass") private var liquidGlass = false
|
||
@AppStorage("settingsSync") private var settingsSync = 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"
|
||
// Previously server-only; now AppStorage-backed so they persist and the
|
||
// calendar views actually apply them.
|
||
@AppStorage("textContrast") private var textContrast = 3
|
||
@AppStorage("lineContrast") private var lineContrast = 3
|
||
@AppStorage("hourHeight") private var hourHeight = 60
|
||
@AppStorage("defaultView") private var defaultView = "month"
|
||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
liquidGlassSection
|
||
cacheSection
|
||
spracheSection
|
||
farbenSection
|
||
schriftSection
|
||
linienSection
|
||
ansichtSection
|
||
stundenSection
|
||
}
|
||
.navigationTitle(L10n.t("settings.title", appLang))
|
||
.navigationBarTitleDisplayMode(.large)
|
||
}
|
||
// Reflect the latest server values when opening the screen.
|
||
.task { await SettingsSync.pull(api: api) }
|
||
// Appearance changes update widgets live; synced values are also pushed
|
||
// to the server (debounced). `push` itself decides what actually gets
|
||
// sent based on the sync toggle, so every change can simply call it.
|
||
.onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||
.onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||
.onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||
.onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||
.onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||
.onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||
.onChange(of: dividerHex) { _, _ in SettingsSync.push(api: api) }
|
||
.onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) }
|
||
.onChange(of: textContrast) { _, _ in SettingsSync.push(api: api) }
|
||
.onChange(of: lineContrast) { _, _ in SettingsSync.push(api: api) }
|
||
.onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) }
|
||
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
|
||
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
|
||
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
|
||
.onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
|
||
// Enabling sync adopts the server's appearance (server wins).
|
||
.onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } }
|
||
}
|
||
|
||
// 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)
|
||
|
||
Toggle(isOn: $settingsSync) {
|
||
Label {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(L10n.t("settings.sync", appLang))
|
||
Text(L10n.t("settings.sync.desc", appLang))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
} icon: {
|
||
Image(systemName: "arrow.triangle.2.circlepath")
|
||
.foregroundStyle(.teal)
|
||
}
|
||
}
|
||
.tint(Color.accentColor)
|
||
} header: {
|
||
Text(L10n.t("settings.appdesign", appLang))
|
||
} footer: {
|
||
Text(L10n.t("settings.sync.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: $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: $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: $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: $weekStartDay) {
|
||
Text(L10n.t("settings.monday", appLang)).tag("monday")
|
||
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
|
||
}
|
||
Toggle(L10n.t("settings.dimpast", appLang), isOn: $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: $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: – 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)
|
||
}
|
||
}
|
||
}
|
||
}
|