Add birthday feature (iOS)

- 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>
This commit is contained in:
Scarriffle
2026-07-12 21:58:31 +02:00
parent e55e6c7c92
commit cf990e4279
15 changed files with 527 additions and 19 deletions

View File

@@ -79,7 +79,7 @@ private struct AgendaEventRow: View {
.frame(width: 4, height: 40)
VStack(alignment: .leading, spacing: 3) {
Text(event.title)
EventLabel(event: event)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 6) {

View File

@@ -0,0 +1,104 @@
import SwiftUI
/// Minimal "new birthday" mask: pick a birthday calendar, enter a name and a
/// date (day + month, optionally a year). Saves an all-day, yearly-recurring
/// local event; the server adds the age suffix and cake icon on read.
struct BirthdayEditorSheet: View {
let api: CalendarrAPI
var onDone: () async -> Void
@AppStorage("appLanguage") private var appLang = "system"
@Environment(\.dismiss) private var dismiss
@State private var calendars: [LocalCalendar] = []
@State private var selectedCalId: Int? = nil
@State private var name = ""
@State private var date = Date()
@State private var yearUnknown = false
@State private var loading = true
@State private var saving = false
private var birthdayCalendars: [LocalCalendar] {
calendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") }
}
private var canSave: Bool {
!saving && selectedCalId != nil
&& !name.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
if loading {
HStack { Spacer(); ProgressView(); Spacer() }
} else if birthdayCalendars.isEmpty {
Text(L10n.t("birthday.no_calendars", appLang))
.foregroundStyle(.secondary)
} else {
Section(L10n.t("birthday.person", appLang)) {
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
}
Section(L10n.t("birthday.date", appLang)) {
DatePicker(L10n.t("birthday.date", appLang), selection: $date,
displayedComponents: [.date])
Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown)
}
if birthdayCalendars.count > 1 {
Section(L10n.t("birthday.contacts.target", appLang)) {
Picker(L10n.t("birthday.contacts.target", appLang), selection: $selectedCalId) {
ForEach(birthdayCalendars) { c in
Text(c.name).tag(Optional(c.id))
}
}
}
}
}
}
.navigationTitle(L10n.t("birthday.new", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }
.disabled(!canSave)
}
}
.task { await load() }
}
}
private func load() async {
calendars = (try? await api.getLocalCalendars()) ?? []
if selectedCalId == nil { selectedCalId = birthdayCalendars.first?.id }
loading = false
}
private func save() async {
guard let calId = selectedCalId else { return }
saving = true
let cal = Calendar.current
let comps = cal.dateComponents([.year, .month, .day], from: date)
let month = comps.month ?? 1
let day = comps.day ?? 1
let year = yearUnknown ? nil : comps.year
// All-day anchor at local noon; anchor year = birth year (else 1970) so
// the yearly rule expands across any queried range.
var anchor = DateComponents()
anchor.year = year ?? 1970
anchor.month = month
anchor.day = day
anchor.hour = 12
let start = cal.date(from: anchor) ?? date
let end = cal.date(byAdding: .day, value: 1, to: start) ?? start
_ = try? await api.createLocalEvent(
calendarId: calId, title: name.trimmingCharacters(in: .whitespaces),
start: start, end: end, isAllDay: true, location: "", description: "",
color: nil, rrule: "FREQ=YEARLY", birthYear: year
)
await onDone()
dismiss()
}
}

View File

