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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user