From d4fd7beaf7670664bebf187382c7a67bff8e3343 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Jul 2026 21:25:06 +0200 Subject: [PATCH] Birthdays: explicit calendar creation, sync never auto-creates (iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Calendarr iOS/Models/Localization.swift | 2 + .../Services/BirthdaysImporter.swift | 5 +- Calendarr iOS/Views/AccountsView.swift | 49 ++++++++++++------- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 6bd6176..c8ce99f 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -292,6 +292,7 @@ private let strings: [String: [String: String]] = [ "accounts.loading": "Lade Konten…", "accounts.add.caldav": "CalDAV-Konto", "accounts.add.local": "Lokaler Kalender", + "accounts.add.birthday": "Geburtstagskalender", "accounts.add.ical": "iCal-URL abonnieren", "accounts.add.ha": "Home Assistant", "accounts.caldav.header": "CalDAV-Konten", @@ -650,6 +651,7 @@ private let strings: [String: [String: String]] = [ "accounts.loading": "Loading accounts…", "accounts.add.caldav": "CalDAV account", "accounts.add.local": "Local calendar", + "accounts.add.birthday": "Birthday calendar", "accounts.add.ical": "Subscribe to iCal URL", "accounts.add.ha": "Home Assistant", "accounts.caldav.header": "CalDAV accounts", diff --git a/Calendarr iOS/Services/BirthdaysImporter.swift b/Calendarr iOS/Services/BirthdaysImporter.swift index 471ca81..79fcf28 100644 --- a/Calendarr iOS/Services/BirthdaysImporter.swift +++ b/Calendarr iOS/Services/BirthdaysImporter.swift @@ -141,7 +141,10 @@ enum BirthdaysImporter { guard isEnabled else { return } guard await requestAccess() 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 } // Only reconcile THIS device's contact rows; leave other devices' diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 5ef84d1..e66eba8 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -47,13 +47,9 @@ struct AccountsView: View { haSection } .onChange(of: birthdaysSyncEnabled) { _, on in - if on { - Task { - _ = await BirthdaysImporter.requestAccess() - _ = await BirthdaysImporter.ensureBirthdayCalendar(api: api) - await load() - } - } + // Enabling only asks for Contacts access — it must NOT create + // the birthday calendar. That's an explicit action. + if on { Task { _ = await BirthdaysImporter.requestAccess() } } } } } @@ -64,6 +60,12 @@ struct AccountsView: View { Menu { Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = 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.ha", appLang)) { showAddHA = true } } label: { @@ -235,23 +237,28 @@ struct AccountsView: View { @ViewBuilder var birthdayContactsSection: some View { Section { - Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled) - if birthdaysSyncEnabled { - if let cal = birthdayCalendar { + if let cal = birthdayCalendar { + Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled) + if birthdaysSyncEnabled { BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang) .onChange(of: birthdayNotify) { _, v in Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) } } - } - Button { - Task { await syncBirthdays() } - } label: { - HStack { - Text(L10n.t("birthday.contacts.sync_now", appLang)) - if isSyncingBirthdays { Spacer(); ProgressView() } + Button { + Task { await syncBirthdays() } + } label: { + HStack { + Text(L10n.t("birthday.contacts.sync_now", appLang)) + if isSyncingBirthdays { Spacer(); ProgressView() } + } } + .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: { Text(L10n.t("birthday.contacts.header", appLang)) @@ -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 { isSyncingBirthdays = true defer { isSyncingBirthdays = false }