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:
@@ -347,6 +347,9 @@ private let strings: [String: [String: String]] = [
|
|||||||
|
|
||||||
// Birthdays
|
// Birthdays
|
||||||
"birthday.new": "Neuer Geburtstag",
|
"birthday.new": "Neuer Geburtstag",
|
||||||
|
"birthday.calendar_name": "Geburtstage",
|
||||||
|
"birthday.activate": "Geburtstagskalender aktivieren",
|
||||||
|
"birthday.activate_hint": "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
|
||||||
"birthday.person": "Name",
|
"birthday.person": "Name",
|
||||||
"birthday.person_placeholder": "Name der Person",
|
"birthday.person_placeholder": "Name der Person",
|
||||||
"birthday.date": "Geburtstag",
|
"birthday.date": "Geburtstag",
|
||||||
@@ -702,6 +705,9 @@ private let strings: [String: [String: String]] = [
|
|||||||
|
|
||||||
// Birthdays
|
// Birthdays
|
||||||
"birthday.new": "New birthday",
|
"birthday.new": "New birthday",
|
||||||
|
"birthday.calendar_name": "Birthdays",
|
||||||
|
"birthday.activate": "Enable birthday calendar",
|
||||||
|
"birthday.activate_hint": "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
|
||||||
"birthday.person": "Name",
|
"birthday.person": "Name",
|
||||||
"birthday.person_placeholder": "Person's name",
|
"birthday.person_placeholder": "Person's name",
|
||||||
"birthday.date": "Birthday",
|
"birthday.date": "Birthday",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Contacts
|
import Contacts
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays.
|
/// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays.
|
||||||
/// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`.
|
/// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`.
|
||||||
@@ -19,31 +20,41 @@ struct BirthdayEntry: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads birthdays from the system Contacts and mirrors them into a chosen
|
/// Reads birthdays from the system Contacts and mirrors them into the user's
|
||||||
/// birthday `LocalCalendar` on the backend, so they show on every client.
|
/// single birthday calendar on the backend, so they show on every client.
|
||||||
///
|
///
|
||||||
/// The sync is a **mirror**: it reconciles contact-sourced rows by
|
/// The sync is a **mirror**, scoped to THIS device: it reconciles rows whose
|
||||||
/// `external_uid` (adds new, updates changed, deletes removed) and never touches
|
/// `external_uid` starts with `contact:<deviceId>:` (adds new, updates changed,
|
||||||
/// manually added birthdays (which have no `external_uid`). The heavy lifting —
|
/// deletes removed) and never touches other devices' rows or manually added
|
||||||
/// age suffix, cake icon, "notify N days before" reminder — is done server-side;
|
/// birthdays. Age suffix, cake icon and "notify N days before" are done
|
||||||
/// this only uploads name + date + birth year.
|
/// server-side; this only uploads name + date + birth year, and reports the
|
||||||
|
/// device so the web can list "birthdays come from these devices".
|
||||||
enum BirthdaysImporter {
|
enum BirthdaysImporter {
|
||||||
|
|
||||||
// MARK: – Persisted binding (which calendar receives Contacts birthdays)
|
// MARK: – Persisted state
|
||||||
|
|
||||||
enum Key {
|
enum Key {
|
||||||
static let enabled = "birthdaysSyncEnabled" // Bool
|
static let enabled = "birthdaysSyncEnabled" // Bool
|
||||||
static let calendarId = "birthdaysSyncCalendarId" // Int (0 = none)
|
static let deviceId = "birthdaysDeviceId" // stable per-install UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
||||||
static var targetCalendarId: Int? {
|
|
||||||
let v = UserDefaults.standard.object(forKey: Key.calendarId) as? Int ?? 0
|
|
||||||
return v > 0 ? v : nil
|
|
||||||
}
|
|
||||||
static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) }
|
static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) }
|
||||||
static func setTargetCalendarId(_ id: Int?) {
|
|
||||||
UserDefaults.standard.set(id ?? 0, forKey: Key.calendarId)
|
static var deviceId: String {
|
||||||
|
if let id = UserDefaults.standard.string(forKey: Key.deviceId) { return id }
|
||||||
|
let id = UUID().uuidString
|
||||||
|
UserDefaults.standard.set(id, forKey: Key.deviceId)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor static var deviceName: String {
|
||||||
|
let n = UIDevice.current.name
|
||||||
|
return n.isEmpty ? "iPhone" : n
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var appLang: String {
|
||||||
|
UserDefaults.standard.string(forKey: "appLanguage") ?? "system"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Contacts access
|
// MARK: – Contacts access
|
||||||
@@ -55,7 +66,6 @@ enum BirthdaysImporter {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request Contacts access once. Returns true if usable for enumeration.
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func requestAccess() async -> Bool {
|
static func requestAccess() async -> Bool {
|
||||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
@@ -72,11 +82,11 @@ enum BirthdaysImporter {
|
|||||||
// MARK: – Reading contact birthdays
|
// MARK: – Reading contact birthdays
|
||||||
|
|
||||||
struct ContactBirthday {
|
struct ContactBirthday {
|
||||||
let externalUid: String // "contact:<identifier>"
|
let contactId: String
|
||||||
let name: String
|
let name: String
|
||||||
let month: Int
|
let month: Int
|
||||||
let day: Int
|
let day: Int
|
||||||
let year: Int? // nil = year unknown
|
let year: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
static func readContactBirthdays() throws -> [ContactBirthday] {
|
static func readContactBirthdays() throws -> [ContactBirthday] {
|
||||||
@@ -101,54 +111,77 @@ enum BirthdaysImporter {
|
|||||||
guard !name.isEmpty else { return }
|
guard !name.isEmpty else { return }
|
||||||
let year = bday.year.flatMap { $0 > 0 ? $0 : nil }
|
let year = bday.year.flatMap { $0 > 0 ? $0 : nil }
|
||||||
out.append(ContactBirthday(
|
out.append(ContactBirthday(
|
||||||
externalUid: "contact:\(contact.identifier)",
|
contactId: contact.identifier, name: name, month: m, day: d, year: year
|
||||||
name: name, month: m, day: d, year: year
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – The single birthday calendar
|
||||||
|
|
||||||
|
/// The user's birthday calendar, creating the single "Geburtstage" calendar
|
||||||
|
/// if none exists yet.
|
||||||
|
static func ensureBirthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
|
||||||
|
let cals = (try? await api.getLocalCalendars()) ?? []
|
||||||
|
if let existing = cals.first(where: { $0.isBirthday && $0.owned }) { return existing }
|
||||||
|
let name = L10n.t("birthday.calendar_name", appLang)
|
||||||
|
return try? await api.addLocalCalendar(name: name, color: "#E0407F", isBirthday: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func birthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
|
||||||
|
let cals = (try? await api.getLocalCalendars()) ?? []
|
||||||
|
return cals.first(where: { $0.isBirthday && $0.owned })
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Sync
|
// MARK: – Sync
|
||||||
|
|
||||||
/// Mirror the address book into the bound birthday calendar. No-op unless the
|
/// Mirror the address book into the birthday calendar (creating it if
|
||||||
/// sync is enabled, a target calendar is set, and access is granted.
|
/// needed). No-op unless enabled and Contacts access is granted.
|
||||||
static func sync(api: CalendarrAPI) async {
|
static func sync(api: CalendarrAPI) async {
|
||||||
guard isEnabled, let calId = targetCalendarId 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 existing = try? await api.getBirthdayEntries(calendarId: calId) else { return }
|
guard let cal = await ensureBirthdayCalendar(api: api) else { return }
|
||||||
|
guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return }
|
||||||
|
|
||||||
// Only reconcile contact-sourced rows; leave manual entries untouched.
|
// Only reconcile THIS device's contact rows; leave other devices'
|
||||||
|
// rows and manual entries untouched.
|
||||||
|
let prefix = "contact:\(deviceId):"
|
||||||
var byExt: [String: BirthdayEntry] = [:]
|
var byExt: [String: BirthdayEntry] = [:]
|
||||||
for e in existing { if let ext = e.externalUid { byExt[ext] = e } }
|
for e in existing {
|
||||||
|
if let ext = e.externalUid, ext.hasPrefix(prefix) { byExt[ext] = e }
|
||||||
|
}
|
||||||
|
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
for c in contacts {
|
for c in contacts {
|
||||||
seen.insert(c.externalUid)
|
let ext = prefix + c.contactId
|
||||||
|
seen.insert(ext)
|
||||||
let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year)
|
let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year)
|
||||||
if let match = byExt[c.externalUid] {
|
if let match = byExt[ext] {
|
||||||
let changed = match.title != c.name || match.month != c.month
|
let changed = match.title != c.name || match.month != c.month
|
||||||
|| match.day != c.day || match.birthYear != c.year
|
|| match.day != c.day || match.birthYear != c.year
|
||||||
if changed {
|
if changed {
|
||||||
try? await api.updateLocalEvent(
|
try? await api.updateLocalEvent(
|
||||||
uid: match.uid, title: c.name, start: start, end: end,
|
uid: match.uid, title: c.name, start: start, end: end,
|
||||||
isAllDay: true, location: "", description: "", color: nil,
|
isAllDay: true, location: "", description: "", color: nil,
|
||||||
rrule: "FREQ=YEARLY", externalUid: c.externalUid,
|
rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ?? -1
|
||||||
birthYear: c.year ?? -1 // -1 clears birth_year server-side
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_ = try? await api.createLocalEvent(
|
_ = try? await api.createLocalEvent(
|
||||||
calendarId: calId, title: c.name, start: start, end: end,
|
calendarId: cal.id, title: c.name, start: start, end: end,
|
||||||
isAllDay: true, location: "", description: "", color: nil,
|
isAllDay: true, location: "", description: "", color: nil,
|
||||||
rrule: "FREQ=YEARLY", externalUid: c.externalUid, birthYear: c.year
|
rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove contact-sourced rows whose contact no longer has a birthday.
|
|
||||||
for (ext, entry) in byExt where !seen.contains(ext) {
|
for (ext, entry) in byExt where !seen.contains(ext) {
|
||||||
try? await api.deleteLocalEvent(uid: entry.uid)
|
try? await api.deleteLocalEvent(uid: entry.uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report this device so the web can list where birthdays come from.
|
||||||
|
let name = await deviceName
|
||||||
|
try? await api.reportBirthdaySync(deviceId: deviceId, deviceName: name, count: contacts.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All-day [start, end) for a birthday. Anchor year = birth year when known,
|
/// All-day [start, end) for a birthday. Anchor year = birth year when known,
|
||||||
|
|||||||
@@ -153,10 +153,10 @@ class CalendarrAPI {
|
|||||||
/// Update a local calendar's birthday settings. `is_birthday` marks it as a
|
/// Update a local calendar's birthday settings. `is_birthday` marks it as a
|
||||||
/// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1
|
/// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1
|
||||||
/// sentinel to clear it server-side).
|
/// sentinel to clear it server-side).
|
||||||
func updateLocalCalendarBirthday(id: Int, isBirthday: Bool?, notifyDaysBefore: Int??) async throws {
|
func updateLocalCalendarBirthday(id: Int, isBirthday: Bool? = nil, notifyDaysBefore: Int? = nil) async throws {
|
||||||
var body: [String: Any] = [:]
|
var body: [String: Any] = [:]
|
||||||
if let b = isBirthday { body["is_birthday"] = b }
|
if let b = isBirthday { body["is_birthday"] = b }
|
||||||
if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n ?? -1 }
|
if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n } // -1 clears server-side
|
||||||
guard !body.isEmpty else { return }
|
guard !body.isEmpty else { return }
|
||||||
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body)
|
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body)
|
||||||
}
|
}
|
||||||
@@ -168,6 +168,14 @@ class CalendarrAPI {
|
|||||||
return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? []
|
return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Report that this device just synced its Contacts birthdays, so the web
|
||||||
|
/// can show "birthdays come from these devices".
|
||||||
|
func reportBirthdaySync(deviceId: String, deviceName: String, count: Int) async throws {
|
||||||
|
_ = try await request("/api/birthdays/sync-report", method: "POST", body: [
|
||||||
|
"device_id": deviceId, "device_name": deviceName, "count": count,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
func getICalSubscriptions() async throws -> [ICalSubscription] {
|
func getICalSubscriptions() async throws -> [ICalSubscription] {
|
||||||
let data = try await request("/api/ical/subscriptions")
|
let data = try await request("/api/ical/subscriptions")
|
||||||
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
|
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ struct AccountsView: View {
|
|||||||
|
|
||||||
// Contacts → birthday-calendar sync (opt-in, bound to one birthday calendar).
|
// Contacts → birthday-calendar sync (opt-in, bound to one birthday calendar).
|
||||||
@AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false
|
@AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false
|
||||||
@AppStorage("birthdaysSyncCalendarId") private var birthdaysSyncCalendarId = 0
|
|
||||||
@State private var isSyncingBirthdays = false
|
@State private var isSyncingBirthdays = false
|
||||||
|
@State private var birthdayNotify = -1
|
||||||
|
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
@@ -47,7 +47,13 @@ struct AccountsView: View {
|
|||||||
haSection
|
haSection
|
||||||
}
|
}
|
||||||
.onChange(of: birthdaysSyncEnabled) { _, on in
|
.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] {
|
/// The user's single birthday calendar (created on first sync/activation).
|
||||||
localCalendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") }
|
private var birthdayCalendar: LocalCalendar? {
|
||||||
|
localCalendars.first { $0.isBirthday && $0.owned }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder var birthdayContactsSection: some View {
|
@ViewBuilder var birthdayContactsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled)
|
Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled)
|
||||||
if birthdaysSyncEnabled {
|
if birthdaysSyncEnabled {
|
||||||
if birthdayCalendars.isEmpty {
|
if let cal = birthdayCalendar {
|
||||||
Text(L10n.t("birthday.contacts.need_calendar", appLang))
|
BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang)
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.onChange(of: birthdayNotify) { _, v in
|
||||||
} else {
|
Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) }
|
||||||
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() }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.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: {
|
} header: {
|
||||||
Text(L10n.t("birthday.contacts.header", appLang))
|
Text(L10n.t("birthday.contacts.header", appLang))
|
||||||
@@ -263,6 +268,7 @@ struct AccountsView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
await BirthdaysImporter.sync(api: api)
|
await BirthdaysImporter.sync(api: api)
|
||||||
|
await load() // pick up the (possibly newly created) birthday calendar
|
||||||
infoMessage = L10n.t("birthday.contacts.synced", appLang)
|
infoMessage = L10n.t("birthday.contacts.synced", appLang)
|
||||||
// Let the calendar refresh so imported birthdays show up right away.
|
// Let the calendar refresh so imported birthdays show up right away.
|
||||||
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
|
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
|
||||||
@@ -474,10 +480,8 @@ struct AccountsView: View {
|
|||||||
CalendarStore.saveBanishedKeys(b)
|
CalendarStore.saveBanishedKeys(b)
|
||||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||||
}
|
}
|
||||||
// Default the Contacts-sync target to the first birthday calendar.
|
// Reflect the birthday calendar's current reminder setting in the picker.
|
||||||
if birthdaysSyncCalendarId == 0, let first = birthdayCalendars.first?.id {
|
birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1
|
||||||
birthdaysSyncCalendarId = first
|
|
||||||
}
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,8 +599,6 @@ struct AddLocalCalSheet: View {
|
|||||||
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var color = Color(hex: "#34a853")
|
@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 isLoading = false
|
||||||
@State private var error = ""
|
@State private var error = ""
|
||||||
|
|
||||||
@@ -607,14 +609,6 @@ struct AddLocalCalSheet: View {
|
|||||||
TextField(L10n.t("local.name", appLang), text: $name)
|
TextField(L10n.t("local.name", appLang), text: $name)
|
||||||
ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false)
|
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 {
|
if !error.isEmpty {
|
||||||
Section { Text(error).foregroundStyle(.red) }
|
Section { Text(error).foregroundStyle(.red) }
|
||||||
}
|
}
|
||||||
@@ -637,10 +631,7 @@ struct AddLocalCalSheet: View {
|
|||||||
private func save() async {
|
private func save() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
do {
|
do {
|
||||||
_ = try await api.addLocalCalendar(
|
_ = try await api.addLocalCalendar(name: name, color: color.toHex())
|
||||||
name: name, color: color.toHex(),
|
|
||||||
isBirthday: isBirthday,
|
|
||||||
birthdayNotifyDaysBefore: (isBirthday && notifyDays >= 0) ? notifyDays : nil)
|
|
||||||
await onDone()
|
await onDone()
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch { self.error = error.localizedDescription }
|
} catch { self.error = error.localizedDescription }
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Minimal "new birthday" mask: pick a birthday calendar, enter a name and a
|
/// Minimal "new birthday" mask for the single birthday calendar: enter a name
|
||||||
/// date (day + month, optionally a year). Saves an all-day, yearly-recurring
|
/// 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.
|
/// 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 {
|
struct BirthdayEditorSheet: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
var onDone: () async -> Void
|
var onDone: () async -> Void
|
||||||
@@ -10,20 +11,16 @@ struct BirthdayEditorSheet: View {
|
|||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var calendars: [LocalCalendar] = []
|
@State private var calendar: LocalCalendar? = nil
|
||||||
@State private var selectedCalId: Int? = nil
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var date = Date()
|
@State private var date = Date()
|
||||||
@State private var yearUnknown = false
|
@State private var yearUnknown = false
|
||||||
@State private var loading = true
|
@State private var loading = true
|
||||||
@State private var saving = false
|
@State private var saving = false
|
||||||
|
@State private var activating = false
|
||||||
private var birthdayCalendars: [LocalCalendar] {
|
|
||||||
calendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
!saving && selectedCalId != nil
|
!saving && calendar != nil
|
||||||
&& !name.trimmingCharacters(in: .whitespaces).isEmpty
|
&& !name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +29,13 @@ struct BirthdayEditorSheet: View {
|
|||||||
Form {
|
Form {
|
||||||
if loading {
|
if loading {
|
||||||
HStack { Spacer(); ProgressView(); Spacer() }
|
HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
} else if birthdayCalendars.isEmpty {
|
} else if calendar == nil {
|
||||||
Text(L10n.t("birthday.no_calendars", appLang))
|
Section {
|
||||||
.foregroundStyle(.secondary)
|
Text(L10n.t("birthday.activate_hint", appLang))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button(L10n.t("birthday.activate", appLang)) { Task { await activate() } }
|
||||||
|
.disabled(activating)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Section(L10n.t("birthday.person", appLang)) {
|
Section(L10n.t("birthday.person", appLang)) {
|
||||||
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
|
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
|
||||||
@@ -44,15 +45,6 @@ struct BirthdayEditorSheet: View {
|
|||||||
displayedComponents: [.date])
|
displayedComponents: [.date])
|
||||||
Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown)
|
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))
|
.navigationTitle(L10n.t("birthday.new", appLang))
|
||||||
@@ -71,16 +63,21 @@ struct BirthdayEditorSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
calendars = (try? await api.getLocalCalendars()) ?? []
|
calendar = await BirthdaysImporter.birthdayCalendar(api: api)
|
||||||
if selectedCalId == nil { selectedCalId = birthdayCalendars.first?.id }
|
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func activate() async {
|
||||||
|
activating = true
|
||||||
|
calendar = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
|
||||||
|
activating = false
|
||||||
|
}
|
||||||
|
|
||||||
private func save() async {
|
private func save() async {
|
||||||
guard let calId = selectedCalId else { return }
|
guard let cal = calendar else { return }
|
||||||
saving = true
|
saving = true
|
||||||
let cal = Calendar.current
|
let calc = Calendar.current
|
||||||
let comps = cal.dateComponents([.year, .month, .day], from: date)
|
let comps = calc.dateComponents([.year, .month, .day], from: date)
|
||||||
let month = comps.month ?? 1
|
let month = comps.month ?? 1
|
||||||
let day = comps.day ?? 1
|
let day = comps.day ?? 1
|
||||||
let year = yearUnknown ? nil : comps.year
|
let year = yearUnknown ? nil : comps.year
|
||||||
@@ -91,10 +88,10 @@ struct BirthdayEditorSheet: View {
|
|||||||
anchor.month = month
|
anchor.month = month
|
||||||
anchor.day = day
|
anchor.day = day
|
||||||
anchor.hour = 12
|
anchor.hour = 12
|
||||||
let start = cal.date(from: anchor) ?? date
|
let start = calc.date(from: anchor) ?? date
|
||||||
let end = cal.date(byAdding: .day, value: 1, to: start) ?? start
|
let end = calc.date(byAdding: .day, value: 1, to: start) ?? start
|
||||||
_ = try? await api.createLocalEvent(
|
_ = 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: "",
|
start: start, end: end, isAllDay: true, location: "", description: "",
|
||||||
color: nil, rrule: "FREQ=YEARLY", birthYear: year
|
color: nil, rrule: "FREQ=YEARLY", birthYear: year
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -535,6 +535,9 @@ struct CalendarHostView: View {
|
|||||||
/// shows up without the user opening the filter sheet.
|
/// shows up without the user opening the filter sheet.
|
||||||
private func syncFromServer(force: Bool = false) async {
|
private func syncFromServer(force: Bool = false) async {
|
||||||
await SettingsSync.pull(api: api)
|
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)
|
let changed = await store.reconcileCalendarVisibility(api: api)
|
||||||
if changed || force { await forceReload() }
|
if changed || force { await forceReload() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,9 @@ struct SettingsView: View {
|
|||||||
groupVisibleId = s.groupVisibleCalendarId ?? 0
|
groupVisibleId = s.groupVisibleCalendarId ?? 0
|
||||||
}
|
}
|
||||||
if let cals = try? await api.getLocalCalendars() {
|
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