feat: event reminders + default reminder setting + local notifications (iOS)

Per-event reminders (multiple, local calendars only) in the editor, prefilled
from a new "default reminder" setting that applies to all events otherwise.
CalEvent gains `reminders`; AppSettings/SettingsSync sync default_reminder_minutes
(always group). New NotificationScheduler requests permission and schedules the
soonest ≤60 upcoming reminders via UNUserNotificationCenter, rescheduling on
load/sync/edit and when the default changes (skipped in group overlay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-06-06 16:21:08 +02:00
parent e7d8effb47
commit 587a0e65fa
10 changed files with 187 additions and 5 deletions

View File

@@ -0,0 +1,65 @@
import Foundation
import UserNotifications
extension Notification.Name {
/// Posted when the default-reminder setting changes, so the calendar host
/// can recompute the scheduled local notifications from its cached events.
static let rescheduleReminders = Notification.Name("rescheduleReminders")
}
/// Schedules local OS notifications for upcoming events. Per-event reminders
/// (local events) take precedence; otherwise the user's default reminder applies
/// to every event (incl. external). Re-run whenever events or the default change.
enum NotificationScheduler {
static func requestAuthorizationIfNeeded() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
}
/// Recompute and (re)schedule notifications from the given events. The iOS
/// pending-notification cap is 64, so only the soonest are scheduled.
static func reschedule(events: [CalEvent]) {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized
|| settings.authorizationStatus == .provisional else { return }
let defaultMin = (UserDefaults.standard.object(forKey: "defaultReminderMinutes") as? Int) ?? -1
let now = Date()
var pending: [(fire: Date, event: CalEvent)] = []
for ev in events {
let offsets = ev.reminders.isEmpty
? (defaultMin >= 0 ? [defaultMin] : [])
: ev.reminders
for m in offsets {
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
if fire > now { pending.append((fire, ev)) }
}
}
pending.sort { $0.fire < $1.fire }
let limited = pending.prefix(60) // stay safely under the 64 system cap
center.removeAllPendingNotificationRequests()
for item in limited {
let content = UNMutableNotificationContent()
content.title = item.event.title
content.body = bodyText(item.event)
content.sound = .default
let comps = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger))
}
}
}
private static func bodyText(_ ev: CalEvent) -> String {
var parts: [String] = []
if !ev.isAllDay {
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none
parts.append(f.string(from: ev.startDate))
}
if !ev.location.isEmpty { parts.append(ev.location) }
return parts.joined(separator: " · ")
}
}