From 587a0e65fa4382fbb085a33e926520edd1d5e9e6 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 6 Jun 2026 16:21:08 +0200 Subject: [PATCH] feat: event reminders + default reminder setting + local notifications (iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Calendarr iOS/Models/AppSettings.swift | 3 + Calendarr iOS/Models/CalEvent.swift | 5 +- Calendarr iOS/Models/CalendarStore.swift | 9 +++ Calendarr iOS/Models/ReminderOptions.swift | 37 +++++++++++ Calendarr iOS/Services/CalendarrAPI.swift | 6 +- .../Services/NotificationScheduler.swift | 65 +++++++++++++++++++ Calendarr iOS/Services/SettingsSync.swift | 5 ++ .../Views/Calendar/CalendarHostView.swift | 8 +++ .../Views/Calendar/EventEditorSheet.swift | 31 ++++++++- Calendarr iOS/Views/SettingsView.swift | 23 +++++++ 10 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 Calendarr iOS/Models/ReminderOptions.swift create mode 100644 Calendarr iOS/Services/NotificationScheduler.swift diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index bbf5248..8bf2642 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -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) } } diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index b1259d7..3120d11 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -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)") } ?? [] ) } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 720d085..691a860 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -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 diff --git a/Calendarr iOS/Models/ReminderOptions.swift b/Calendarr iOS/Models/ReminderOptions.swift new file mode 100644 index 0000000..05e5a38 --- /dev/null +++ b/Calendarr iOS/Models/ReminderOptions.swift @@ -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." + } +} diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index f87631b..c245fe8 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -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) } diff --git a/Calendarr iOS/Services/NotificationScheduler.swift b/Calendarr iOS/Services/NotificationScheduler.swift new file mode 100644 index 0000000..e65f36b --- /dev/null +++ b/Calendarr iOS/Services/NotificationScheduler.swift @@ -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: " · ") + } +} diff --git a/Calendarr iOS/Services/SettingsSync.swift b/Calendarr iOS/Services/SettingsSync.swift index 6bc1101..223b119 100644 --- a/Calendarr iOS/Services/SettingsSync.swift +++ b/Calendarr iOS/Services/SettingsSync.swift @@ -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 diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 61d5bee..d2eea84 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -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) diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index 1f9b6c8..87d7f28 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -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, diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 0c9e6c6..9a8ed37 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -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 {