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;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -500,6 +500,7 @@
|
|||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
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_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -511,7 +512,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.9;
|
MARKETING_VERSION = 3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -535,7 +536,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -544,6 +545,7 @@
|
|||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
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_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -555,7 +557,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.9;
|
MARKETING_VERSION = 3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -114,11 +114,17 @@ struct LocalCalendar: Codable, Identifiable {
|
|||||||
var permission: String? = nil
|
var permission: String? = nil
|
||||||
var group: Bool = false
|
var group: Bool = false
|
||||||
var remindersEnabled: Bool = true
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, color, enabled, owned, permission, group
|
case id, name, color, enabled, owned, permission, group
|
||||||
case sharedBy = "shared_by"
|
case sharedBy = "shared_by"
|
||||||
case remindersEnabled = "reminders_enabled"
|
case remindersEnabled = "reminders_enabled"
|
||||||
|
case isBirthday = "is_birthday"
|
||||||
|
case birthdayNotifyDaysBefore = "birthday_notify_days_before"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -132,6 +138,8 @@ struct LocalCalendar: Codable, Identifiable {
|
|||||||
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
||||||
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
||||||
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
|
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] = []
|
var reminders: [Int] = []
|
||||||
// True for events from a calendar shared with the user read-only.
|
// True for events from a calendar shared with the user read-only.
|
||||||
var readOnly: Bool = false
|
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.
|
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||||
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
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? {
|
static func from(json: [String: Any]) -> CalEvent? {
|
||||||
guard
|
guard
|
||||||
let title = json["title"] as? String,
|
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 },
|
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||||
displayTitle: (json["display_title"] 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)") } ?? [],
|
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)
|
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.color": "Farbe",
|
||||||
"local.create": "Erstellen",
|
"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 add sheet
|
||||||
"ical.title": "iCal abonnieren",
|
"ical.title": "iCal abonnieren",
|
||||||
"ical.subscription": "Abonnement",
|
"ical.subscription": "Abonnement",
|
||||||
@@ -677,6 +700,29 @@ private let strings: [String: [String: String]] = [
|
|||||||
"local.color": "Color",
|
"local.color": "Color",
|
||||||
"local.create": "Create",
|
"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 add sheet
|
||||||
"ical.title": "Subscribe to iCal",
|
"ical.title": "Subscribe to iCal",
|
||||||
"ical.subscription": "Subscription",
|
"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)) ?? []
|
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
func addLocalCalendar(name: String, color: String) async throws -> LocalCalendar {
|
func addLocalCalendar(name: String, color: String,
|
||||||
let data = try await request("/api/local/calendars", method: "POST", body: ["name": name, "color": color])
|
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 }
|
guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError }
|
||||||
return cal
|
return cal
|
||||||
}
|
}
|
||||||
@@ -145,6 +150,24 @@ class CalendarrAPI {
|
|||||||
_ = try await request("/api/local/calendars/\(id)", method: "DELETE")
|
_ = 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] {
|
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)) ?? []
|
||||||
@@ -236,7 +259,9 @@ class CalendarrAPI {
|
|||||||
|
|
||||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?,
|
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] = [
|
var body: [String: Any] = [
|
||||||
"calendar_id": calendarId,
|
"calendar_id": calendarId,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -249,6 +274,9 @@ class CalendarrAPI {
|
|||||||
]
|
]
|
||||||
if let c = color, !c.isEmpty { body["color"] = c }
|
if let c = color, !c.isEmpty { body["color"] = c }
|
||||||
if let reminders { body["reminders"] = reminders }
|
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)
|
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
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,
|
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?,
|
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] = [
|
var body: [String: Any] = [
|
||||||
"title": title,
|
"title": title,
|
||||||
"start": formatISO(start, allDay: isAllDay),
|
"start": formatISO(start, allDay: isAllDay),
|
||||||
@@ -269,6 +299,9 @@ class CalendarrAPI {
|
|||||||
]
|
]
|
||||||
if let c = color { body["color"] = c }
|
if let c = color { body["color"] = c }
|
||||||
if let reminders { body["reminders"] = reminders }
|
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)
|
_ = 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 exportDoc: ExportedICS?
|
||||||
@State private var infoMessage: String?
|
@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"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -36,10 +41,14 @@ struct AccountsView: View {
|
|||||||
if !banishedKeys.isEmpty { banishedSection }
|
if !banishedKeys.isEmpty { banishedSection }
|
||||||
caldavSection
|
caldavSection
|
||||||
localSection
|
localSection
|
||||||
|
birthdayContactsSection
|
||||||
icalSection
|
icalSection
|
||||||
googleSection
|
googleSection
|
||||||
haSection
|
haSection
|
||||||
}
|
}
|
||||||
|
.onChange(of: birthdaysSyncEnabled) { _, on in
|
||||||
|
if on { Task { _ = await BirthdaysImporter.requestAccess() } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.t("accounts.title", appLang))
|
.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 {
|
var icalSection: some View {
|
||||||
Section {
|
Section {
|
||||||
if icalSubs.isEmpty {
|
if icalSubs.isEmpty {
|
||||||
@@ -419,6 +474,10 @@ 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.
|
||||||
|
if birthdaysSyncCalendarId == 0, let first = birthdayCalendars.first?.id {
|
||||||
|
birthdaysSyncCalendarId = first
|
||||||
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +595,8 @@ 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 = ""
|
||||||
|
|
||||||
@@ -546,6 +607,14 @@ 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) }
|
||||||
}
|
}
|
||||||
@@ -568,7 +637,10 @@ struct AddLocalCalSheet: View {
|
|||||||
private func save() async {
|
private func save() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
do {
|
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()
|
await onDone()
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch { self.error = error.localizedDescription }
|
} 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 {
|
struct AddICalSheet: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let onDone: () async -> Void
|
let onDone: () async -> Void
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ private struct AgendaEventRow: View {
|
|||||||
.frame(width: 4, height: 40)
|
.frame(width: 4, height: 40)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(event.title)
|
EventLabel(event: event)
|
||||||
.font(.body.weight(.medium))
|
.font(.body.weight(.medium))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
HStack(spacing: 6) {
|
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 showFilter = false
|
||||||
@State private var didApplyDefaultView = false
|
@State private var didApplyDefaultView = false
|
||||||
@State private var groups: [CalGroup] = []
|
@State private var groups: [CalGroup] = []
|
||||||
|
@State private var showNewBirthday = false
|
||||||
|
|
||||||
private var titleString: String {
|
private var titleString: String {
|
||||||
if store.viewType == .month {
|
if store.viewType == .month {
|
||||||
@@ -380,6 +381,21 @@ struct CalendarHostView: View {
|
|||||||
|
|
||||||
// MARK: – FAB buttons
|
// 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)
|
/// Standard solid FAB (flat mode)
|
||||||
private var solidFAB: some View {
|
private var solidFAB: some View {
|
||||||
Button {
|
Button {
|
||||||
@@ -393,6 +409,7 @@ struct CalendarHostView: View {
|
|||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.shadow(radius: 4, y: 2)
|
.shadow(radius: 4, y: 2)
|
||||||
}
|
}
|
||||||
|
.contextMenu { fabMenu }
|
||||||
.padding(.trailing, 20).padding(.bottom, 20)
|
.padding(.trailing, 20).padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +427,7 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.glassEffect(in: Circle())
|
.glassEffect(in: Circle())
|
||||||
|
.contextMenu { fabMenu }
|
||||||
.padding(.trailing, 20).padding(.bottom, 20)
|
.padding(.trailing, 20).padding(.bottom, 20)
|
||||||
} else {
|
} else {
|
||||||
solidFAB
|
solidFAB
|
||||||
@@ -421,6 +439,7 @@ struct CalendarHostView: View {
|
|||||||
private var calendarSheets: CalendarSheets {
|
private var calendarSheets: CalendarSheets {
|
||||||
CalendarSheets(store: store, editorContext: $editorContext,
|
CalendarSheets(store: store, editorContext: $editorContext,
|
||||||
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
||||||
|
showNewBirthday: $showNewBirthday,
|
||||||
api: api,
|
api: api,
|
||||||
reload: { await onNavigate() },
|
reload: { await onNavigate() },
|
||||||
reloadForce: { await reloadVisible(force: true) })
|
reloadForce: { await reloadVisible(force: true) })
|
||||||
@@ -449,6 +468,14 @@ struct CalendarHostView: View {
|
|||||||
Task(priority: .background) {
|
Task(priority: .background) {
|
||||||
await store.prefetchBackground(api: api, months: cacheMonths)
|
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).
|
// 3. Periodic settings + visibility pull (tied to this .task's lifetime).
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .seconds(600))
|
try? await Task.sleep(for: .seconds(600))
|
||||||
@@ -538,12 +565,16 @@ private struct CalendarSheets: ViewModifier {
|
|||||||
@Binding var editorContext: CalEditorContext?
|
@Binding var editorContext: CalEditorContext?
|
||||||
@Binding var selectedEvent: CalEvent?
|
@Binding var selectedEvent: CalEvent?
|
||||||
@Binding var showFilter: Bool
|
@Binding var showFilter: Bool
|
||||||
|
@Binding var showNewBirthday: Bool
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let reload: () async -> Void
|
let reload: () async -> Void
|
||||||
let reloadForce: () async -> Void
|
let reloadForce: () async -> Void
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
|
.sheet(isPresented: $showNewBirthday) {
|
||||||
|
BirthdayEditorSheet(api: api) { await reloadForce() }
|
||||||
|
}
|
||||||
// Use sheet(item:) so the editing event is captured atomically –
|
// Use sheet(item:) so the editing event is captured atomically –
|
||||||
// avoiding the race where sheet(isPresented:) evaluates its content
|
// avoiding the race where sheet(isPresented:) evaluates its content
|
||||||
// before the editingEvent state update propagates.
|
// before the editingEvent state update propagates.
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ struct DayView: View {
|
|||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(allDayEvents) { ev in
|
ForEach(allDayEvents) { ev in
|
||||||
Button(action: { onEventTap(ev) }) {
|
Button(action: { onEventTap(ev) }) {
|
||||||
Text(ev.title)
|
EventLabel(event: ev)
|
||||||
.font(.caption.weight(.medium))
|
.font(.caption.weight(.medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ struct EventDetailSheet: View {
|
|||||||
.fill(Color(hex: event.effectiveColor))
|
.fill(Color(hex: event.effectiveColor))
|
||||||
.frame(width: 6, height: 44)
|
.frame(width: 6, height: 44)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(event.title)
|
Text(event.renderTitle)
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
Text(event.calendarName)
|
Text(event.calendarName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -156,7 +156,7 @@ struct EventDetailSheet: View {
|
|||||||
}
|
}
|
||||||
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
|
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text("\"\(event.title)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
|
Text("\"\(event.renderTitle)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showCopySheet) {
|
.sheet(isPresented: $showCopySheet) {
|
||||||
EventEditorSheet(
|
EventEditorSheet(
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ private struct DayPreviewAllDayBar: View {
|
|||||||
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
|
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
|
||||||
let cLeft = event.startDate < dayStart
|
let cLeft = event.startDate < dayStart
|
||||||
let cRight = event.endDate > dayEnd // endDate is exclusive for allDay
|
let cRight = event.endDate > dayEnd // endDate is exclusive for allDay
|
||||||
Text(event.title)
|
EventLabel(event: event)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -580,7 +580,7 @@ private struct DayPreviewTimedRow: View {
|
|||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 44, alignment: .leading)
|
.frame(width: 44, alignment: .leading)
|
||||||
Text(event.title)
|
EventLabel(event: event)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -604,7 +604,7 @@ private struct EventBar: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
Text(event.title)
|
EventLabel(event: event)
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.system(size: 10, weight: .medium))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ struct EventBlock: View {
|
|||||||
.fill(Color(hex: event.effectiveColor).opacity(0.85))
|
.fill(Color(hex: event.effectiveColor).opacity(0.85))
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(event.title)
|
EventLabel(event: event)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ struct WeekView: View {
|
|||||||
VStack(spacing: 1) {
|
VStack(spacing: 1) {
|
||||||
ForEach(dayEvs.prefix(2)) { ev in
|
ForEach(dayEvs.prefix(2)) { ev in
|
||||||
Button { onEventTap(ev) } label: {
|
Button { onEventTap(ev) } label: {
|
||||||
Text(ev.title)
|
EventLabel(event: ev)
|
||||||
.font(.system(size: 9, weight: .medium))
|
.font(.system(size: 9, weight: .medium))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user