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>
73 lines
3.4 KiB
Swift
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: " · ")
|
|
}
|
|
}
|