- 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>
94 lines
3.4 KiB
Swift
94 lines
3.4 KiB
Swift
import SwiftUI
|
||
|
||
// Shared constants used by WeekView, DayView, EventEditorSheet
|
||
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 1–4 `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 1–4 `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
|
||
let h = CGFloat(cal.component(.hour, from: ev.startDate))
|
||
let m = CGFloat(cal.component(.minute, from: ev.startDate))
|
||
return h * hourHeight + m * hourHeight / 60
|
||
}
|
||
|
||
func eventHeight(_ ev: CalEvent) -> CGFloat {
|
||
let dur = ev.endDate.timeIntervalSince(ev.startDate)
|
||
return max(CGFloat(dur / 3600) * hourHeight, 20)
|
||
}
|
||
|
||
// 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)
|
||
.fill(Color(hex: event.effectiveColor).opacity(0.85))
|
||
.overlay(alignment: .topLeading) {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(event.title)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(2)
|
||
if !event.location.isEmpty {
|
||
Text(event.location)
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.white.opacity(0.85))
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.padding(4)
|
||
}
|
||
.padding(.horizontal, 1)
|
||
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||
}
|
||
}
|