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:
@@ -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) {
|
||||
|
||||
104
Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift
Normal file
104
Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user