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:
@@ -18,6 +18,7 @@ struct AppSettings: Codable {
|
|||||||
var lineColor: String = "#3A3A3C"
|
var lineColor: String = "#3A3A3C"
|
||||||
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
||||||
var groupVisibleCalendarId: Int? = nil
|
var groupVisibleCalendarId: Int? = nil
|
||||||
|
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case defaultView = "default_view"
|
case defaultView = "default_view"
|
||||||
@@ -37,6 +38,7 @@ struct AppSettings: Codable {
|
|||||||
case lineColor = "line_color"
|
case lineColor = "line_color"
|
||||||
case privateEventVisibility = "private_event_visibility"
|
case privateEventVisibility = "private_event_visibility"
|
||||||
case groupVisibleCalendarId = "group_visible_calendar_id"
|
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||||
|
case defaultReminderMinutes = "default_reminder_minutes"
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
@@ -66,6 +68,7 @@ struct AppSettings: Codable {
|
|||||||
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
|
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
|
||||||
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
||||||
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
||||||
|
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
// Server-decorated title for the group combined view (group icon / owner
|
// Server-decorated title for the group combined view (group icon / owner
|
||||||
// prefix); rendered in group mode while `title` stays raw for editing.
|
// prefix); rendered in group mode while `title` stays raw for editing.
|
||||||
var displayTitle: String? = nil
|
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.
|
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||||
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||||
@@ -82,7 +84,8 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
owner: EventPerson.from(json["owner"]),
|
owner: EventPerson.from(json["owner"]),
|
||||||
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
||||||
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
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)") } ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,15 @@ class CalendarStore {
|
|||||||
return !hiddenCalendarKeys.contains(key)
|
return !hiddenCalendarKeys.contains(key)
|
||||||
&& !banishedCalendarKeys.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
|
/// Optimistically drop a just-deleted event from the cache so it disappears
|
||||||
|
|||||||
37
Calendarr iOS/Models/ReminderOptions.swift
Normal file
37
Calendarr iOS/Models/ReminderOptions.swift
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,7 +229,7 @@ class CalendarrAPI {
|
|||||||
|
|
||||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?,
|
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] = [
|
var body: [String: Any] = [
|
||||||
"calendar_id": calendarId,
|
"calendar_id": calendarId,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -241,6 +241,7 @@ class CalendarrAPI {
|
|||||||
"private": isPrivate
|
"private": isPrivate
|
||||||
]
|
]
|
||||||
if let c = color, !c.isEmpty { body["color"] = c }
|
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)
|
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
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,
|
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?,
|
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] = [
|
var body: [String: Any] = [
|
||||||
"title": title,
|
"title": title,
|
||||||
"start": formatISO(start, allDay: isAllDay),
|
"start": formatISO(start, allDay: isAllDay),
|
||||||
@@ -260,6 +261,7 @@ class CalendarrAPI {
|
|||||||
"private": isPrivate
|
"private": isPrivate
|
||||||
]
|
]
|
||||||
if let c = color { body["color"] = c }
|
if let c = color { body["color"] = c }
|
||||||
|
if let reminders { body["reminders"] = reminders }
|
||||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
_ = 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 defaultView = "defaultView"
|
||||||
static let weekStartDay = "weekStartDay"
|
static let weekStartDay = "weekStartDay"
|
||||||
static let dimPastEvents = "dimPastEvents"
|
static let dimPastEvents = "dimPastEvents"
|
||||||
|
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
||||||
// master switch
|
// master switch
|
||||||
static let enabled = "settingsSync"
|
static let enabled = "settingsSync"
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,8 @@ enum SettingsSync {
|
|||||||
s.defaultView = str(Key.defaultView, "month")
|
s.defaultView = str(Key.defaultView, "month")
|
||||||
s.weekStartDay = str(Key.weekStartDay, "monday")
|
s.weekStartDay = str(Key.weekStartDay, "monday")
|
||||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
||||||
|
let rem = int(Key.defaultReminder, -1)
|
||||||
|
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ enum SettingsSync {
|
|||||||
d.set(s.defaultView, forKey: Key.defaultView)
|
d.set(s.defaultView, forKey: Key.defaultView)
|
||||||
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
||||||
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
||||||
|
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
||||||
guard includeOptional else { return }
|
guard includeOptional else { return }
|
||||||
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
||||||
// synced – the server has no columns for them (iOS-only). Writing the
|
// synced – the server has no columns for them (iOS-only). Writing the
|
||||||
@@ -134,6 +138,7 @@ enum SettingsSync {
|
|||||||
merged.defaultView = local.defaultView
|
merged.defaultView = local.defaultView
|
||||||
merged.weekStartDay = local.weekStartDay
|
merged.weekStartDay = local.weekStartDay
|
||||||
merged.dimPastEvents = local.dimPastEvents
|
merged.dimPastEvents = local.dimPastEvents
|
||||||
|
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
||||||
if isEnabled {
|
if isEnabled {
|
||||||
merged.primaryColor = local.primaryColor
|
merged.primaryColor = local.primaryColor
|
||||||
merged.accentColor = local.accentColor
|
merged.accentColor = local.accentColor
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ struct CalendarHostView: View {
|
|||||||
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||||
Task { await forceReload() }
|
Task { await forceReload() }
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||||
|
store.rescheduleNotifications()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Liquid Glass variant
|
// MARK: – Liquid Glass variant
|
||||||
@@ -162,6 +165,9 @@ struct CalendarHostView: View {
|
|||||||
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||||
Task { await forceReload() }
|
Task { await forceReload() }
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||||
|
store.rescheduleNotifications()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Top bar (flat mode)
|
// MARK: – Top bar (flat mode)
|
||||||
@@ -407,6 +413,8 @@ struct CalendarHostView: View {
|
|||||||
// MARK: – Loading logic
|
// MARK: – Loading logic
|
||||||
|
|
||||||
private func startup() async {
|
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
|
// 0. Pull settings first so week-start / default-view are correct
|
||||||
// before we compute the initial range and load events.
|
// before we compute the initial range and load events.
|
||||||
await SettingsSync.pull(api: api)
|
await SettingsSync.pull(api: api)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ struct EventEditorSheet: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
@State private var title = ""
|
@State private var title = ""
|
||||||
@State private var isAllDay = false
|
@State private var isAllDay = false
|
||||||
@State private var startDate = Date()
|
@State private var startDate = Date()
|
||||||
@@ -19,6 +20,7 @@ struct EventEditorSheet: View {
|
|||||||
@State private var selectedCalendarId: String = ""
|
@State private var selectedCalendarId: String = ""
|
||||||
@State private var color = ""
|
@State private var color = ""
|
||||||
@State private var isPrivate = false
|
@State private var isPrivate = false
|
||||||
|
@State private var reminders: [Int] = []
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
@State private var error = ""
|
@State private var error = ""
|
||||||
|
|
||||||
@@ -81,6 +83,27 @@ struct EventEditorSheet: View {
|
|||||||
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
|
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
|
||||||
.tint(Color.accentColor)
|
.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)) {
|
Section(L10n.t("event.color_section", appLang)) {
|
||||||
@@ -149,6 +172,7 @@ struct EventEditorSheet: View {
|
|||||||
notes = ev.notes
|
notes = ev.notes
|
||||||
color = ev.color ?? ""
|
color = ev.color ?? ""
|
||||||
isPrivate = ev.isPrivate
|
isPrivate = ev.isPrivate
|
||||||
|
reminders = ev.reminders
|
||||||
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
|
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
|
||||||
if ev.source == "homeassistant" {
|
if ev.source == "homeassistant" {
|
||||||
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||||
@@ -167,6 +191,7 @@ struct EventEditorSheet: View {
|
|||||||
notes = ev.notes
|
notes = ev.notes
|
||||||
color = ev.color ?? ""
|
color = ev.color ?? ""
|
||||||
isPrivate = ev.isPrivate
|
isPrivate = ev.isPrivate
|
||||||
|
reminders = ev.reminders
|
||||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||||
} else {
|
} else {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
@@ -174,6 +199,8 @@ struct EventEditorSheet: View {
|
|||||||
minute: 0, second: 0, of: initialDate) ?? initialDate
|
minute: 0, second: 0, of: initialDate) ?? initialDate
|
||||||
endDate = startDate.addingTimeInterval(3600)
|
endDate = startDate.addingTimeInterval(3600)
|
||||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
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":
|
case "local":
|
||||||
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
||||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
||||||
isPrivate: isPrivate)
|
isPrivate: isPrivate, reminders: reminders)
|
||||||
case "homeassistant":
|
case "homeassistant":
|
||||||
// No update API exists – delete the old event and recreate with new data.
|
// No update API exists – delete the old event and recreate with new data.
|
||||||
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||||
@@ -214,7 +241,7 @@ struct EventEditorSheet: View {
|
|||||||
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
location: location, description: notes, color: colorVal,
|
location: location, description: notes, color: colorVal,
|
||||||
isPrivate: isPrivate)
|
isPrivate: isPrivate, reminders: reminders)
|
||||||
case "google":
|
case "google":
|
||||||
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct SettingsView: View {
|
|||||||
@AppStorage("defaultView") private var defaultView = "month"
|
@AppStorage("defaultView") private var defaultView = "month"
|
||||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||||
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||||||
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
|
|
||||||
// Profile chapter (server-backed; loaded on appear).
|
// Profile chapter (server-backed; loaded on appear).
|
||||||
@State private var displayName = ""
|
@State private var displayName = ""
|
||||||
@@ -37,6 +38,7 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
profilSection
|
profilSection
|
||||||
privatsphaereSection
|
privatsphaereSection
|
||||||
|
benachrichtigungenSection
|
||||||
geteilterKalenderSection
|
geteilterKalenderSection
|
||||||
liquidGlassSection
|
liquidGlassSection
|
||||||
cacheSection
|
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
|
// MARK: – Privatsphäre
|
||||||
|
|
||||||
var privatsphaereSection: some View {
|
var privatsphaereSection: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user