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:
@@ -229,7 +229,7 @@ class CalendarrAPI {
|
||||
|
||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false) async throws -> CalEvent {
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
|
||||
var body: [String: Any] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
@@ -241,6 +241,7 @@ class CalendarrAPI {
|
||||
"private": isPrivate
|
||||
]
|
||||
if let c = color, !c.isEmpty { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
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 }
|
||||
@@ -249,7 +250,7 @@ class CalendarrAPI {
|
||||
|
||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false) async throws {
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
|
||||
var body: [String: Any] = [
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
@@ -260,6 +261,7 @@ class CalendarrAPI {
|
||||
"private": isPrivate
|
||||
]
|
||||
if let c = color { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
|
||||
65
Calendarr iOS/Services/NotificationScheduler.swift
Normal file
65
Calendarr iOS/Services/NotificationScheduler.swift
Normal 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: " · ")
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ enum SettingsSync {
|
||||
static let defaultView = "defaultView"
|
||||
static let weekStartDay = "weekStartDay"
|
||||
static let dimPastEvents = "dimPastEvents"
|
||||
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
||||
// master switch
|
||||
static let enabled = "settingsSync"
|
||||
}
|
||||
@@ -71,6 +72,8 @@ enum SettingsSync {
|
||||
s.defaultView = str(Key.defaultView, "month")
|
||||
s.weekStartDay = str(Key.weekStartDay, "monday")
|
||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
||||
let rem = int(Key.defaultReminder, -1)
|
||||
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ enum SettingsSync {
|
||||
d.set(s.defaultView, forKey: Key.defaultView)
|
||||
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
||||
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
||||
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
||||
guard includeOptional else { return }
|
||||
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
||||
// synced – the server has no columns for them (iOS-only). Writing the
|
||||
@@ -134,6 +138,7 @@ enum SettingsSync {
|
||||
merged.defaultView = local.defaultView
|
||||
merged.weekStartDay = local.weekStartDay
|
||||
merged.dimPastEvents = local.dimPastEvents
|
||||
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
||||
if isEnabled {
|
||||
merged.primaryColor = local.primaryColor
|
||||
merged.accentColor = local.accentColor
|
||||
|
||||
Reference in New Issue
Block a user