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:
@@ -491,7 +491,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -500,6 +500,7 @@
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@@ -511,7 +512,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.9;
|
||||
MARKETING_VERSION = 3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||
PRODUCT_NAME = "Calendarr iOS";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -535,7 +536,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -544,6 +545,7 @@
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@@ -555,7 +557,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.9;
|
||||
MARKETING_VERSION = 3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||
PRODUCT_NAME = "Calendarr iOS";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -114,11 +114,17 @@ struct LocalCalendar: Codable, Identifiable {
|
||||
var permission: String? = nil
|
||||
var group: Bool = false
|
||||
var remindersEnabled: Bool = true
|
||||
// Birthday calendar: events are all-day yearly; server renders age + cake icon.
|
||||
var isBirthday: Bool = false
|
||||
// Days before a birthday to remind (0 = on the day). nil = no reminder.
|
||||
var birthdayNotifyDaysBefore: Int? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled, owned, permission, group
|
||||
case sharedBy = "shared_by"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
case isBirthday = "is_birthday"
|
||||
case birthdayNotifyDaysBefore = "birthday_notify_days_before"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -132,6 +138,8 @@ struct LocalCalendar: Codable, Identifiable {
|
||||
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
||||
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
||||
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
|
||||
isBirthday = try c.decodeIfPresent(Bool.self, forKey: .isBirthday) ?? false
|
||||
birthdayNotifyDaysBefore = try c.decodeIfPresent(Int.self, forKey: .birthdayNotifyDaysBefore)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,10 +67,17 @@ struct CalEvent: Identifiable, Hashable {
|
||||
var reminders: [Int] = []
|
||||
// True for events from a calendar shared with the user read-only.
|
||||
var readOnly: Bool = false
|
||||
// True for events from a birthday calendar — clients show a cake icon and the
|
||||
// server bakes the age into `displayTitle`.
|
||||
var isBirthday: Bool = false
|
||||
|
||||
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||
|
||||
// Title to render: the server-decorated one (birthday age, group prefix) wins
|
||||
// over the raw title, which is kept for editing.
|
||||
var renderTitle: String { displayTitle ?? title }
|
||||
|
||||
static func from(json: [String: Any]) -> CalEvent? {
|
||||
guard
|
||||
let title = json["title"] as? String,
|
||||
@@ -110,7 +117,8 @@ struct CalEvent: Identifiable, Hashable {
|
||||
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
|
||||
readOnly: json["read_only"] as? Bool ?? false
|
||||
readOnly: json["read_only"] as? Bool ?? false,
|
||||
isBirthday: json["is_birthday"] as? Bool ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -174,3 +182,21 @@ func formatISO(_ date: Date, allDay: Bool) -> String {
|
||||
}
|
||||
return isoBasic.string(from: date)
|
||||
}
|
||||
|
||||
/// Inline label for an event in a calendar grid: a leading birthday (cake) icon
|
||||
/// when the event is a birthday, followed by the render title (which carries the
|
||||
/// server-computed age). Icon and text inherit the surrounding font/colour, so
|
||||
/// the label matches whatever bar it's dropped into.
|
||||
struct EventLabel: View {
|
||||
let event: CalEvent
|
||||
var body: some View {
|
||||
if event.isBirthday {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "birthday.cake.fill").imageScale(.small)
|
||||
Text(event.renderTitle)
|
||||
}
|
||||
} else {
|
||||
Text(event.renderTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +345,29 @@ private let strings: [String: [String: String]] = [
|
||||
"local.color": "Farbe",
|
||||
"local.create": "Erstellen",
|
||||
|
||||
// Birthdays
|
||||
"birthday.new": "Neuer Geburtstag",
|
||||
"birthday.person": "Name",
|
||||
"birthday.person_placeholder": "Name der Person",
|
||||
"birthday.date": "Geburtstag",
|
||||
"birthday.year_unknown": "Jahr unbekannt",
|
||||
"birthday.no_calendars": "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen unter „Konten & Kalender“.",
|
||||
"birthday.is_calendar": "Geburtstagskalender",
|
||||
"birthday.is_calendar.desc": "Ganztägige, jährliche Termine mit Alter und Geburtstags-Icon.",
|
||||
"birthday.notify": "Erinnerung",
|
||||
"birthday.notify.off": "Aus",
|
||||
"birthday.notify.same_day": "Am Tag",
|
||||
"birthday.notify.one_day": "1 Tag vorher",
|
||||
"birthday.notify.days": "%d Tage vorher",
|
||||
"birthday.contacts.header": "Geburtstage aus Kontakten",
|
||||
"birthday.contacts.sync": "Aus Kontakten synchronisieren",
|
||||
"birthday.contacts.target": "Zielkalender",
|
||||
"birthday.contacts.sync_now": "Jetzt synchronisieren",
|
||||
"birthday.contacts.synced": "Geburtstage synchronisiert",
|
||||
"birthday.contacts.hint": "Überträgt Geburtstage aus deinen Kontakten in den gewählten Geburtstagskalender – sichtbar auf allen Geräten.",
|
||||
"birthday.contacts.denied": "Kein Zugriff auf Kontakte. Bitte in den iOS-Einstellungen erlauben.",
|
||||
"birthday.contacts.need_calendar": "Erstelle zuerst einen Geburtstagskalender.",
|
||||
|
||||
// iCal add sheet
|
||||
"ical.title": "iCal abonnieren",
|
||||
"ical.subscription": "Abonnement",
|
||||
@@ -677,6 +700,29 @@ private let strings: [String: [String: String]] = [
|
||||
"local.color": "Color",
|
||||
"local.create": "Create",
|
||||
|
||||
// Birthdays
|
||||
"birthday.new": "New birthday",
|
||||
"birthday.person": "Name",
|
||||
"birthday.person_placeholder": "Person's name",
|
||||
"birthday.date": "Birthday",
|
||||
"birthday.year_unknown": "Year unknown",
|
||||
"birthday.no_calendars": "No birthday calendar yet. Create one first under “Accounts & Calendars”.",
|
||||
"birthday.is_calendar": "Birthday calendar",
|
||||
"birthday.is_calendar.desc": "All-day, yearly events with age and a birthday icon.",
|
||||
"birthday.notify": "Reminder",
|
||||
"birthday.notify.off": "Off",
|
||||
"birthday.notify.same_day": "On the day",
|
||||
"birthday.notify.one_day": "1 day before",
|
||||
"birthday.notify.days": "%d days before",
|
||||
"birthday.contacts.header": "Birthdays from Contacts",
|
||||
"birthday.contacts.sync": "Sync from Contacts",
|
||||
"birthday.contacts.target": "Target calendar",
|
||||
"birthday.contacts.sync_now": "Sync now",
|
||||
"birthday.contacts.synced": "Birthdays synced",
|
||||
"birthday.contacts.hint": "Copies birthdays from your contacts into the chosen birthday calendar – visible on all devices.",
|
||||
"birthday.contacts.denied": "No access to Contacts. Please allow it in iOS Settings.",
|
||||
"birthday.contacts.need_calendar": "Create a birthday calendar first.",
|
||||
|
||||
// iCal add sheet
|
||||
"ical.title": "Subscribe to iCal",
|
||||
"ical.subscription": "Subscription",
|
||||
|
||||
168
Calendarr iOS/Services/BirthdaysImporter.swift
Normal file
168
Calendarr iOS/Services/BirthdaysImporter.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
|
||||
/// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays.
|
||||
/// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`.
|
||||
struct BirthdayEntry: Codable, Identifiable {
|
||||
let uid: String
|
||||
let externalUid: String?
|
||||
let title: String
|
||||
let month: Int?
|
||||
let day: Int?
|
||||
let birthYear: Int?
|
||||
var id: String { uid }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uid, title, month, day
|
||||
case externalUid = "external_uid"
|
||||
case birthYear = "birth_year"
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads birthdays from the system Contacts and mirrors them into a chosen
|
||||
/// birthday `LocalCalendar` on the backend, so they show on every client.
|
||||
///
|
||||
/// The sync is a **mirror**: it reconciles contact-sourced rows by
|
||||
/// `external_uid` (adds new, updates changed, deletes removed) and never touches
|
||||
/// manually added birthdays (which have no `external_uid`). The heavy lifting —
|
||||
/// age suffix, cake icon, "notify N days before" reminder — is done server-side;
|
||||
/// this only uploads name + date + birth year.
|
||||
enum BirthdaysImporter {
|
||||
|
||||
// MARK: – Persisted binding (which calendar receives Contacts birthdays)
|
||||
|
||||
enum Key {
|
||||
static let enabled = "birthdaysSyncEnabled" // Bool
|
||||
static let calendarId = "birthdaysSyncCalendarId" // Int (0 = none)
|
||||
}
|
||||
|
||||
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 setTargetCalendarId(_ id: Int?) {
|
||||
UserDefaults.standard.set(id ?? 0, forKey: Key.calendarId)
|
||||
}
|
||||
|
||||
// MARK: – Contacts access
|
||||
|
||||
static var isAuthorized: Bool {
|
||||
let s = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if s == .authorized { return true }
|
||||
if #available(iOS 18.0, *), s == .limited { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Request Contacts access once. Returns true if usable for enumeration.
|
||||
@discardableResult
|
||||
static func requestAccess() async -> Bool {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if status == .authorized { return true }
|
||||
if #available(iOS 18.0, *), status == .limited { return true }
|
||||
guard status == .notDetermined else { return false }
|
||||
return await withCheckedContinuation { cont in
|
||||
CNContactStore().requestAccess(for: .contacts) { granted, _ in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Reading contact birthdays
|
||||
|
||||
struct ContactBirthday {
|
||||
let externalUid: String // "contact:<identifier>"
|
||||
let name: String
|
||||
let month: Int
|
||||
let day: Int
|
||||
let year: Int? // nil = year unknown
|
||||
}
|
||||
|
||||
static func readContactBirthdays() throws -> [ContactBirthday] {
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactBirthdayKey as CNKeyDescriptor,
|
||||
]
|
||||
let req = CNContactFetchRequest(keysToFetch: keys)
|
||||
var out: [ContactBirthday] = []
|
||||
try store.enumerateContacts(with: req) { contact, _ in
|
||||
guard let bday = contact.birthday, let m = bday.month, let d = bday.day else { return }
|
||||
var name = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
|
||||
if name.isEmpty {
|
||||
name = [contact.givenName, contact.familyName]
|
||||
.filter { !$0.isEmpty }.joined(separator: " ")
|
||||
}
|
||||
if name.isEmpty { name = contact.organizationName }
|
||||
guard !name.isEmpty else { return }
|
||||
let year = bday.year.flatMap { $0 > 0 ? $0 : nil }
|
||||
out.append(ContactBirthday(
|
||||
externalUid: "contact:\(contact.identifier)",
|
||||
name: name, month: m, day: d, year: year
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: – Sync
|
||||
|
||||
/// Mirror the address book into the bound birthday calendar. No-op unless the
|
||||
/// sync is enabled, a target calendar is set, and access is granted.
|
||||
static func sync(api: CalendarrAPI) async {
|
||||
guard isEnabled, let calId = targetCalendarId else { return }
|
||||
guard await requestAccess() else { return }
|
||||
guard let contacts = try? readContactBirthdays() else { return }
|
||||
guard let existing = try? await api.getBirthdayEntries(calendarId: calId) else { return }
|
||||
|
||||
// Only reconcile contact-sourced rows; leave manual entries untouched.
|
||||
var byExt: [String: BirthdayEntry] = [:]
|
||||
for e in existing { if let ext = e.externalUid { byExt[ext] = e } }
|
||||
|
||||
var seen = Set<String>()
|
||||
for c in contacts {
|
||||
seen.insert(c.externalUid)
|
||||
let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year)
|
||||
if let match = byExt[c.externalUid] {
|
||||
let changed = match.title != c.name || match.month != c.month
|
||||
|| match.day != c.day || match.birthYear != c.year
|
||||
if changed {
|
||||
try? await api.updateLocalEvent(
|
||||
uid: match.uid, title: c.name, start: start, end: end,
|
||||
isAllDay: true, location: "", description: "", color: nil,
|
||||
rrule: "FREQ=YEARLY", externalUid: c.externalUid,
|
||||
birthYear: c.year ?? -1 // -1 clears birth_year server-side
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_ = try? await api.createLocalEvent(
|
||||
calendarId: calId, title: c.name, start: start, end: end,
|
||||
isAllDay: true, location: "", description: "", color: nil,
|
||||
rrule: "FREQ=YEARLY", externalUid: c.externalUid, birthYear: c.year
|
||||
)
|
||||
}
|
||||
}
|
||||
// Remove contact-sourced rows whose contact no longer has a birthday.
|
||||
for (ext, entry) in byExt where !seen.contains(ext) {
|
||||
try? await api.deleteLocalEvent(uid: entry.uid)
|
||||
}
|
||||
}
|
||||
|
||||
/// All-day [start, end) for a birthday. Anchor year = birth year when known,
|
||||
/// else 1970 so FREQ=YEARLY expands across any queried range. Built at local
|
||||
/// noon so day-only formatting can't roll to an adjacent day.
|
||||
private static func allDayRange(month: Int, day: Int, year: Int?) -> (Date, Date) {
|
||||
var comp = DateComponents()
|
||||
comp.year = year ?? 1970
|
||||
comp.month = month
|
||||
comp.day = day
|
||||
comp.hour = 12
|
||||
let cal = Calendar.current
|
||||
let start = cal.date(from: comp) ?? Date()
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start) ?? start
|
||||
return (start, end)
|
||||
}
|
||||
}
|
||||
@@ -135,8 +135,13 @@ class CalendarrAPI {
|
||||
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func addLocalCalendar(name: String, color: String) async throws -> LocalCalendar {
|
||||
let data = try await request("/api/local/calendars", method: "POST", body: ["name": name, "color": color])
|
||||
func addLocalCalendar(name: String, color: String,
|
||||
isBirthday: Bool = false,
|
||||
birthdayNotifyDaysBefore: Int? = nil) async throws -> LocalCalendar {
|
||||
var body: [String: Any] = ["name": name, "color": color]
|
||||
if isBirthday { body["is_birthday"] = true }
|
||||
if let d = birthdayNotifyDaysBefore { body["birthday_notify_days_before"] = d }
|
||||
let data = try await request("/api/local/calendars", method: "POST", body: body)
|
||||
guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError }
|
||||
return cal
|
||||
}
|
||||
@@ -145,6 +150,24 @@ class CalendarrAPI {
|
||||
_ = try await request("/api/local/calendars/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
/// Update a local calendar's birthday settings. `is_birthday` marks it as a
|
||||
/// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1
|
||||
/// sentinel to clear it server-side).
|
||||
func updateLocalCalendarBirthday(id: Int, isBirthday: Bool?, notifyDaysBefore: Int??) async throws {
|
||||
var body: [String: Any] = [:]
|
||||
if let b = isBirthday { body["is_birthday"] = b }
|
||||
if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n ?? -1 }
|
||||
guard !body.isEmpty else { return }
|
||||
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
/// Raw (unexpanded) rows of a birthday calendar, used by the Contacts
|
||||
/// importer to reconcile by `external_uid`.
|
||||
func getBirthdayEntries(calendarId: Int) async throws -> [BirthdayEntry] {
|
||||
let data = try await request("/api/local/calendars/\(calendarId)/birthdays")
|
||||
return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func getICalSubscriptions() async throws -> [ICalSubscription] {
|
||||
let data = try await request("/api/ical/subscriptions")
|
||||
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
|
||||
@@ -236,7 +259,9 @@ class CalendarrAPI {
|
||||
|
||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil,
|
||||
rrule: String? = nil, externalUid: String? = nil,
|
||||
birthYear: Int? = nil) async throws -> CalEvent {
|
||||
var body: [String: Any] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
@@ -249,6 +274,9 @@ class CalendarrAPI {
|
||||
]
|
||||
if let c = color, !c.isEmpty { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
if let rrule, !rrule.isEmpty { body["rrule"] = rrule }
|
||||
if let externalUid { body["external_uid"] = externalUid }
|
||||
if let birthYear { body["birth_year"] = birthYear }
|
||||
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
||||
@@ -257,7 +285,9 @@ class CalendarrAPI {
|
||||
|
||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil,
|
||||
rrule: String? = nil, externalUid: String? = nil,
|
||||
birthYear: Int? = nil) async throws {
|
||||
var body: [String: Any] = [
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
@@ -269,6 +299,9 @@ class CalendarrAPI {
|
||||
]
|
||||
if let c = color { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
if let rrule { body["rrule"] = rrule }
|
||||
if let externalUid { body["external_uid"] = externalUid }
|
||||
if let birthYear { body["birth_year"] = birthYear }
|
||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ struct AccountsView: View {
|
||||
@State private var exportDoc: ExportedICS?
|
||||
@State private var infoMessage: String?
|
||||
|
||||
// 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
|
||||
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
var body: some View {
|
||||
@@ -36,10 +41,14 @@ struct AccountsView: View {
|
||||
if !banishedKeys.isEmpty { banishedSection }
|
||||
caldavSection
|
||||
localSection
|
||||
birthdayContactsSection
|
||||
icalSection
|
||||
googleSection
|
||||
haSection
|
||||
}
|
||||
.onChange(of: birthdaysSyncEnabled) { _, on in
|
||||
if on { Task { _ = await BirthdaysImporter.requestAccess() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.t("accounts.title", appLang))
|
||||
@@ -213,6 +222,52 @@ struct AccountsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var birthdayCalendars: [LocalCalendar] {
|
||||
localCalendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") }
|
||||
}
|
||||
|
||||
@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() }
|
||||
}
|
||||
}
|
||||
.disabled(isSyncingBirthdays || birthdaysSyncCalendarId == 0)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.t("birthday.contacts.header", appLang))
|
||||
} footer: {
|
||||
Text(L10n.t("birthday.contacts.hint", appLang))
|
||||
}
|
||||
}
|
||||
|
||||
private func syncBirthdays() async {
|
||||
isSyncingBirthdays = true
|
||||
defer { isSyncingBirthdays = false }
|
||||
guard await BirthdaysImporter.requestAccess() else {
|
||||
errorAlert = L10n.t("birthday.contacts.denied", appLang)
|
||||
return
|
||||
}
|
||||
await BirthdaysImporter.sync(api: api)
|
||||
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)
|
||||
}
|
||||
|
||||
var icalSection: some View {
|
||||
Section {
|
||||
if icalSubs.isEmpty {
|
||||
@@ -419,6 +474,10 @@ 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
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@@ -536,6 +595,8 @@ 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 = ""
|
||||
|
||||
@@ -546,6 +607,14 @@ 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) }
|
||||
}
|
||||
@@ -568,7 +637,10 @@ struct AddLocalCalSheet: View {
|
||||
private func save() async {
|
||||
isLoading = true
|
||||
do {
|
||||
_ = try await api.addLocalCalendar(name: name, color: color.toHex())
|
||||
_ = try await api.addLocalCalendar(
|
||||
name: name, color: color.toHex(),
|
||||
isBirthday: isBirthday,
|
||||
birthdayNotifyDaysBefore: (isBirthday && notifyDays >= 0) ? notifyDays : nil)
|
||||
await onDone()
|
||||
dismiss()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
@@ -576,6 +648,24 @@ struct AddLocalCalSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable "notify N days before" picker for birthday calendars.
|
||||
/// -1 = off, 0 = on the day, N = N days before.
|
||||
struct BirthdayNotifyPicker: View {
|
||||
@Binding var days: Int
|
||||
let appLang: String
|
||||
|
||||
var body: some View {
|
||||
Picker(L10n.t("birthday.notify", appLang), selection: $days) {
|
||||
Text(L10n.t("birthday.notify.off", appLang)).tag(-1)
|
||||
Text(L10n.t("birthday.notify.same_day", appLang)).tag(0)
|
||||
Text(L10n.t("birthday.notify.one_day", appLang)).tag(1)
|
||||
ForEach([2, 3, 7], id: \.self) { d in
|
||||
Text(String(format: L10n.t("birthday.notify.days", appLang), d)).tag(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddICalSheet: View {
|
||||
let api: CalendarrAPI
|
||||
let onDone: () async -> Void
|
||||
|
||||
@@ -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