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."
}
}