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

@@ -18,6 +18,7 @@ struct AppSettings: Codable {
var lineColor: String = "#3A3A3C"
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
var groupVisibleCalendarId: Int? = nil
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
enum CodingKeys: String, CodingKey {
case defaultView = "default_view"
@@ -37,6 +38,7 @@ struct AppSettings: Codable {
case lineColor = "line_color"
case privateEventVisibility = "private_event_visibility"
case groupVisibleCalendarId = "group_visible_calendar_id"
case defaultReminderMinutes = "default_reminder_minutes"
}
init() {}
@@ -66,6 +68,7 @@ struct AppSettings: Codable {
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
}
}

View File

@@ -41,6 +41,8 @@ struct CalEvent: Identifiable, Hashable {
// Server-decorated title for the group combined view (group icon / owner
// prefix); rendered in group mode while `title` stays raw for editing.
var displayTitle: String? = nil
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
var reminders: [Int] = []
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
var effectiveColor: String { displayColor ?? color ?? calendarColor }
@@ -82,7 +84,8 @@ struct CalEvent: Identifiable, Hashable {
owner: EventPerson.from(json["owner"]),
isGroupEvent: json["is_group_event"] as? Bool ?? false,
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)") } ?? []
)
}
}

View File

@@ -264,6 +264,15 @@ class CalendarStore {
return !hiddenCalendarKeys.contains(key)
&& !banishedCalendarKeys.contains(key)
}
// Personal events drive local reminder notifications.
NotificationScheduler.reschedule(events: allCachedEvents)
}
/// Recompute scheduled reminder notifications from the personal cache
/// (skipped while a group overlay is active).
func rescheduleNotifications() {
guard activeGroup == nil else { return }
NotificationScheduler.reschedule(events: allCachedEvents)
}
/// Optimistically drop a just-deleted event from the cache so it disappears

View File

@@ -0,0 +1,37 @@
import Foundation
/// Reminder offset options (minutes before an event's start; 0 = at start) and
/// their localized labels. Shared by the event editor, the settings default
/// picker, and the notification scheduler so the choices stay consistent.
enum ReminderOptions {
/// Selectable offsets in minutes-before-start.
static let all: [Int] = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]
private static func isEnglish(_ appLang: String) -> Bool {
if appLang == "en" { return true }
if appLang == "de" { return false }
return (Locale.current.language.languageCode?.identifier ?? "de").hasPrefix("en")
}
static func label(_ minutes: Int, _ appLang: String) -> String {
let en = isEnglish(appLang)
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
if minutes < 60 { return en ? "\(minutes) min before" : "\(minutes) Min. vorher" }
if minutes < 1440 {
let h = minutes / 60
return en ? "\(h) h before" : "\(h) Std. vorher"
}
let d = minutes / 1440
return en ? "\(d) day\(d == 1 ? "" : "s") before" : "\(d) Tag\(d == 1 ? "" : "e") vorher"
}
static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" }
static func addLabel(_ l: String) -> String { isEnglish(l) ? "Add reminder" : "Benachrichtigung hinzufügen" }
static func off(_ l: String) -> String { isEnglish(l) ? "Off" : "Aus" }
static func defaultTitle(_ l: String) -> String { isEnglish(l) ? "Default reminder" : "Standardbenachrichtigung" }
static func defaultFooter(_ l: String) -> String {
isEnglish(l)
? "Applies to all events unless an event has its own reminders."
: "Gilt für alle Termine, sofern ein Termin keine eigenen Benachrichtigungen hat."
}
}

View File

@@ -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)
}

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: " · ")
}
}

View File

@@ -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

View File

