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

@@ -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())