Birthdays: explicit calendar creation, sync never auto-creates (iOS)

- Contacts sync fills only an EXISTING birthday calendar (birthdayCalendar),
  never auto-creates it — deleting it no longer resurrects it on sync
- Accounts + menu gains "Geburtstagskalender" (only when none exists) to create
  the single birthday calendar explicitly
- the Birthdays-from-Contacts section shows a "create one first" hint when there
  is no birthday calendar; enabling sync only requests Contacts access

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-13 21:25:06 +02:00
parent 69ed8bf67d
commit d4fd7beaf7
3 changed files with 37 additions and 19 deletions

View File

@@ -292,6 +292,7 @@ private let strings: [String: [String: String]] = [
"accounts.loading": "Lade Konten…", "accounts.loading": "Lade Konten…",
"accounts.add.caldav": "CalDAV-Konto", "accounts.add.caldav": "CalDAV-Konto",
"accounts.add.local": "Lokaler Kalender", "accounts.add.local": "Lokaler Kalender",
"accounts.add.birthday": "Geburtstagskalender",
"accounts.add.ical": "iCal-URL abonnieren", "accounts.add.ical": "iCal-URL abonnieren",
"accounts.add.ha": "Home Assistant", "accounts.add.ha": "Home Assistant",
"accounts.caldav.header": "CalDAV-Konten", "accounts.caldav.header": "CalDAV-Konten",
@@ -650,6 +651,7 @@ private let strings: [String: [String: String]] = [
"accounts.loading": "Loading accounts…", "accounts.loading": "Loading accounts…",
"accounts.add.caldav": "CalDAV account", "accounts.add.caldav": "CalDAV account",
"accounts.add.local": "Local calendar", "accounts.add.local": "Local calendar",
"accounts.add.birthday": "Birthday calendar",
"accounts.add.ical": "Subscribe to iCal URL", "accounts.add.ical": "Subscribe to iCal URL",
"accounts.add.ha": "Home Assistant", "accounts.add.ha": "Home Assistant",
"accounts.caldav.header": "CalDAV accounts", "accounts.caldav.header": "CalDAV accounts",

View File

@@ -141,7 +141,10 @@ enum BirthdaysImporter {
guard isEnabled else { return } guard isEnabled else { return }
guard await requestAccess() else { return } guard await requestAccess() else { return }
guard let contacts = try? readContactBirthdays() else { return } guard let contacts = try? readContactBirthdays() else { return }
guard let cal = await ensureBirthdayCalendar(api: api) else { return } // Only fill an EXISTING birthday calendar never auto-create it. The
// calendar is created explicitly; deleting it means sync has nowhere to
// go (and must not resurrect it).
guard let cal = await birthdayCalendar(api: api) else { return }
guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return } guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return }
// Only reconcile THIS device's contact rows; leave other devices' // Only reconcile THIS device's contact rows; leave other devices'

View File

@@ -47,13 +47,9 @@ struct AccountsView: View {
haSection haSection
} }
.onChange(of: birthdaysSyncEnabled) { _, on in .onChange(of: birthdaysSyncEnabled) { _, on in
if on { // Enabling only asks for Contacts access it must NOT create
Task { // the birthday calendar. That's an explicit action.
_ = await BirthdaysImporter.requestAccess() if on { Task { _ = await BirthdaysImporter.requestAccess() } }
_ = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
await load()
}
}
} }
} }
} }
@@ -64,6 +60,12 @@ struct AccountsView: View {
Menu { Menu {
Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true } Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true }
Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true } Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true }
// Only one birthday calendar per account.
if birthdayCalendar == nil {
Button(L10n.t("accounts.add.birthday", appLang)) {
Task { await createBirthdayCalendar() }
}
}
Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true } Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true }
Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true } Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true }
} label: { } label: {
@@ -235,14 +237,13 @@ struct AccountsView: View {
@ViewBuilder var birthdayContactsSection: some View { @ViewBuilder var birthdayContactsSection: some View {
Section { Section {
if let cal = birthdayCalendar {
Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled) Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled)
if birthdaysSyncEnabled { if birthdaysSyncEnabled {
if let cal = birthdayCalendar {
BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang) BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang)
.onChange(of: birthdayNotify) { _, v in .onChange(of: birthdayNotify) { _, v in
Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) } Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) }
} }
}
Button { Button {
Task { await syncBirthdays() } Task { await syncBirthdays() }
} label: { } label: {
@@ -253,6 +254,12 @@ struct AccountsView: View {
} }
.disabled(isSyncingBirthdays) .disabled(isSyncingBirthdays)
} }
} else {
// No birthday calendar yet sync has nothing to fill. Create one
// first via the + menu (never auto-created by sync).
Text(L10n.t("birthday.contacts.need_calendar", appLang))
.font(.caption).foregroundStyle(.secondary)
}
} header: { } header: {
Text(L10n.t("birthday.contacts.header", appLang)) Text(L10n.t("birthday.contacts.header", appLang))
} footer: { } footer: {
@@ -260,6 +267,12 @@ struct AccountsView: View {
} }
} }
/// Explicitly create the single birthday calendar (from the + menu).
private func createBirthdayCalendar() async {
_ = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
await load()
}
private func syncBirthdays() async { private func syncBirthdays() async {
isSyncingBirthdays = true isSyncingBirthdays = true
defer { isSyncingBirthdays = false } defer { isSyncingBirthdays = false }