@@ -30,6 +30,7 @@ struct CalendarHostView: View {
@State private var showFilter = false
@State private var didApplyDefaultView = false
@State private var groups: [CalGroup] = []
@State private var showNewBirthday = false
private var titleString: String {
if store.viewType == .month {
@@ -380,6 +381,21 @@ struct CalendarHostView: View {
// MARK: FAB buttons
/// Long-press menu on the create button: a plain new event, or a new birthday
/// (which opens a minimal name + date mask that only targets birthday calendars).
@ViewBuilder private var fabMenu: some View {
Button {
editorContext = .create(.now)
} label: {
Label(L10n.t("event.new_title", appLang), systemImage: "plus")
}
Button {
showNewBirthday = true
} label: {
Label(L10n.t("birthday.new", appLang), systemImage: "birthday.cake.fill")
}
}
/// Standard solid FAB (flat mode)
private var solidFAB: some View {
Button {
@@ -393,6 +409,7 @@ struct CalendarHostView: View {
.clipShape(Circle())
.shadow(radius: 4, y: 2)
}
.contextMenu { fabMenu }
.padding(.trailing, 20).padding(.bottom, 20)
}
@@ -410,6 +427,7 @@ struct CalendarHostView: View {
}
.buttonStyle(.plain)
.glassEffect(in: Circle())
.contextMenu { fabMenu }
.padding(.trailing, 20).padding(.bottom, 20)
} else {
solidFAB
@@ -421,6 +439,7 @@ struct CalendarHostView: View {
private var calendarSheets: CalendarSheets {
CalendarSheets(store: store, editorContext: $editorContext,
selectedEvent: $selectedEvent, showFilter: $showFilter,
showNewBirthday: $showNewBirthday,
api: api,
reload: { await onNavigate() },
reloadForce: { await reloadVisible(force: true) })
@@ -449,6 +468,14 @@ struct CalendarHostView: View {
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
// 2b. Mirror Contacts birthdays into the bound birthday calendar, if the
// user enabled it, then refresh so new birthdays appear immediately.
if BirthdaysImporter.isEnabled {
Task(priority: .background) {
await BirthdaysImporter.sync(api: api)
await forceReload()
}
}
// 3. Periodic settings + visibility pull (tied to this .task's lifetime).
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(600))
@@ -538,12 +565,16 @@ private struct CalendarSheets: ViewModifier {
@Binding var editorContext: CalEditorContext?
@Binding var selectedEvent: CalEvent?
@Binding var showFilter: Bool
@Binding var showNewBirthday: Bool
let api: CalendarrAPI
let reload: () async -> Void
let reloadForce: () async -> Void
func body(content: Content) -> some View {
content
.sheet(isPresented: $showNewBirthday) {
BirthdayEditorSheet(api: api) { await reloadForce() }
}
// Use sheet(item:) so the editing event is captured atomically
// avoiding the race where sheet(isPresented:) evaluates its content
// before the editingEvent state update propagates.

View File

@@ -81,7 +81,7 @@ struct DayView: View {
HStack(spacing: 6) {
ForEach(allDayEvents) { ev in
Button(action: { onEventTap(ev) }) {
Text(ev.title)
EventLabel(event: ev)
.font(.caption.weight(.medium))
.foregroundStyle(.white)
.padding(.horizontal, 8).padding(.vertical, 4)

View File

@@ -62,7 +62,7 @@ struct EventDetailSheet: View {
.fill(Color(hex: event.effectiveColor))
.frame(width: 6, height: 44)
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
Text(event.renderTitle)
.font(.title3.bold())
Text(event.calendarName)
.font(.caption)
@@ -156,7 +156,7 @@ struct EventDetailSheet: View {
}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text("\"\(event.title)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
Text("\"\(event.renderTitle)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
}
.sheet(isPresented: $showCopySheet) {
EventEditorSheet(

View File

@@ -519,7 +519,7 @@ private struct DayPreviewAllDayBar: View {
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
let cLeft = event.startDate < dayStart
let cRight = event.endDate > dayEnd // endDate is exclusive for allDay
Text(event.title)
EventLabel(event: event)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
@@ -580,7 +580,7 @@ private struct DayPreviewTimedRow: View {
.font(.system(size: 11))
.foregroundStyle(.secondary)
.frame(width: 44, alignment: .leading)
Text(event.title)
EventLabel(event: event)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
@@ -604,7 +604,7 @@ private struct EventBar: View {
var body: some View {
HStack(spacing: 3) {
Text(event.title)
EventLabel(event: event)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)
.foregroundStyle(.white)

View File

@@ -74,7 +74,7 @@ struct EventBlock: View {
.fill(Color(hex: event.effectiveColor).opacity(0.85))
.overlay(alignment: .topLeading) {
VStack(alignment: .leading, spacing: 1) {
Text(event.title)
EventLabel(event: event)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(2)

View File

@@ -84,7 +84,7 @@ struct WeekView: View {
VStack(spacing: 1) {
ForEach(dayEvs.prefix(2)) { ev in
Button { onEventTap(ev) } label: {
Text(ev.title)
EventLabel(event: ev)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)