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

@@ -114,11 +114,17 @@ struct LocalCalendar: Codable, Identifiable {
var permission: String? = nil
var group: Bool = false
var remindersEnabled: Bool = true
// Birthday calendar: events are all-day yearly; server renders age + cake icon.
var isBirthday: Bool = false
// Days before a birthday to remind (0 = on the day). nil = no reminder.
var birthdayNotifyDaysBefore: Int? = nil
enum CodingKeys: String, CodingKey {
case id, name, color, enabled, owned, permission, group
case sharedBy = "shared_by"
case remindersEnabled = "reminders_enabled"
case isBirthday = "is_birthday"
case birthdayNotifyDaysBefore = "birthday_notify_days_before"
}
init(from decoder: Decoder) throws {
@@ -132,6 +138,8 @@ struct LocalCalendar: Codable, Identifiable {
permission = try c.decodeIfPresent(String.self, forKey: .permission)
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
isBirthday = try c.decodeIfPresent(Bool.self, forKey: .isBirthday) ?? false
birthdayNotifyDaysBefore = try c.decodeIfPresent(Int.self, forKey: .birthdayNotifyDaysBefore)
}
}

View File

@@ -67,10 +67,17 @@ struct CalEvent: Identifiable, Hashable {
var reminders: [Int] = []
// True for events from a calendar shared with the user read-only.
var readOnly: Bool = false
// True for events from a birthday calendar clients show a cake icon and the
// server bakes the age into `displayTitle`.
var isBirthday: Bool = false
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
var effectiveColor: String { displayColor ?? color ?? calendarColor }
// Title to render: the server-decorated one (birthday age, group prefix) wins
// over the raw title, which is kept for editing.
var renderTitle: String { displayTitle ?? title }
static func from(json: [String: Any]) -> CalEvent? {
guard
let title = json["title"] as? String,
@@ -110,7 +117,8 @@ struct CalEvent: Identifiable, Hashable {
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
readOnly: json["read_only"] as? Bool ?? false
readOnly: json["read_only"] as? Bool ?? false,
isBirthday: json["is_birthday"] as? Bool ?? false
)
}
}
@@ -174,3 +182,21 @@ func formatISO(_ date: Date, allDay: Bool) -> String {
}
return isoBasic.string(from: date)
}
/// Inline label for an event in a calendar grid: a leading birthday (cake) icon
/// when the event is a birthday, followed by the render title (which carries the
/// server-computed age). Icon and text inherit the surrounding font/colour, so
/// the label matches whatever bar it's dropped into.
struct EventLabel: View {
let event: CalEvent
var body: some View {
if event.isBirthday {
HStack(spacing: 3) {
Image(systemName: "birthday.cake.fill").imageScale(.small)
Text(event.renderTitle)
}
} else {
Text(event.renderTitle)
}
}
}

View File

@@ -345,6 +345,29 @@ private let strings: [String: [String: String]] = [
"local.color": "Farbe",
"local.create": "Erstellen",
// Birthdays
"birthday.new": "Neuer Geburtstag",
"birthday.person": "Name",
"birthday.person_placeholder": "Name der Person",
"birthday.date": "Geburtstag",
"birthday.year_unknown": "Jahr unbekannt",
"birthday.no_calendars": "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen unter „Konten & Kalender“.",
"birthday.is_calendar": "Geburtstagskalender",
"birthday.is_calendar.desc": "Ganztägige, jährliche Termine mit Alter und Geburtstags-Icon.",
"birthday.notify": "Erinnerung",
"birthday.notify.off": "Aus",
"birthday.notify.same_day": "Am Tag",
"birthday.notify.one_day": "1 Tag vorher",
"birthday.notify.days": "%d Tage vorher",
"birthday.contacts.header": "Geburtstage aus Kontakten",
"birthday.contacts.sync": "Aus Kontakten synchronisieren",
"birthday.contacts.target": "Zielkalender",
"birthday.contacts.sync_now": "Jetzt synchronisieren",
"birthday.contacts.synced": "Geburtstage synchronisiert",
"birthday.contacts.hint": "Überträgt Geburtstage aus deinen Kontakten in den gewählten Geburtstagskalender sichtbar auf allen Geräten.",
"birthday.contacts.denied": "Kein Zugriff auf Kontakte. Bitte in den iOS-Einstellungen erlauben.",
"birthday.contacts.need_calendar": "Erstelle zuerst einen Geburtstagskalender.",
// iCal add sheet
"ical.title": "iCal abonnieren",
"ical.subscription": "Abonnement",
@@ -677,6 +700,29 @@ private let strings: [String: [String: String]] = [
"local.color": "Color",
"local.create": "Create",
// Birthdays
"birthday.new": "New birthday",
"birthday.person": "Name",
"birthday.person_placeholder": "Person's name",
"birthday.date": "Birthday",
"birthday.year_unknown": "Year unknown",
"birthday.no_calendars": "No birthday calendar yet. Create one first under “Accounts & Calendars”.",
"birthday.is_calendar": "Birthday calendar",
"birthday.is_calendar.desc": "All-day, yearly events with age and a birthday icon.",
"birthday.notify": "Reminder",
"birthday.notify.off": "Off",
"birthday.notify.same_day": "On the day",
"birthday.notify.one_day": "1 day before",
"birthday.notify.days": "%d days before",
"birthday.contacts.header": "Birthdays from Contacts",
"birthday.contacts.sync": "Sync from Contacts",
"birthday.contacts.target": "Target calendar",
"birthday.contacts.sync_now": "Sync now",
"birthday.contacts.synced": "Birthdays synced",
"birthday.contacts.hint": "Copies birthdays from your contacts into the chosen birthday calendar visible on all devices.",
"birthday.contacts.denied": "No access to Contacts. Please allow it in iOS Settings.",
"birthday.contacts.need_calendar": "Create a birthday calendar first.",
// iCal add sheet
"ical.title": "Subscribe to iCal",
"ical.subscription": "Subscription",