- 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>
105 lines
4.2 KiB
Swift
105 lines
4.2 KiB
Swift
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()
|
|
}
|
|
}
|