Files
Calendarr-IOS/Calendarr iOS/Services/NotificationScheduler.swift
Scarriffle c0edca338e iOS: localization fixes, per-calendar reminders, widget polish
C1 — Localization: route the remaining hardcoded German strings through
L10n (LoginView, ServerSetupView, SettingsView email, EventDetailSheet) so
"System Default" + English device language shows fully English text.

C2 — Per-calendar reminders: parse the new reminders_enabled flag on every
calendar type; CalendarStore persists a reminderDisabledKeys set and passes
it to NotificationScheduler, which skips events of muted calendars (default
and per-event reminders). Filter sheet gains a per-calendar reminder toggle
(leading swipe + bell.slash indicator), reconciled from the server and
synced back via PUT.

C3 — Widgets:
- Shared WidgetTime.range helper; Today / Today & Tomorrow / Three Days /
  Up Next now show start–end instead of only the start time.
- This Week: show up to 6 events per day (was 3) to use the height.
- Two Weeks: mini event-title pills instead of bare dots.
- Two Months: weeks expand to fill the column (no more empty lower third).
- Day & Events: smaller header/strip/rows so content stops clipping.
- Next 5 days → Next 7 days (range + labels), higher row cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:14:39 +02:00

73 lines
3.4 KiB
Swift

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.
/// `disabledCalendarKeys` ("source:id") are calendars with reminders turned
/// off their events never generate notifications.
static func reschedule(events: [CalEvent], disabledCalendarKeys: Set<String> = []) {
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 {
// Skip calendars the user muted for reminders.
if !disabledCalendarKeys.isEmpty {
let key = CalendarStore.calendarKey(source: ev.source, calendarId: ev.calendarId)
if disabledCalendarKeys.contains(key) { continue }
}
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: " · ")
}
}