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:
168
Calendarr iOS/Services/BirthdaysImporter.swift
Normal file
168
Calendarr iOS/Services/BirthdaysImporter.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
|
||||
/// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays.
|
||||
/// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`.
|
||||
struct BirthdayEntry: Codable, Identifiable {
|
||||
let uid: String
|
||||
let externalUid: String?
|
||||
let title: String
|
||||
let month: Int?
|
||||
let day: Int?
|
||||
let birthYear: Int?
|
||||
var id: String { uid }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uid, title, month, day
|
||||
case externalUid = "external_uid"
|
||||
case birthYear = "birth_year"
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads birthdays from the system Contacts and mirrors them into a chosen
|
||||
/// birthday `LocalCalendar` on the backend, so they show on every client.
|
||||
///
|
||||
/// The sync is a **mirror**: it reconciles contact-sourced rows by
|
||||
/// `external_uid` (adds new, updates changed, deletes removed) and never touches
|
||||
/// manually added birthdays (which have no `external_uid`). The heavy lifting —
|
||||
/// age suffix, cake icon, "notify N days before" reminder — is done server-side;
|
||||
/// this only uploads name + date + birth year.
|
||||
enum BirthdaysImporter {
|
||||
|
||||
// MARK: – Persisted binding (which calendar receives Contacts birthdays)
|
||||
|
||||
enum Key {
|
||||
static let enabled = "birthdaysSyncEnabled" // Bool
|
||||
static let calendarId = "birthdaysSyncCalendarId" // Int (0 = none)
|
||||
}
|
||||
|
||||
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
||||
static var targetCalendarId: Int? {
|
||||
let v = UserDefaults.standard.object(forKey: Key.calendarId) as? Int ?? 0
|
||||
return v > 0 ? v : nil
|
||||
}
|
||||
static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) }
|
||||
static func setTargetCalendarId(_ id: Int?) {
|
||||
UserDefaults.standard.set(id ?? 0, forKey: Key.calendarId)
|
||||
}
|
||||
|
||||
// MARK: – Contacts access
|
||||
|
||||
static var isAuthorized: Bool {
|
||||
let s = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if s == .authorized { return true }
|
||||
if #available(iOS 18.0, *), s == .limited { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Request Contacts access once. Returns true if usable for enumeration.
|
||||
@discardableResult
|
||||
static func requestAccess() async -> Bool {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if status == .authorized { return true }
|
||||
if #available(iOS 18.0, *), status == .limited { return true }
|
||||
guard status == .notDetermined else { return false }
|
||||
return await withCheckedContinuation { cont in
|
||||
CNContactStore().requestAccess(for: .contacts) { granted, _ in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Reading contact birthdays
|
||||
|
||||
struct ContactBirthday {
|
||||
let externalUid: String // "contact:<identifier>"
|
||||
let name: String
|
||||
let month: Int
|
||||
let day: Int
|
||||
let year: Int? // nil = year unknown
|
||||
}
|
||||
|
||||
static func readContactBirthdays() throws -> [ContactBirthday] {
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactBirthdayKey as CNKeyDescriptor,
|
||||
]
|
||||
let req = CNContactFetchRequest(keysToFetch: keys)
|
||||
var out: [ContactBirthday] = []
|
||||
try store.enumerateContacts(with: req) { contact, _ in
|
||||
guard let bday = contact.birthday, let m = bday.month, let d = bday.day else { return }
|
||||
var name = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
|
||||
if name.isEmpty {
|
||||
name = [contact.givenName, contact.familyName]
|
||||
.filter { !$0.isEmpty }.joined(separator: " ")
|
||||
}
|
||||
if name.isEmpty { name = contact.organizationName }
|
||||
guard !name.isEmpty else { return }
|
||||
let year = bday.year.flatMap { $0 > 0 ? $0 : nil }
|
||||
out.append(ContactBirthday(
|
||||
externalUid: "contact:\(contact.identifier)",
|
||||
name: name, month: m, day: d, year: year
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: – Sync
|
||||
|
||||
/// Mirror the address book into the bound birthday calendar. No-op unless the
|
||||
/// sync is enabled, a target calendar is set, and access is granted.
|
||||
static func sync(api: CalendarrAPI) async {
|
||||
guard isEnabled, let calId = targetCalendarId else { return }
|
||||
guard await requestAccess() else { return }
|
||||
guard let contacts = try? readContactBirthdays() else { return }
|
||||
guard let existing = try? await api.getBirthdayEntries(calendarId: calId) else { return }
|
||||
|
||||
// Only reconcile contact-sourced rows; leave manual entries untouched.
|
||||
var byExt: [String: BirthdayEntry] = [:]
|
||||
for e in existing { if let ext = e.externalUid { byExt[ext] = e } }
|
||||
|
||||
var seen = Set<String>()
|
||||
for c in contacts {
|
||||
seen.insert(c.externalUid)
|
||||
let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year)
|
||||
if let match = byExt[c.externalUid] {
|
||||
let changed = match.title != c.name || match.month != c.month
|
||||
|| match.day != c.day || match.birthYear != c.year
|
||||
if changed {
|
||||
try? await api.updateLocalEvent(
|
||||
uid: match.uid, title: c.name, start: start, end: end,
|
||||
isAllDay: true, location: "", description: "", color: nil,
|
||||
rrule: "FREQ=YEARLY", externalUid: c.externalUid,
|
||||
birthYear: c.year ?? -1 // -1 clears birth_year server-side
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_ = try? await api.createLocalEvent(
|
||||
calendarId: calId, title: c.name, start: start, end: end,
|
||||
isAllDay: true, location: "", description: "", color: nil,
|
||||
rrule: "FREQ=YEARLY", externalUid: c.externalUid, birthYear: c.year
|
||||
)
|
||||
}
|
||||
}
|
||||
// Remove contact-sourced rows whose contact no longer has a birthday.
|
||||
for (ext, entry) in byExt where !seen.contains(ext) {
|
||||
try? await api.deleteLocalEvent(uid: entry.uid)
|
||||
}
|
||||
}
|
||||
|
||||
/// All-day [start, end) for a birthday. Anchor year = birth year when known,
|
||||
/// else 1970 so FREQ=YEARLY expands across any queried range. Built at local
|
||||
/// noon so day-only formatting can't roll to an adjacent day.
|
||||
private static func allDayRange(month: Int, day: Int, year: Int?) -> (Date, Date) {
|
||||
var comp = DateComponents()
|
||||
comp.year = year ?? 1970
|
||||
comp.month = month
|
||||
comp.day = day
|
||||
comp.hour = 12
|
||||
let cal = Calendar.current
|
||||
let start = cal.date(from: comp) ?? Date()
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start) ?? start
|
||||
return (start, end)
|
||||
}
|
||||
}
|
||||
@@ -135,8 +135,13 @@ class CalendarrAPI {
|
||||
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func addLocalCalendar(name: String, color: String) async throws -> LocalCalendar {
|
||||
let data = try await request("/api/local/calendars", method: "POST", body: ["name": name, "color": color])
|
||||
func addLocalCalendar(name: String, color: String,
|
||||
isBirthday: Bool = false,
|
||||
birthdayNotifyDaysBefore: Int? = nil) async throws -> LocalCalendar {
|
||||
var body: [String: Any] = ["name": name, "color": color]
|
||||
if isBirthday { body["is_birthday"] = true }
|
||||
if let d = birthdayNotifyDaysBefore { body["birthday_notify_days_before"] = d }
|
||||
let data = try await request("/api/local/calendars", method: "POST", body: body)
|
||||
guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError }
|
||||
return cal
|
||||
}
|
||||
@@ -145,6 +150,24 @@ class CalendarrAPI {
|
||||
_ = try await request("/api/local/calendars/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
/// Update a local calendar's birthday settings. `is_birthday` marks it as a
|
||||
/// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1
|
||||
/// sentinel to clear it server-side).
|
||||
func updateLocalCalendarBirthday(id: Int, isBirthday: Bool?, notifyDaysBefore: Int??) async throws {
|
||||
var body: [String: Any] = [:]
|
||||
if let b = isBirthday { body["is_birthday"] = b }
|
||||
if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n ?? -1 }
|
||||
guard !body.isEmpty else { return }
|
||||
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
/// Raw (unexpanded) rows of a birthday calendar, used by the Contacts
|
||||
/// importer to reconcile by `external_uid`.
|
||||
func getBirthdayEntries(calendarId: Int) async throws -> [BirthdayEntry] {
|
||||
let data = try await request("/api/local/calendars/\(calendarId)/birthdays")
|
||||
return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func getICalSubscriptions() async throws -> [ICalSubscription] {
|
||||
let data = try await request("/api/ical/subscriptions")
|
||||
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
|
||||
@@ -236,7 +259,9 @@ class CalendarrAPI {
|
||||
|
||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil,
|
||||
rrule: String? = nil, externalUid: String? = nil,
|
||||
birthYear: Int? = nil) async throws -> CalEvent {
|
||||
var body: [String: Any] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
@@ -249,6 +274,9 @@ class CalendarrAPI {
|
||||
]
|
||||
if let c = color, !c.isEmpty { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
if let rrule, !rrule.isEmpty { body["rrule"] = rrule }
|
||||
if let externalUid { body["external_uid"] = externalUid }
|
||||
if let birthYear { body["birth_year"] = birthYear }
|
||||
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
||||
@@ -257,7 +285,9 @@ class CalendarrAPI {
|
||||
|
||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil,
|
||||
rrule: String? = nil, externalUid: String? = nil,
|
||||
birthYear: Int? = nil) async throws {
|
||||
var body: [String: Any] = [
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
@@ -269,6 +299,9 @@ class CalendarrAPI {
|
||||
]
|
||||
if let c = color { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
if let rrule { body["rrule"] = rrule }
|
||||
if let externalUid { body["external_uid"] = externalUid }
|
||||
if let birthYear { body["birth_year"] = birthYear }
|
||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user