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

@@ -491,7 +491,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -500,6 +500,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -511,7 +512,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.9;
MARKETING_VERSION = 3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -535,7 +536,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -544,6 +545,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -555,7 +557,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.9;
MARKETING_VERSION = 3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES;

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",

View 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)
}
}

View File

@@ -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)
}

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)