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

@@ -24,6 +24,11 @@ struct AccountsView: View {
@State private var exportDoc: ExportedICS?
@State private var infoMessage: String?
// Contacts birthday-calendar sync (opt-in, bound to one birthday calendar).
@AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false
@AppStorage("birthdaysSyncCalendarId") private var birthdaysSyncCalendarId = 0
@State private var isSyncingBirthdays = false
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
@@ -36,10 +41,14 @@ struct AccountsView: View {
if !banishedKeys.isEmpty { banishedSection }
caldavSection
localSection
birthdayContactsSection
icalSection
googleSection
haSection
}
.onChange(of: birthdaysSyncEnabled) { _, on in
if on { Task { _ = await BirthdaysImporter.requestAccess() } }
}
}
}
.navigationTitle(L10n.t("accounts.title", appLang))
@@ -213,6 +222,52 @@ struct AccountsView: View {
}
}
private var birthdayCalendars: [LocalCalendar] {
localCalendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") }
}
@ViewBuilder var birthdayContactsSection: some View {
Section {
Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled)
if birthdaysSyncEnabled {
if birthdayCalendars.isEmpty {
Text(L10n.t("birthday.contacts.need_calendar", appLang))
.font(.caption).foregroundStyle(.secondary)
} else {
Picker(L10n.t("birthday.contacts.target", appLang), selection: $birthdaysSyncCalendarId) {
ForEach(birthdayCalendars) { c in Text(c.name).tag(c.id) }
}
Button {
Task { await syncBirthdays() }
} label: {
HStack {
Text(L10n.t("birthday.contacts.sync_now", appLang))
if isSyncingBirthdays { Spacer(); ProgressView() }
}
}
.disabled(isSyncingBirthdays || birthdaysSyncCalendarId == 0)
}
}
} header: {
Text(L10n.t("birthday.contacts.header", appLang))
} footer: {
Text(L10n.t("birthday.contacts.hint", appLang))
}
}
private func syncBirthdays() async {
isSyncingBirthdays = true
defer { isSyncingBirthdays = false }
guard await BirthdaysImporter.requestAccess() else {
errorAlert = L10n.t("birthday.contacts.denied", appLang)
return
}
await BirthdaysImporter.sync(api: api)
infoMessage = L10n.t("birthday.contacts.synced", appLang)
// Let the calendar refresh so imported birthdays show up right away.
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
}
var icalSection: some View {
Section {
if icalSubs.isEmpty {
@@ -419,6 +474,10 @@ struct AccountsView: View {
CalendarStore.saveBanishedKeys(b)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
}
// Default the Contacts-sync target to the first birthday calendar.
if birthdaysSyncCalendarId == 0, let first = birthdayCalendars.first?.id {
birthdaysSyncCalendarId = first
}
isLoading = false
}
@@ -536,6 +595,8 @@ struct AddLocalCalSheet: View {
@State private var name = ""
@State private var color = Color(hex: "#34a853")
@State private var isBirthday = false
@State private var notifyDays = -1 // -1 = off, 0 = on the day, N = days before
@State private var isLoading = false
@State private var error = ""
@@ -546,6 +607,14 @@ struct AddLocalCalSheet: View {
TextField(L10n.t("local.name", appLang), text: $name)
ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false)
}
Section {
Toggle(L10n.t("birthday.is_calendar", appLang), isOn: $isBirthday)
if isBirthday {
BirthdayNotifyPicker(days: $notifyDays, appLang: appLang)
}
} footer: {
if isBirthday { Text(L10n.t("birthday.is_calendar.desc", appLang)) }
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
@@ -568,7 +637,10 @@ struct AddLocalCalSheet: View {
private func save() async {
isLoading = true
do {
_ = try await api.addLocalCalendar(name: name, color: color.toHex())
_ = try await api.addLocalCalendar(
name: name, color: color.toHex(),
isBirthday: isBirthday,
birthdayNotifyDaysBefore: (isBirthday && notifyDays >= 0) ? notifyDays : nil)
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
@@ -576,6 +648,24 @@ struct AddLocalCalSheet: View {
}
}
/// Reusable "notify N days before" picker for birthday calendars.
/// -1 = off, 0 = on the day, N = N days before.
struct BirthdayNotifyPicker: View {
@Binding var days: Int
let appLang: String
var body: some View {
Picker(L10n.t("birthday.notify", appLang), selection: $days) {
Text(L10n.t("birthday.notify.off", appLang)).tag(-1)
Text(L10n.t("birthday.notify.same_day", appLang)).tag(0)
Text(L10n.t("birthday.notify.one_day", appLang)).tag(1)
ForEach([2, 3, 7], id: \.self) { d in
Text(String(format: L10n.t("birthday.notify.days", appLang), d)).tag(d)
}
}
}
}
struct AddICalSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void

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)