Settings sync, calendar visibility sync, event refresh & week-view fixes

- 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>
This commit is contained in:
Scarriffle
2026-05-27 20:44:14 +02:00
parent 07a9e9eb7f
commit 4125bfc728
16 changed files with 616 additions and 156 deletions

View File

@@ -195,9 +195,7 @@ struct AccountsView: View {
.foregroundStyle(.secondary)
Spacer()
Button(L10n.t("accounts.banished_unhide", appLang)) {
banishedKeys.remove(key)
CalendarStore.saveBanishedKeys(banishedKeys)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
unbanish(key)
}
.font(.callout)
.foregroundStyle(Color.accentColor)
@@ -208,6 +206,25 @@ struct AccountsView: View {
}
}
/// Re-show a banished calendar. For server-backed sources this clears the
/// server's sidebar_hidden (re-enabling the calendar); for local/ical it's
/// just the local set.
private func unbanish(_ key: String) {
banishedKeys.remove(key)
CalendarStore.saveBanishedKeys(banishedKeys)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
if let parsed = CalendarStore.parseCalendarKey(key),
CalendarStore.serverManagedSources.contains(parsed.source) {
// The server excluded this calendar's events while hidden, so they
// aren't in the cache. Re-enable on the server, then force a refetch
// so the events actually reappear without a manual sync.
Task {
try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: false)
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
}
}
}
private func resolveBanished(_ key: String) -> (name: String, colorHex: String) {
let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
guard parts.count == 2, let id = Int(parts[1]) else {
@@ -282,6 +299,22 @@ struct AccountsView: View {
async let g = (try? await api.getGoogleAccounts()) ?? []
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
// Reconcile banished list with the server's sidebar_hidden (server wins
// for CalDAV/Google/HA; local/ical keep their local state).
var b = banishedKeys
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
if hidden { b.insert(key) } else { b.remove(key) }
}
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
if b != banishedKeys {
banishedKeys = b
CalendarStore.saveBanishedKeys(b)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
}
isLoading = false
}

View File

@@ -8,6 +8,10 @@ struct CalendarHostView: View {
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("defaultView") private var defaultView = "month"
@Environment(\.scenePhase) private var scenePhase
@State private var store = CalendarStore()
@State private var showEditor = false
@@ -16,6 +20,7 @@ struct CalendarHostView: View {
@State private var selectedEvent: CalEvent? = nil
@State private var visibleMonth: Date = .now
@State private var showFilter = false
@State private var didApplyDefaultView = false
private var titleString: String {
if store.viewType == .month {
@@ -68,9 +73,16 @@ struct CalendarHostView: View {
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
applyServerDrivenSettings(initial: false)
}
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
}
// MARK: Liquid Glass variant
@@ -123,9 +135,16 @@ struct CalendarHostView: View {
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
applyServerDrivenSettings(initial: false)
}
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
}
// MARK: Top bar (flat mode)
@@ -322,12 +341,19 @@ struct CalendarHostView: View {
CalendarSheets(store: store, showEditor: $showEditor,
editorDate: $editorDate, editingEvent: $editingEvent,
selectedEvent: $selectedEvent, showFilter: $showFilter,
api: api, reload: { await onNavigate() })
api: api,
reload: { await onNavigate() },
reloadForce: { await reloadVisible(force: true) })
}
// MARK: Loading logic
private func startup() async {
// 0. Pull settings first so week-start / default-view are correct
// before we compute the initial range and load events.
await SettingsSync.pull(api: api)
applyServerDrivenSettings(initial: true)
await store.loadWritableCalendars(api: api)
// 1. Load current view immediately (visible)
let (s, e) = store.rangeForCurrentView()
@@ -336,6 +362,25 @@ struct CalendarHostView: View {
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
// 3. Periodic settings pull (tied to this .task's lifetime).
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(600))
if Task.isCancelled { break }
await SettingsSync.pull(api: api)
}
}
/// Apply the server-driven "always sync" settings to the live store.
/// `weekStartsOnMonday` is applied every time; the default view is applied
/// only once at startup so it never overrides the user's manual switches.
private func applyServerDrivenSettings(initial: Bool) {
store.weekStartsOnMonday = (weekStartDay != "sunday")
if initial, !didApplyDefaultView {
didApplyDefaultView = true
if let vt = CalViewType(rawValue: defaultView) {
store.viewType = vt
}
}
}
/// Called on every navigation instant if within cache, fetches otherwise.
@@ -344,12 +389,31 @@ struct CalendarHostView: View {
await store.loadEvents(api: api, start: s, end: e)
}
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
/// created/edited event shows up immediately (the server is authoritative).
private func reloadVisible(force: Bool) async {
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e, force: force)
}
/// Called when cacheMonths setting changes clear cache and re-prefetch.
private func recache() async {
store.invalidateCache()
await startup()
}
/// Manual sync from the menu: drop the event cache and re-fetch from the
/// server (the periodic loop in `startup()` is untouched, so we don't spawn
/// a second one). Settings were already pulled by the menu action.
private func forceReload() async {
store.invalidateCache()
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
}
/// Called when the user scrolls into a new month refreshes the visible range
/// immediately from cache, then fetches on demand if needed.
private func ensureLoaded(around month: Date) async {
@@ -380,19 +444,24 @@ private struct CalendarSheets: ViewModifier {
@Binding var showFilter: Bool
let api: CalendarrAPI
let reload: () async -> Void
let reloadForce: () async -> Void
func body(content: Content) -> some View {
content
.sheet(isPresented: $showEditor) {
EventEditorSheet(api: api, store: store,
initialDate: editorDate, editingEvent: editingEvent) {
editingEvent = nil; await reload()
// Create/edit changed server state bust the cache so the
// new/updated event appears without a manual sync.
editingEvent = nil; await reloadForce()
}
}
.sheet(item: $selectedEvent) { ev in
EventDetailSheet(event: ev, api: api, store: store) { updated in
selectedEvent = nil
if let u = updated { editingEvent = u; showEditor = true }
// Delete already removed the event from the cache optimistically;
// a light cache refresh is enough here.
await reload()
}
}

View File

@@ -5,14 +5,20 @@ struct DayView: View {
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
private var cal: Calendar { store.userCalendar }
private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) }
private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } }
private var allDayEvents: [CalEvent] {
store.events(on: store.currentDate).filter { $0.isAllDay || eventSpansMultipleDays($0) }
}
private var timedEvents: [CalEvent] {
store.events(on: store.currentDate).filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
}
var body: some View {
VStack(spacing: 0) {
@@ -97,7 +103,7 @@ struct DayView: View {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(Color(hex: textHex).opacity(0.6))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.offset(y: -6)
}
}
@@ -131,7 +137,8 @@ private struct DayHourSlot: View {
let language: String
let onCreateEvent: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("lineContrast") private var lineContrast = 3
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
@@ -139,7 +146,7 @@ private struct DayHourSlot: View {
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5)
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())

View File

@@ -40,6 +40,12 @@ struct EventDetailSheet: View {
event.source == "local" || event.source == "caldav"
}
/// Home Assistant events can't be edited in-app (no editor support), but
/// the server does support deleting them.
private var canDelete: Bool {
canEdit || event.source == "homeassistant"
}
var body: some View {
NavigationStack {
List {
@@ -86,7 +92,7 @@ struct EventDetailSheet: View {
}
}
if canEdit {
if canDelete {
Section {
Button(role: .destructive) {
showDeleteConfirm = true
@@ -115,7 +121,7 @@ struct EventDetailSheet: View {
}
}
}
.confirmationDialog("Termin löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
.alert("Termin löschen?", isPresented: $showDeleteConfirm) {
Button("Löschen", role: .destructive) {
Task { await deleteEvent() }
}
@@ -129,12 +135,20 @@ struct EventDetailSheet: View {
private func deleteEvent() async {
isDeleting = true
do {
if event.source == "local" {
switch event.source {
case "local":
try await api.deleteLocalEvent(uid: event.id)
} else {
case "homeassistant":
// calendarId looks like "homeassistant-42" numeric DB id 42
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
default:
let calId = Int(event.calendarId)
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
}
// Optimistically drop it from the cache so it vanishes immediately,
// regardless of how long the source takes to propagate the delete.
store.removeCachedEvent(id: event.id)
await onDone(nil)
} catch {
isDeleting = false

View File

@@ -26,6 +26,7 @@ struct MonthView: View {
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("textContrast") private var textContrast = 3
@State private var scrolledWeek: Date? = nil
@State private var didInitialScroll = false
@@ -98,7 +99,7 @@ struct MonthView: View {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.caption2.weight(.semibold))
.foregroundStyle(Color(hex: textHex).opacity(0.7))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
}
}
@@ -291,6 +292,9 @@ private struct DayCell: View {
let onShowWeek: () -> Void
let onShowDay: () -> Void
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3
private var cal: Calendar { Calendar.current }
private var dayNum: Int { cal.component(.day, from: date) }
private var isFirstOfMonth: Bool { dayNum == 1 }
@@ -330,14 +334,14 @@ private struct DayCell: View {
if extraCount > 0 {
Text("+\(extraCount)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(0.6))
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
.padding(.leading, 4)
}
Spacer(minLength: 0)
if let wn = weekNumber {
Text("\(cwLabel) \(wn)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(0.6))
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
.padding(.trailing, 4)
}
}
@@ -345,11 +349,11 @@ private struct DayCell: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5)
Rectangle().fill(lineColor.opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
.overlay(alignment: .top) {
Rectangle()
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3))
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(gridLineOpacity(lineContrast)))
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
}
.overlay(alignment: .bottom) {
@@ -377,6 +381,9 @@ private struct DayCell: View {
private struct EventBar: View {
let event: CalEvent
@AppStorage("dimPastEvents") private var dimPast = false
private var isPast: Bool { event.endDate < .now }
var body: some View {
HStack(spacing: 3) {
@@ -390,5 +397,6 @@ private struct EventBar: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(hex: event.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 3))
.opacity(dimPast && isPast ? 0.5 : 1.0)
}
}

View File

@@ -1,10 +1,54 @@
import SwiftUI
// Shared constants used by WeekView, DayView, EventEditorSheet
let hourHeight: CGFloat = 60
let timeColumnWidth: CGFloat = 44
let hours = Array(0..<24)
/// Live hour-row height, driven by the synced `hourHeight` setting.
/// Falls back to 60 when unset (fresh install / value 0). Views that lay out
/// against this also observe `@AppStorage("hourHeight")` so their body
/// re-renders when it changes.
var hourHeight: CGFloat {
let v = UserDefaults.standard.integer(forKey: "hourHeight")
return v > 0 ? CGFloat(v) : 60
}
/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"),
/// mapped from the 14 `textContrast` level. Level 3 the previous hard-coded
/// look so existing installs are visually unchanged.
func secondaryTextOpacity(_ level: Int) -> Double {
switch level {
case 1: return 0.4
case 2: return 0.55
case 4: return 1.0
default: return 0.75
}
}
/// Opacity for grid lines / separators, mapped from the 14 `lineContrast`
/// level. Level 3 the previous hard-coded ~0.4 look.
func gridLineOpacity(_ level: Int) -> Double {
switch level {
case 1: return 0.15
case 2: return 0.3
case 4: return 0.8
default: return 0.5
}
}
/// A timed (non-all-day) event that crosses a day boundary. Such events must
/// NOT be placed in the hourly grid their height would be `duration ×
/// hourHeight`, i.e. taller than the whole day, rendering as a giant block
/// (and, sharing one id across days, only drawing on the first day). They are
/// shown in the all-day strip instead, like all-day events.
func eventSpansMultipleDays(_ ev: CalEvent) -> Bool {
guard !ev.isAllDay, ev.endDate > ev.startDate else { return false }
let cal = Calendar.current
// End is exclusive: an event ending exactly at midnight is still single-day.
let lastInstant = ev.endDate.addingTimeInterval(-1)
return !cal.isDate(ev.startDate, inSameDayAs: lastInstant)
}
// Position helpers
func eventTop(_ ev: CalEvent) -> CGFloat {
let cal = Calendar.current
@@ -21,6 +65,9 @@ func eventHeight(_ ev: CalEvent) -> CGFloat {
// Shared event block used in WeekView and DayView
struct EventBlock: View {
let event: CalEvent
@AppStorage("dimPastEvents") private var dimPast = false
private var isPast: Bool { event.endDate < .now }
var body: some View {
RoundedRectangle(cornerRadius: 4)
@@ -41,5 +88,6 @@ struct EventBlock: View {
.padding(4)
}
.padding(.horizontal, 1)
.opacity(dimPast && isPast ? 0.5 : 1.0)
}
}

View File

@@ -7,10 +7,13 @@ struct WeekView: View {
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
private var cal: Calendar { store.userCalendar }
@@ -21,14 +24,16 @@ struct WeekView: View {
private var timedEvents: [(Int, CalEvent)] {
weekDays.enumerated().flatMap { idx, day in
store.events(on: day).filter { !$0.isAllDay }.map { (idx, $0) }
store.events(on: day)
.filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
.map { (idx, $0) }
}
}
private var allDayEvents: [CalEvent] {
let s = weekDays.first ?? .now
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
return store.events(in: s, end: e).filter(\.isAllDay)
return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) }
}
private var todayIndex: Int? {
@@ -56,10 +61,10 @@ struct WeekView: View {
ForEach(weekDays, id: \.self) { day in
Text(headerFmt.string(from: day).uppercased())
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7))
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.frame(maxWidth: .infinity, minHeight: 36)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5)
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
@@ -94,7 +99,7 @@ struct WeekView: View {
.padding(.horizontal, 1)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: 0.5)
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
@@ -127,7 +132,7 @@ struct WeekView: View {
}
.frame(width: colW)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5)
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
@@ -171,7 +176,7 @@ struct WeekView: View {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(Color(hex: textHex).opacity(0.6))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.offset(y: -6)
}
}
@@ -208,7 +213,8 @@ struct HourSlot: View {
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
@AppStorage("lineContrast") private var lineContrast = 3
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
@@ -216,7 +222,7 @@ struct HourSlot: View {
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5)
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())

View File

@@ -136,7 +136,9 @@ struct CalendarFilterSheet: View {
let isVisible = !hidden.contains(key)
Button {
if isVisible { hidden.insert(key) } else { hidden.remove(key) }
store.setCalendarHidden(key, hidden: !isVisible)
// New hidden state == was-visible (flip). Previous code passed the
// inverse, which persisted the opposite of what the UI showed.
store.setCalendarHidden(key, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
@@ -158,6 +160,7 @@ struct CalendarFilterSheet: View {
hidden.remove(key)
banished.insert(key)
store.setCalendarBanished(key, banished: true)
pushBanishToServer(key: key, hidden: true)
} label: {
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
}
@@ -175,6 +178,19 @@ struct CalendarFilterSheet: View {
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
// Reconcile banished state with the server's sidebar_hidden flags
// (server wins for CalDAV/Google/HA; local/ical keep their local state).
var b = store.banishedCalendarKeys
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
if hidden { b.insert(key) } else { b.remove(key) }
}
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
store.setBanishedCalendars(b)
banished = b
var keys = Set<String>()
for cal in localCalendars {
keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
@@ -200,4 +216,11 @@ struct CalendarFilterSheet: View {
allKeys = keys
isLoading = false
}
/// For server-backed sources, persist the banish on the server too.
private func pushBanishToServer(key: String, hidden: Bool) {
guard let parsed = CalendarStore.parseCalendarKey(key),
CalendarStore.serverManagedSources.contains(parsed.source) else { return }
Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) }
}
}

View File

@@ -5,6 +5,7 @@ struct MenuSheet: View {
@Environment(AppState.self) var appState
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var isSyncing = false
var body: some View {
NavigationStack {
@@ -68,6 +69,19 @@ struct MenuSheet: View {
}
}
Section(L10n.t("menu.sync.section", appLang)) {
Button {
Task { await syncNow() }
} label: {
HStack {
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
Spacer()
if isSyncing { ProgressView() }
}
}
.disabled(isSyncing)
}
Section {
Button(role: .destructive) {
dismiss()
@@ -88,4 +102,14 @@ struct MenuSheet: View {
}
}
}
/// Manual sync: pull appearance/behaviour settings from the server, then
/// ask the calendar host to re-fetch events (cache-busting).
private func syncNow() async {
isSyncing = true
await SettingsSync.pull(api: api)
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
isSyncing = false
dismiss()
}
}

View File

@@ -2,12 +2,8 @@ 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("settingsSync") private var settingsSync = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@@ -18,65 +14,52 @@ struct SettingsView: View {
@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 {
Group {
if isLoading {
ProgressView(L10n.t("settings.loading", appLang))
} else {
Form {
liquidGlassSection
cacheSection
spracheSection
farbenSection
schriftSection
linienSection
ansichtSection
stundenSection
}
}
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() }
// 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
@@ -97,10 +80,25 @@ struct SettingsView: View {
}
}
.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.liquidglass.footer", appLang))
Text(L10n.t("settings.sync.footer", appLang))
.font(.caption)
}
}
@@ -177,7 +175,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.textContrast,
value: $textContrast,
options: [
(1, L10n.t("settings.contrast.dark", appLang)),
(2, L10n.t("settings.contrast.medium", appLang)),
@@ -201,7 +199,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.lineContrast,
value: $lineContrast,
options: [
(1, L10n.t("settings.linecontrast.barely", appLang)),
(2, L10n.t("settings.linecontrast.subtle", appLang)),
@@ -218,18 +216,18 @@ struct SettingsView: View {
var ansichtSection: some View {
Section(L10n.t("settings.calview", appLang)) {
Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) {
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: $settings.weekStartDay) {
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: $settings.dimPastEvents)
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
.tint(Color.accentColor)
}
}
@@ -245,7 +243,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $settings.hourHeight,
value: $hourHeight,
options: [
(28, L10n.t("settings.hourheight.compact", appLang)),
(44, L10n.t("settings.hourheight.normal", appLang)),
@@ -258,53 +256,6 @@ struct SettingsView: View {
}
}
// 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