@@ -95,6 +95,9 @@ struct CalendarHostView: View {
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
}
// MARK: Liquid Glass variant
@@ -162,6 +165,9 @@ struct CalendarHostView: View {
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
}
// MARK: Top bar (flat mode)
@@ -407,6 +413,8 @@ struct CalendarHostView: View {
// MARK: Loading logic
private func startup() async {
// Ask for notification permission early so reminders can be scheduled.
NotificationScheduler.requestAuthorizationIfNeeded()
// 0. Pull settings first so week-start / default-view are correct
// before we compute the initial range and load events.
await SettingsSync.pull(api: api)

View File

@@ -10,6 +10,7 @@ struct EventEditorSheet: View {
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@State private var title = ""
@State private var isAllDay = false
@State private var startDate = Date()
@@ -19,6 +20,7 @@ struct EventEditorSheet: View {
@State private var selectedCalendarId: String = ""
@State private var color = ""
@State private var isPrivate = false
@State private var reminders: [Int] = []
@State private var isSaving = false
@State private var error = ""
@@ -81,6 +83,27 @@ struct EventEditorSheet: View {
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
.tint(Color.accentColor)
}
Section(ReminderOptions.sectionTitle(appLang)) {
ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in
Picker(ReminderOptions.sectionTitle(appLang), selection: Binding(
get: { reminders.indices.contains(idx) ? reminders[idx] : 0 },
set: { if reminders.indices.contains(idx) { reminders[idx] = $0 } }
)) {
ForEach(ReminderOptions.all, id: \.self) { opt in
Text(ReminderOptions.label(opt, appLang)).tag(opt)
}
}
.labelsHidden()
}
.onDelete { reminders.remove(atOffsets: $0) }
Button {
let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15
reminders.append(next)
} label: {
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
}
}
}
Section(L10n.t("event.color_section", appLang)) {
@@ -149,6 +172,7 @@ struct EventEditorSheet: View {
notes = ev.notes
color = ev.color ?? ""
isPrivate = ev.isPrivate
reminders = ev.reminders
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
if ev.source == "homeassistant" {
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -167,6 +191,7 @@ struct EventEditorSheet: View {
notes = ev.notes
color = ev.color ?? ""
isPrivate = ev.isPrivate
reminders = ev.reminders
selectedCalendarId = store.writableCalendars.first?.id ?? ""
} else {
let cal = Calendar.current
@@ -174,6 +199,8 @@ struct EventEditorSheet: View {
minute: 0, second: 0, of: initialDate) ?? initialDate
endDate = startDate.addingTimeInterval(3600)
selectedCalendarId = store.writableCalendars.first?.id ?? ""
// New events inherit the user's default reminder (editable).
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
}
}
@@ -193,7 +220,7 @@ struct EventEditorSheet: View {
case "local":
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
isPrivate: isPrivate)
isPrivate: isPrivate, reminders: reminders)
case "homeassistant":
// No update API exists delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -214,7 +241,7 @@ struct EventEditorSheet: View {
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal,
isPrivate: isPrivate)
isPrivate: isPrivate, reminders: reminders)
case "google":
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,

View File

@@ -22,6 +22,7 @@ struct SettingsView: View {
@AppStorage("defaultView") private var defaultView = "month"
@AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("dimPastEvents") private var dimPastEvents = false
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
// Profile chapter (server-backed; loaded on appear).
@State private var displayName = ""
@@ -37,6 +38,7 @@ struct SettingsView: View {
Form {
profilSection
privatsphaereSection
benachrichtigungenSection
geteilterKalenderSection
liquidGlassSection
cacheSection
@@ -105,6 +107,27 @@ struct SettingsView: View {
}
}
// MARK: Benachrichtigungen
var benachrichtigungenSection: some View {
Section {
Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) {
Text(ReminderOptions.off(appLang)).tag(-1)
ForEach(ReminderOptions.all, id: \.self) { m in
Text(ReminderOptions.label(m, appLang)).tag(m)
}
}
.onChange(of: defaultReminderMinutes) { _, _ in
SettingsSync.push(api: api)
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
}
} header: {
Text(ReminderOptions.sectionTitle(appLang))
} footer: {
Text(ReminderOptions.defaultFooter(appLang)).font(.caption)
}
}
// MARK: Privatsphäre
var privatsphaereSection: some View {