From cf990e42798c52e1b90bb5d53f3c6481b8bf65c2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 12 Jul 2026 21:58:31 +0200 Subject: [PATCH] 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 --- Calendarr iOS.xcodeproj/project.pbxproj | 10 +- Calendarr iOS/Models/AppSettings.swift | 8 + Calendarr iOS/Models/CalEvent.swift | 28 ++- Calendarr iOS/Models/Localization.swift | 46 +++++ .../Services/BirthdaysImporter.swift | 168 ++++++++++++++++++ Calendarr iOS/Services/CalendarrAPI.swift | 41 ++++- Calendarr iOS/Views/AccountsView.swift | 92 +++++++++- Calendarr iOS/Views/Calendar/AgendaView.swift | 2 +- .../Views/Calendar/BirthdayEditorSheet.swift | 104 +++++++++++ .../Views/Calendar/CalendarHostView.swift | 31 ++++ Calendarr iOS/Views/Calendar/DayView.swift | 2 +- .../Views/Calendar/EventDetailSheet.swift | 4 +- Calendarr iOS/Views/Calendar/MonthView.swift | 6 +- .../Views/Calendar/TimeGridView.swift | 2 +- Calendarr iOS/Views/Calendar/WeekView.swift | 2 +- 15 files changed, 527 insertions(+), 19 deletions(-) create mode 100644 Calendarr iOS/Services/BirthdaysImporter.swift create mode 100644 Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 7b0fb67..4f81040 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -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; diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 2f3e7c3..3e57518 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -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) } } diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index e53f30a..94ae950 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -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) + } + } +} diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 629601f..811fdff 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -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", diff --git a/Calendarr iOS/Services/BirthdaysImporter.swift b/Calendarr iOS/Services/BirthdaysImporter.swift new file mode 100644 index 0000000..8458c5c --- /dev/null +++ b/Calendarr iOS/Services/BirthdaysImporter.swift @@ -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:" + 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() + 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) + } +} diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index 444f6b5..a52d95c 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -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) } diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 99b005c..2aafd83 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -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 diff --git a/Calendarr iOS/Views/Calendar/AgendaView.swift b/Calendarr iOS/Views/Calendar/AgendaView.swift index 081650d..fe57864 100644 --- a/Calendarr iOS/Views/Calendar/AgendaView.swift +++ b/Calendarr iOS/Views/Calendar/AgendaView.swift @@ -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) { diff --git a/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift b/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift new file mode 100644 index 0000000..c54293b --- /dev/null +++ b/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift @@ -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() + } +} diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index d674889..9e1b645 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -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. diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift index d72664a..e78064c 100644 --- a/Calendarr iOS/Views/Calendar/DayView.swift +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -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) diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index 9816188..7e80c0f 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -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( diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index ba764be..c662b97 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -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) diff --git a/Calendarr iOS/Views/Calendar/TimeGridView.swift b/Calendarr iOS/Views/Calendar/TimeGridView.swift index 7cbafc3..b3242ce 100644 --- a/Calendarr iOS/Views/Calendar/TimeGridView.swift +++ b/Calendarr iOS/Views/Calendar/TimeGridView.swift @@ -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) diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index 19c53e5..8fd8a98 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -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)