Birthdays: single calendar model + sync on server-sync (iOS)
- one birthday calendar per user; BirthdaysImporter auto-creates/targets it
(no picker) and reconciles per-device (external_uid contact:<deviceId>:<id>)
so multiple devices don't clash
- Contacts birthday sync now runs on every "Sync with server" (syncFromServer),
not just app launch and the manual button
- report the device after each sync (POST /api/birthdays/sync-report) for the
web device list
- Accounts: Birthdays-from-Contacts section drops the target picker, keeps the
reminder picker; removed the birthday toggle from the generic new-calendar sheet
- New-birthday sheet targets the single calendar, offers activation if none
- exclude the birthday calendar from the group-visible ("shared calendar") picker
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,8 @@ struct AccountsView: View {
|
||||
|
||||
// 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
|
||||
@State private var birthdayNotify = -1
|
||||
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
@@ -47,7 +47,13 @@ struct AccountsView: View {
|
||||
haSection
|
||||
}
|
||||
.onChange(of: birthdaysSyncEnabled) { _, on in
|
||||
if on { Task { _ = await BirthdaysImporter.requestAccess() } }
|
||||
if on {
|
||||
Task {
|
||||
_ = await BirthdaysImporter.requestAccess()
|
||||
_ = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
|
||||
await load()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,31 +228,30 @@ struct AccountsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var birthdayCalendars: [LocalCalendar] {
|
||||
localCalendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") }
|
||||
/// The user's single birthday calendar (created on first sync/activation).
|
||||
private var birthdayCalendar: LocalCalendar? {
|
||||
localCalendars.first { $0.isBirthday && $0.owned }
|
||||
}
|
||||
|
||||
@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() }
|
||||
if let cal = birthdayCalendar {
|
||||
BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang)
|
||||
.onChange(of: birthdayNotify) { _, v in
|
||||
Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) }
|
||||
}
|
||||
}
|
||||
.disabled(isSyncingBirthdays || birthdaysSyncCalendarId == 0)
|
||||
}
|
||||
Button {
|
||||
Task { await syncBirthdays() }
|
||||
} label: {
|
||||
HStack {
|
||||
Text(L10n.t("birthday.contacts.sync_now", appLang))
|
||||
if isSyncingBirthdays { Spacer(); ProgressView() }
|
||||
}
|
||||
}
|
||||
.disabled(isSyncingBirthdays)
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.t("birthday.contacts.header", appLang))
|
||||
@@ -263,6 +268,7 @@ struct AccountsView: View {
|
||||
return
|
||||
}
|
||||
await BirthdaysImporter.sync(api: api)
|
||||
await load() // pick up the (possibly newly created) birthday calendar
|
||||
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)
|
||||
@@ -474,10 +480,8 @@ 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
|
||||
}
|
||||
// Reflect the birthday calendar's current reminder setting in the picker.
|
||||
birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@@ -595,8 +599,6 @@ 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 = ""
|
||||
|
||||
@@ -607,14 +609,6 @@ 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) }
|
||||
}
|
||||
@@ -637,10 +631,7 @@ struct AddLocalCalSheet: View {
|
||||
private func save() async {
|
||||
isLoading = true
|
||||
do {
|
||||
_ = try await api.addLocalCalendar(
|
||||
name: name, color: color.toHex(),
|
||||
isBirthday: isBirthday,
|
||||
birthdayNotifyDaysBefore: (isBirthday && notifyDays >= 0) ? notifyDays : nil)
|
||||
_ = try await api.addLocalCalendar(name: name, color: color.toHex())
|
||||
await onDone()
|
||||
dismiss()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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.
|
||||
/// Minimal "new birthday" mask for the single birthday calendar: enter a name
|
||||
/// and a date (optionally "year unknown"). Saves an all-day, yearly-recurring
|
||||
/// local event; the server adds the age suffix and cake icon on read. If no
|
||||
/// birthday calendar exists yet, offers to activate one.
|
||||
struct BirthdayEditorSheet: View {
|
||||
let api: CalendarrAPI
|
||||
var onDone: () async -> Void
|
||||
@@ -10,20 +11,16 @@ struct BirthdayEditorSheet: View {
|
||||
@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 calendar: LocalCalendar? = 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") }
|
||||
}
|
||||
@State private var activating = false
|
||||
|
||||
private var canSave: Bool {
|
||||
!saving && selectedCalId != nil
|
||||
!saving && calendar != nil
|
||||
&& !name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
@@ -32,9 +29,13 @@ struct BirthdayEditorSheet: View {
|
||||
Form {
|
||||
if loading {
|
||||
HStack { Spacer(); ProgressView(); Spacer() }
|
||||
} else if birthdayCalendars.isEmpty {
|
||||
Text(L10n.t("birthday.no_calendars", appLang))
|
||||
.foregroundStyle(.secondary)
|
||||
} else if calendar == nil {
|
||||
Section {
|
||||
Text(L10n.t("birthday.activate_hint", appLang))
|
||||
.foregroundStyle(.secondary)
|
||||
Button(L10n.t("birthday.activate", appLang)) { Task { await activate() } }
|
||||
.disabled(activating)
|
||||
}
|
||||
} else {
|
||||
Section(L10n.t("birthday.person", appLang)) {
|
||||
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
|
||||
@@ -44,15 +45,6 @@ struct BirthdayEditorSheet: View {
|
||||
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))
|
||||
@@ -71,16 +63,21 @@ struct BirthdayEditorSheet: View {
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
calendars = (try? await api.getLocalCalendars()) ?? []
|
||||
if selectedCalId == nil { selectedCalId = birthdayCalendars.first?.id }
|
||||
calendar = await BirthdaysImporter.birthdayCalendar(api: api)
|
||||
loading = false
|
||||
}
|
||||
|
||||
private func activate() async {
|
||||
activating = true
|
||||
calendar = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
|
||||
activating = false
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
guard let calId = selectedCalId else { return }
|
||||
guard let cal = calendar else { return }
|
||||
saving = true
|
||||
let cal = Calendar.current
|
||||
let comps = cal.dateComponents([.year, .month, .day], from: date)
|
||||
let calc = Calendar.current
|
||||
let comps = calc.dateComponents([.year, .month, .day], from: date)
|
||||
let month = comps.month ?? 1
|
||||
let day = comps.day ?? 1
|
||||
let year = yearUnknown ? nil : comps.year
|
||||
@@ -91,10 +88,10 @@ struct BirthdayEditorSheet: View {
|
||||
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
|
||||
let start = calc.date(from: anchor) ?? date
|
||||
let end = calc.date(byAdding: .day, value: 1, to: start) ?? start
|
||||
_ = try? await api.createLocalEvent(
|
||||
calendarId: calId, title: name.trimmingCharacters(in: .whitespaces),
|
||||
calendarId: cal.id, title: name.trimmingCharacters(in: .whitespaces),
|
||||
start: start, end: end, isAllDay: true, location: "", description: "",
|
||||
color: nil, rrule: "FREQ=YEARLY", birthYear: year
|
||||
)
|
||||
|
||||
@@ -535,6 +535,9 @@ struct CalendarHostView: View {
|
||||
/// shows up without the user opening the filter sheet.
|
||||
private func syncFromServer(force: Bool = false) async {
|
||||
await SettingsSync.pull(api: api)
|
||||
// Mirror Contacts birthdays on every server sync (manual, resume,
|
||||
// periodic) — the user expects "sync with server" to include birthdays.
|
||||
if BirthdaysImporter.isEnabled { await BirthdaysImporter.sync(api: api) }
|
||||
let changed = await store.reconcileCalendarVisibility(api: api)
|
||||
if changed || force { await forceReload() }
|
||||
}
|
||||
|
||||
@@ -205,7 +205,9 @@ struct SettingsView: View {
|
||||
groupVisibleId = s.groupVisibleCalendarId ?? 0
|
||||
}
|
||||
if let cals = try? await api.getLocalCalendars() {
|
||||
ownLocalCals = cals.filter { $0.owned && !$0.group }
|
||||
// A birthday calendar may be shared directly, but never stand in as
|
||||
// the group-visible personal calendar.
|
||||
ownLocalCals = cals.filter { $0.owned && !$0.group && !$0.isBirthday }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user