- LocalCalendar/CalEvent gain is_birthday + birthday fields; API sends rrule/external_uid/birth_year and can create/update birthday calendars - BirthdaysImporter: mirrors Contacts birthdays into a chosen birthday calendar, reconciling by external_uid (leaves manual entries untouched) - AccountsView: "birthday calendar" toggle + notify-days when creating a calendar, and a "Birthdays from Contacts" section (enable + target + sync now) - FAB long-press menu -> "New birthday" opens a minimal name+date mask (BirthdayEditorSheet) targeting birthday calendars, with year-unknown option - Event bars render display_title (age) and a cake icon via EventLabel - NSContactsUsageDescription added to both app build configs Co-Authored-By: Claude Opus 4.8 <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) {
|
||
EventLabel(event: event)
|
||
.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)
|
||
}
|
||
}
|