BugFix: Reminder Memory Leak

This commit is contained in:
Hariharan Mudaliar 2025-12-01 21:02:49 +05:30
commit 39673e9130
8 changed files with 401 additions and 456 deletions

View file

@ -631,6 +631,7 @@
}
},
"%@" : {
"extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@ -3314,6 +3315,10 @@
}
}
},
"Auto-scroll to next event" : {
"comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.",
"isCommentAutoGenerated" : true
},
"Automatically check for updates" : {
"localizations" : {
"ar" : {
@ -8433,6 +8438,10 @@
}
}
},
"Event List" : {
"comment" : "A section header in the calendar settings view, describing the list of calendar events.",
"isCommentAutoGenerated" : true
},
"Exclude apps" : {
"localizations" : {
"en" : {
@ -9548,6 +9557,14 @@
}
}
},
"Hide all-day events" : {
"comment" : "A toggle to hide all-day events in the calendar.",
"isCommentAutoGenerated" : true
},
"Hide completed reminders" : {
"comment" : "A toggle that lets the user hide completed reminders",
"isCommentAutoGenerated" : true
},
"Hide DynamicIsland Options" : {
"localizations" : {
"fr" : {
@ -11187,6 +11204,14 @@
},
"Manage the Clock timer from the Clock app." : {
},
"Mark as complete" : {
"comment" : "An accessibility label for a button that marks an item as complete.",
"isCommentAutoGenerated" : true
},
"Mark as incomplete" : {
"comment" : "An accessibility label for a button that marks an item as incomplete.",
"isCommentAutoGenerated" : true
},
"Match menubar height" : {
"localizations" : {
@ -13716,6 +13741,10 @@
}
}
},
"No events" : {
"comment" : "A description displayed when there are no events in the calendar.",
"isCommentAutoGenerated" : true
},
"No events today" : {
"localizations" : {
"ar" : {
@ -16992,6 +17021,10 @@
},
"Show floating pause/stop controls" : {
},
"Show full event titles" : {
"comment" : "A toggle that shows or hides the full event titles in the calendar view",
"isCommentAutoGenerated" : true
},
"Show media output control in other layouts" : {
"comment" : "A toggle that lets the user enable or disable the media output control in layouts other than the home screen.",

View file

@ -15,6 +15,7 @@ protocol CalendarServiceProviding {
func requestAccess(to type: EKEntityType) async -> Bool
func calendars() async -> [CalendarModel]
func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel]
func setReminderCompleted(reminderID: String, completed: Bool) async
}
class CalendarService: CalendarServiceProviding {
@ -122,6 +123,17 @@ class CalendarService: CalendarServiceProviding {
}
}
}
@MainActor
func setReminderCompleted(reminderID: String, completed: Bool) async {
guard let reminder = store.calendarItem(withIdentifier: reminderID) as? EKReminder else { return }
reminder.isCompleted = completed
do {
try store.save(reminder, commit: true)
} catch {
print("Failed to update reminder completion: \(error)")
}
}
}
// MARK: - Model Extensions

View file

@ -10,13 +10,12 @@ import Defaults
import EventKit
struct Config: Equatable {
// var count: Int = 10 // 3 days past + today + 7 days future
var past: Int = 3
var future: Int = 7
var steps: Int = 1 // Each step is one day
var spacing: CGFloat = 1
var past: Int = 7
var future: Int = 14
var steps: Int = 1
var spacing: CGFloat = 0
var showsText: Bool = true
var offset: Int = 2 // Number of dates to the left of the selected date
var offset: Int = 2
}
struct WheelPicker: View {
@ -30,22 +29,24 @@ struct WheelPicker: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: config.spacing) {
let totalSteps = config.steps * (config.past + config.future)
let spacerNum = config.offset
ForEach(0..<totalSteps + 2 * spacerNum + 1, id: \.self) { index in
if(index < spacerNum || index > totalSteps + spacerNum - 1){
Spacer().frame(width: 24, height: 24).id(index)
let dateCount = totalDateItems()
let totalItems = dateCount + 2 * spacerNum
ForEach(0..<totalItems, id: \.self) { index in
if index < spacerNum || index >= spacerNum + dateCount {
Spacer()
.frame(width: 24, height: 24)
.id(index)
} else {
let offset = -config.offset - config.past
let date = dateForIndex(index, offset: offset)
let isSelected = isDateSelected(index, offset: offset)
dateButton(date: date, isSelected: isSelected, offset: offset){
let date = dateForItemIndex(index: index, spacerNum: spacerNum)
let isSelected = Calendar.current.isDate(date, inSameDayAs: selectedDate)
dateButton(date: date, isSelected: isSelected, id: index) {
selectedDate = date
byClick = true
withAnimation{
scrollPosition = indexForDate(date, offset: offset) - config.offset
withAnimation {
scrollPosition = index
}
if (Defaults[.enableHaptics]) {
if Defaults[.enableHaptics] {
haptics.toggle()
}
}
@ -56,85 +57,109 @@ struct WheelPicker: View {
.scrollTargetLayout()
}
.scrollIndicators(.never)
.scrollPosition(id: $scrollPosition, anchor: .leading)
.scrollTargetBehavior(.viewAligned) // Ensures scroll view snaps to button center
.scrollPosition(id: $scrollPosition, anchor: .center)
.scrollTargetBehavior(.viewAligned)
.safeAreaPadding(.horizontal)
.sensoryFeedback(.alignment, trigger: haptics)
.onChange(of: scrollPosition) { oldValue, newValue in
if(!byClick){
.onChange(of: scrollPosition) { _, newValue in
if !byClick {
handleScrollChange(newValue: newValue, config: config)
}else{
} else {
byClick = false
}
}
.onAppear {
scrollToToday(config: config)
}
}
private func dateButton(date: Date, isSelected: Bool, offset: Int, onClick:@escaping()->Void) -> some View {
Button(action: onClick) {
VStack(spacing: 2) {
dayText(date: dateToString(for: date), isSelected: isSelected)
dateCircle(date: date, isSelected: isSelected)
.onChange(of: selectedDate) { _, newValue in
let targetIndex = indexForDate(newValue)
if scrollPosition != targetIndex {
byClick = true
withAnimation {
scrollPosition = targetIndex
}
}
}
}
private func dateButton(date: Date, isSelected: Bool, id: Int, onClick: @escaping () -> Void) -> some View {
let isToday = Calendar.current.isDateInToday(date)
return Button(action: onClick) {
VStack(spacing: 8) {
dayText(date: dateToString(for: date), isToday: isToday, isSelected: isSelected)
dateCircle(date: date, isToday: isToday, isSelected: isSelected)
}
.padding(.vertical, 4)
.padding(.horizontal, 4)
.background(isSelected ? Color.effectiveAccentBackground : Color.clear)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
.id(indexForDate(date, offset: offset))
.id(id)
}
private func dayText(date: String, isSelected: Bool) -> some View {
private func dayText(date: String, isToday: Bool, isSelected: Bool) -> some View {
Text(date)
.font(.caption2)
.foregroundStyle(isSelected ? Color.accentColor : .gray)
.font(.caption)
.foregroundColor(isSelected ? .white : Color(white: 0.65))
}
private func dateCircle(date: Date, isSelected: Bool) -> some View {
private func dateCircle(date: Date, isToday: Bool, isSelected: Bool) -> some View {
ZStack {
Circle()
.fill(isSelected ? Color.accentColor : .clear)
.frame(width: 24, height: 24)
Text("\(date.date)")
.font(.title3)
.foregroundStyle(isSelected ? .white : .gray)
.fill(isToday ? Color.effectiveAccent : .clear)
.frame(width: 20, height: 20)
Text(date.date)
.font(.body)
.fontWeight(.medium)
.foregroundColor(isSelected ? .white : Color(white: isToday ? 0.9 : 0.65))
}
}
func handleScrollChange(newValue: Int?, config: Config) {
let offset = -config.offset - config.past
let todayIndex = indexForDate(Date(), offset: offset)
guard let newIndex = newValue else { return }
let targetDateIndex = newIndex + config.offset
switch targetDateIndex {
case todayIndex - config.past ..< todayIndex + config.future:
selectedDate = dateForIndex(targetDateIndex, offset: offset)
if (Defaults[.enableHaptics]) {
let spacerNum = config.offset
let dateCount = totalDateItems()
guard (spacerNum..<(spacerNum + dateCount)).contains(newIndex) else { return }
let date = dateForItemIndex(index: newIndex, spacerNum: spacerNum)
if !Calendar.current.isDate(date, inSameDayAs: selectedDate) {
selectedDate = date
if Defaults[.enableHaptics] {
haptics.toggle()
}
default:
return
}
}
private func scrollToToday(config: Config) {
let today = Date()
let todayIndex = indexForDate(today, offset: -config.offset - config.past)
byClick = true
scrollPosition = todayIndex - config.offset
scrollPosition = indexForDate(today)
selectedDate = today
}
private func indexForDate(_ date: Date, offset: Int) -> Int {
let calendar = Calendar.current
let startDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: offset, to: Date()) ?? Date())
let targetDate = calendar.startOfDay(for: date)
let daysDifference = calendar.dateComponents([.day], from: startDate, to: targetDate).day ?? 0
return daysDifference
private func indexForDate(_ date: Date) -> Int {
let spacerNum = config.offset
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let startDate = cal.startOfDay(for: cal.date(byAdding: .day, value: -config.past, to: today) ?? today)
let target = cal.startOfDay(for: date)
let days = cal.dateComponents([.day], from: startDate, to: target).day ?? 0
let stepIndex = max(0, min(days / max(config.steps, 1), totalDateItems() - 1))
return spacerNum + stepIndex
}
private func dateForIndex(_ index: Int, offset: Int) -> Date {
let startDate = Calendar.current.date(byAdding: .day, value: offset, to: Date()) ?? Date()
return Calendar.current.date(byAdding: .day, value: index, to: startDate) ?? Date()
private func dateForItemIndex(index: Int, spacerNum: Int) -> Date {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let startDate = cal.date(byAdding: .day, value: -config.past, to: today) ?? today
let stepIndex = index - spacerNum
return cal.date(byAdding: .day, value: stepIndex * max(config.steps, 1), to: startDate) ?? today
}
private func totalDateItems() -> Int {
let range = config.past + config.future
let step = max(config.steps, 1)
return Int(ceil(Double(range) / Double(step))) + 1
}
private func dateToString(for date: Date) -> String {
@ -142,10 +167,6 @@ struct WheelPicker: View {
formatter.dateFormat = "E"
return formatter.string(from: date)
}
private func isDateSelected(_ index: Int, offset: Int) -> Bool {
Calendar.current.isDate(dateForIndex(index, offset: offset), inSameDayAs: selectedDate)
}
}
struct CalendarView: View {
@ -187,7 +208,7 @@ struct CalendarView: View {
events: calendarManager.events
)
if filteredEvents.isEmpty {
EmptyEventsView()
EmptyEventsView(selectedDate: selectedDate)
Spacer(minLength: 0)
} else {
EventListView(events: calendarManager.events)
@ -216,12 +237,14 @@ struct CalendarView: View {
}
struct EmptyEventsView: View {
let selectedDate: Date
var body: some View {
VStack {
Image(systemName: "calendar.badge.checkmark")
.font(.title)
.foregroundColor(Color(white: 0.65))
Text("No events today")
Text(Calendar.current.isDateInToday(selectedDate) ? "No events today" : "No events")
.font(.subheadline)
.foregroundColor(.white)
Text("Enjoy your free time!")
@ -235,11 +258,16 @@ struct EventListView: View {
@Environment(\.openURL) private var openURL
@ObservedObject private var calendarManager = CalendarManager.shared
let events: [EventModel]
@Default(.autoScrollToNextEvent) private var autoScrollToNextEvent
@Default(.showFullEventTitles) private var showFullEventTitles
static func filteredEvents(events: [EventModel]) -> [EventModel] {
events.filter { event in
// Filter out all-day events if setting is enabled
if event.type.isReminder {
if case .reminder(let completed) = event.type {
return !completed || !Defaults[.hideCompletedReminders]
}
}
if event.isAllDay && Defaults[.hideAllDayEvents] {
return false
}
@ -251,42 +279,129 @@ struct EventListView: View {
Self.filteredEvents(events: events)
}
var body: some View {
List {
ForEach(filteredEvents) { event in
Button(action: {
if let url = event.calendarAppURL() {
openURL(url)
}
}) {
eventRow(event)
}
.padding(.leading, -5)
.buttonStyle(PlainButtonStyle())
.listRowSeparator(.automatic)
.listRowSeparatorTint(.gray.opacity(0.2))
.listRowBackground(Color.clear)
private func scrollToRelevantEvent(proxy: ScrollViewProxy) {
guard autoScrollToNextEvent else { return }
let now = Date()
let nonAllDayUpcoming = filteredEvents.first(where: { !$0.isAllDay && $0.end > now })
let firstAllDay = filteredEvents.first(where: { $0.isAllDay })
let lastEvent = filteredEvents.last
guard let target = nonAllDayUpcoming ?? firstAllDay ?? lastEvent else { return }
Task { @MainActor in
withTransaction(Transaction(animation: nil)) {
proxy.scrollTo(target.id, anchor: .top)
}
}
}
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(filteredEvents) { event in
Button(action: {
if let url = event.calendarAppURL() {
openURL(url)
}
}) {
eventRow(event)
}
.id(event.id)
.padding(.leading, -5)
.buttonStyle(PlainButtonStyle())
.listRowSeparator(.automatic)
.listRowSeparatorTint(.gray.opacity(0.2))
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.scrollIndicators(.never)
.scrollContentBackground(.hidden)
.background(Color.clear)
.onAppear {
scrollToRelevantEvent(proxy: proxy)
}
.onChange(of: filteredEvents) { _, _ in
scrollToRelevantEvent(proxy: proxy)
}
}
.listStyle(.plain)
.scrollIndicators(.never)
.scrollContentBackground(.hidden)
.background(Color.clear)
Spacer(minLength: 0)
}
private func eventRow(_ event: EventModel) -> some View {
// Regular event row
return AnyView(
HStack(spacing: 8) {
Circle()
.fill(Color(event.calendar.color))
.frame(width: 8, height: 8)
HStack {
Text(event.title)
.font(.callout)
.foregroundColor(.white)
.lineLimit(1)
if event.type.isReminder {
let isCompleted: Bool
if case .reminder(let completed) = event.type {
isCompleted = completed
} else {
isCompleted = false
}
return AnyView(
HStack(spacing: 8) {
ReminderToggle(
isOn: Binding(
get: { isCompleted },
set: { newValue in
Task {
await calendarManager.setReminderCompleted(
reminderID: event.id, completed: newValue
)
}
}
),
color: Color(event.calendar.color)
)
.opacity(1.0)
HStack {
Text(event.title)
.font(.callout)
.foregroundColor(.white)
.lineLimit(showFullEventTitles ? nil : 1)
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 4) {
if event.isAllDay {
Text("All-day")
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.white)
.lineLimit(1)
} else {
Text(event.start, style: .time)
.foregroundColor(.white)
.font(.caption)
}
}
}
.opacity(
isCompleted
? 0.4
: event.start < Date.now && Calendar.current.isDateInToday(event.start)
? 0.6 : 1.0
)
}
.padding(.vertical, 4)
)
} else {
return AnyView(
HStack(alignment: .top, spacing: 4) {
Rectangle()
.fill(Color(event.calendar.color))
.frame(width: 3)
.cornerRadius(1.5)
VStack(alignment: .leading, spacing: 2) {
Text(event.title)
.font(.callout)
.fontWeight(.medium)
.foregroundColor(.white)
.lineLimit(showFullEventTitles ? nil : 2)
if let location = event.location, !location.isEmpty {
Text(location)
.font(.caption)
.foregroundColor(Color(white: 0.65))
.lineLimit(1)
}
}
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 4) {
if event.isAllDay {
@ -296,21 +411,48 @@ struct EventListView: View {
.foregroundColor(.white)
.lineLimit(1)
} else {
Text("\(event.start, style: .time)")
.font(.caption)
.fontWeight(.medium)
Text(event.start, style: .time)
.foregroundColor(.white)
.lineLimit(1)
Text("\(event.end, style: .time)")
.font(.caption2)
Text(event.end, style: .time)
.foregroundColor(Color(white: 0.65))
.lineLimit(1)
}
}
.font(.caption)
.frame(minWidth: 44, alignment: .trailing)
}
.contentShape(Rectangle())
.opacity(
event.eventStatus == .ended && Calendar.current.isDateInToday(event.start)
? 0.6 : 1.0)
)
}
}
}
struct ReminderToggle: View {
@Binding var isOn: Bool
var color: Color
var body: some View {
Button(action: {
isOn.toggle()
}) {
ZStack {
Circle()
.strokeBorder(color, lineWidth: 2)
.frame(width: 14, height: 14)
if isOn {
Circle()
.fill(color)
.frame(width: 8, height: 8)
}
Circle()
.fill(Color.black.opacity(0.001))
.frame(width: 14, height: 14)
}
)
}
.buttonStyle(PlainButtonStyle())
.padding(0)
.accessibilityLabel(isOn ? "Mark as incomplete" : "Mark as complete")
}
}

View file

@ -614,6 +614,10 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Countdown style")),
SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.calendar.highlightID(for: "Show lock screen reminder")),
SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.calendar.highlightID(for: "Chip color")),
SettingsSearchEntry(tab: .calendar, title: "Hide all-day events", keywords: ["calendar", "all-day"], highlightID: SettingsTab.calendar.highlightID(for: "Hide all-day events")),
SettingsSearchEntry(tab: .calendar, title: "Hide completed reminders", keywords: ["reminder", "completed"], highlightID: SettingsTab.calendar.highlightID(for: "Hide completed reminders")),
SettingsSearchEntry(tab: .calendar, title: "Show full event titles", keywords: ["calendar", "titles"], highlightID: SettingsTab.calendar.highlightID(for: "Show full event titles")),
SettingsSearchEntry(tab: .calendar, title: "Auto-scroll to next event", keywords: ["calendar", "scroll"], highlightID: SettingsTab.calendar.highlightID(for: "Auto-scroll to next event")),
// Shelf
SettingsSearchEntry(tab: .shelf, title: "Enable shelf", keywords: ["shelf", "dock"], highlightID: SettingsTab.shelf.highlightID(for: "Enable shelf")),
@ -1730,6 +1734,10 @@ struct CalendarSettings: View {
@Default(.reminderSneakPeekDuration) var reminderSneakPeekDuration
@Default(.enableLockScreenReminderWidget) var enableLockScreenReminderWidget
@Default(.lockScreenReminderChipStyle) var lockScreenReminderChipStyle
@Default(.hideAllDayEvents) var hideAllDayEvents
@Default(.hideCompletedReminders) var hideCompletedReminders
@Default(.showFullEventTitles) var showFullEventTitles
@Default(.autoScrollToNextEvent) var autoScrollToNextEvent
private func highlightID(_ title: String) -> String {
SettingsTab.calendar.highlightID(for: title)
@ -1780,6 +1788,17 @@ struct CalendarSettings: View {
Defaults.Toggle("Show calendar", key: .showCalendar)
.settingsHighlight(id: highlightID("Show calendar"))
Section(header: Text("Event List")) {
Toggle("Hide all-day events", isOn: $hideAllDayEvents)
.settingsHighlight(id: highlightID("Hide all-day events"))
Toggle("Hide completed reminders", isOn: $hideCompletedReminders)
.settingsHighlight(id: highlightID("Hide completed reminders"))
Toggle("Show full event titles", isOn: $showFullEventTitles)
.settingsHighlight(id: highlightID("Show full event titles"))
Toggle("Auto-scroll to next event", isOn: $autoScrollToNextEvent)
.settingsHighlight(id: highlightID("Auto-scroll to next event"))
}
Section(header: Text("Reminder Live Activity")) {
Defaults.Toggle("Enable reminder live activity", key: .enableReminderLiveActivity)
.settingsHighlight(id: highlightID("Enable reminder live activity"))

View file

@ -0,0 +1,16 @@
#if os(macOS)
import SwiftUI
import Defaults
extension Color {
/// Returns the accent color configured in settings, falling back to the system accent color.
static var effectiveAccent: Color {
Defaults[.accentColor]
}
/// Returns a subtle background variant of the accent color.
static var effectiveAccentBackground: Color {
Defaults[.accentColor].opacity(0.25)
}
}
#endif

View file

@ -203,4 +203,9 @@ class CalendarManager: ObservableObject {
)
self.events = eventsResult
}
func setReminderCompleted(reminderID: String, completed: Bool) async {
await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed)
await updateEvents()
}
}

View file

@ -7,7 +7,6 @@
import Combine
import Defaults
import EventKit
import Foundation
import CoreGraphics
import os
@ -36,59 +35,23 @@ final class ReminderLiveActivityManager: ObservableObject {
private let logger: os.Logger = os.Logger(subsystem: "com.ebullioscopic.Atoll", category: "ReminderLiveActivity")
private enum RefreshReason: String {
case initialization
case defaults
case manual
case eventStoreChange
case calendarListChange
case calendarEventsUpdate
case evaluation
case hover
case ticker
case other
}
private var nextReminder: ReminderEntry?
private var cancellables = Set<AnyCancellable>()
private var tickerTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
private var evaluationTask: Task<Void, Never>?
private var refreshTask: Task<Void, Never>?
private var pendingRefreshTask: Task<Void, Never>?
private var pendingRefreshForce = false
private var pendingRefreshToken = UUID()
private var pendingRefreshReason: RefreshReason = .other
private var lastKnownCalendarIDs = Set<String>()
private var lastRefreshDate: Date?
private var lastRefreshCompletionDate: Date?
private let minimumRefreshInterval: TimeInterval = 60
private let refreshDebounceInterval: TimeInterval = 0.3
private var refreshTaskToken = UUID()
private var hasShownCriticalSneakPeek = false
private let eventStoreChangeNoiseInterval: TimeInterval = 4
private let eventStoreForceThrottleInterval: TimeInterval = 20
private let manualRefreshSuppressionInterval: TimeInterval = 20
private var lastEventStoreForceRefreshDate: Date?
private var lastManualRefreshDate: Date?
private var pendingEventStoreChange: (date: Date, reason: RefreshReason)?
private var reminderCompletionSuppressionDeadline: Date?
private let reminderCompletionSuppressionInterval: TimeInterval = 15
private var lastUpcomingSignature: [String] = []
private var lastNoopEventStoreRefreshDate: Date?
private let noopEventStoreSuppressionInterval: TimeInterval = 30
private var lastCalendarEventSnapshot: [EventModel] = []
private var lastCalendarSnapshotDate: Date?
private var lastCalendarEventSnapshotSignature: [String] = []
private var latestEvents: [EventModel] = []
private let calendarService: CalendarServiceProviding
private let calendarManager = CalendarManager.shared
var isActive: Bool { activeReminder != nil }
private init(calendarService: CalendarServiceProviding = CalendarService()) {
self.calendarService = calendarService
private init() {
latestEvents = calendarManager.events
setupObservers()
scheduleRefresh(force: true, reason: .initialization)
if !latestEvents.isEmpty {
recalculateUpcomingEntries(reason: "initialization")
}
}
private func setupObservers() {
@ -96,7 +59,7 @@ final class ReminderLiveActivityManager: ObservableObject {
.sink { [weak self] change in
guard let self else { return }
if change.newValue {
self.scheduleRefresh(force: true, reason: .defaults)
self.recalculateUpcomingEntries(reason: "defaults-toggle")
} else {
self.deactivateReminder()
}
@ -106,7 +69,19 @@ final class ReminderLiveActivityManager: ObservableObject {
Defaults.publisher(.reminderLeadTime, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.scheduleRefresh(force: true, reason: .defaults)
self.recalculateUpcomingEntries(reason: "lead-time")
}
.store(in: &cancellables)
Defaults.publisher(.hideAllDayEvents, options: [])
.sink { [weak self] _ in
self?.recalculateUpcomingEntries(reason: "hide-all-day")
}
.store(in: &cancellables)
Defaults.publisher(.hideCompletedReminders, options: [])
.sink { [weak self] _ in
self?.recalculateUpcomingEntries(reason: "hide-completed")
}
.store(in: &cancellables)
@ -120,16 +95,6 @@ final class ReminderLiveActivityManager: ObservableObject {
}
.store(in: &cancellables)
calendarManager.$allCalendars
.sink { [weak self] calendars in
guard let self else { return }
let identifiers = Set(calendars.map { $0.id })
guard identifiers != self.lastKnownCalendarIDs else { return }
self.lastKnownCalendarIDs = identifiers
self.handleEventStoreDrivenChange(reason: .calendarListChange)
}
.store(in: &cancellables)
calendarManager.$events
.receive(on: RunLoop.main)
.sink { [weak self] events in
@ -137,26 +102,13 @@ final class ReminderLiveActivityManager: ObservableObject {
self.handleCalendarEventsUpdate(events)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .EKEventStoreChanged)
.sink { [weak self] _ in
guard let self else { return }
self.handleEventStoreDrivenChange(reason: .eventStoreChange)
}
.store(in: &cancellables)
}
private func cancelAllTimers() {
tickerTask = nil
evaluationTask?.cancel()
evaluationTask = nil
pendingRefreshTask?.cancel()
pendingRefreshTask = nil
pendingRefreshForce = false
refreshTask?.cancel()
refreshTask = nil
hasShownCriticalSneakPeek = false
pendingEventStoreChange = nil
}
private func deactivateReminder() {
@ -165,32 +117,13 @@ final class ReminderLiveActivityManager: ObservableObject {
upcomingEntries = []
activeWindowReminders = []
cancelAllTimers()
lastUpcomingSignature = []
lastNoopEventStoreRefreshDate = nil
}
private func selectedCalendarIDs() -> [String] {
calendarManager.allCalendars
.filter { calendarManager.getCalendarSelected($0) }
.map { $0.id }
}
private func handleCalendarEventsUpdate(_ events: [EventModel]) {
let now = Date()
let signature = events.map { $0.id }
if signature == lastCalendarEventSnapshotSignature,
let snapshotDate = lastCalendarSnapshotDate,
now.timeIntervalSince(snapshotDate) < 5 {
return
}
lastCalendarEventSnapshot = events
lastCalendarSnapshotDate = now
lastCalendarEventSnapshotSignature = signature
latestEvents = events
guard Defaults[.enableReminderLiveActivity] else { return }
logger.debug("[Reminder] Applying calendar snapshot update (events=\(events.count, privacy: .public))")
refreshFromEvents(events, referenceDate: now, reason: .calendarEventsUpdate)
recalculateUpcomingEntries(reason: "calendar-events")
}
private func shouldHide(_ event: EventModel) -> Bool {
@ -211,6 +144,45 @@ final class ReminderLiveActivityManager: ObservableObject {
return ReminderEntry(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds))
}
private func recalculateUpcomingEntries(referenceDate: Date = Date(), reason: String) {
guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder()
return
}
guard calendarManager.hasReminderAccess else {
deactivateReminder()
return
}
let leadMinutes = Defaults[.reminderLeadTime]
let upcoming = latestEvents
.filter { !shouldHide($0) }
.compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) }
.sorted { $0.triggerDate < $1.triggerDate }
upcomingEntries = upcoming
updateActiveWindowReminders(for: referenceDate)
guard let first = upcoming.first else {
clearActiveReminderState()
logger.debug("[Reminder] No upcoming reminders found (reason=\(reason, privacy: .public))")
return
}
logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ (reason=\(reason, privacy: .public))")
handleEntrySelection(first, referenceDate: referenceDate)
}
private func clearActiveReminderState() {
nextReminder = nil
if activeReminder != nil {
activeReminder = nil
}
activeWindowReminders = []
cancelAllTimers()
}
private func scheduleEvaluation(at date: Date) {
evaluationTask?.cancel()
let delay = date.timeIntervalSinceNow
@ -226,88 +198,6 @@ final class ReminderLiveActivityManager: ObservableObject {
}
}
private func scheduleRefresh(force: Bool, reason: RefreshReason = .other) {
let wasPending = pendingRefreshTask != nil
pendingRefreshForce = pendingRefreshForce || force
pendingRefreshToken = UUID()
pendingRefreshReason = reason
let token = pendingRefreshToken
logger.debug("[Reminder] scheduleRefresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public) pending=\(wasPending, privacy: .public)")
refreshTask?.cancel()
pendingRefreshTask?.cancel()
pendingRefreshTask = Task { [weak self] in
guard let self else { return }
do {
try await Task.sleep(nanoseconds: UInt64(self.refreshDebounceInterval * 1_000_000_000))
} catch {
self.logger.debug("[Reminder] Debounced refresh for reason=\(reason.rawValue, privacy: .public) was cancelled")
return
}
await self.executeScheduledRefresh(token: token)
}
}
private func executeScheduledRefresh(token: UUID) async {
guard pendingRefreshToken == token else { return }
pendingRefreshTask = nil
if Task.isCancelled {
logger.debug("[Reminder] Cancelled refresh token=\(token.uuidString, privacy: .public)")
return
}
let reason = pendingRefreshReason
let now = Date()
let force = pendingRefreshForce
if !force, let last = lastRefreshDate {
let elapsed = now.timeIntervalSince(last)
if elapsed < minimumRefreshInterval {
let remaining = max(minimumRefreshInterval - elapsed, refreshDebounceInterval)
logger.debug("[Reminder] Deferring refresh reason=\(reason.rawValue, privacy: .public); next attempt in \(remaining, format: .fixed(precision: 2))s")
pendingRefreshToken = UUID()
let nextToken = pendingRefreshToken
pendingRefreshTask = Task { [weak self] in
guard let self else { return }
do {
try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
} catch {
self.logger.debug("[Reminder] Deferred refresh for reason=\(reason.rawValue, privacy: .public) was cancelled")
return
}
await self.executeScheduledRefresh(token: nextToken)
}
return
}
}
pendingRefreshForce = false
lastRefreshDate = now
let startTime = Date()
logger.debug("[Reminder] Starting refresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public)")
refreshTask?.cancel()
let taskToken = UUID()
refreshTaskToken = taskToken
let task = Task { [weak self] in
guard let self else { return }
await self.refreshUpcomingReminder(force: force, reason: reason)
}
refreshTask = task
defer {
if refreshTaskToken == taskToken {
refreshTask = nil
lastRefreshCompletionDate = Date()
let duration = Date().timeIntervalSince(startTime)
logger.debug("[Reminder] Finished refresh reason=\(reason.rawValue, privacy: .public) duration=\(duration, format: .fixed(precision: 2))s upcoming=\(self.upcomingEntries.count, privacy: .public)")
processPendingEventStoreChangeIfNeeded()
}
}
_ = try? await task.value
}
private func startTickerIfNeeded() {
guard tickerTask == nil else { return }
tickerTask = Task { [weak self] in
@ -328,109 +218,16 @@ final class ReminderLiveActivityManager: ObservableObject {
}
private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) {
guard nextReminder != entry else {
Task { await self.evaluateCurrentState(at: referenceDate) }
return
}
nextReminder = entry
hasShownCriticalSneakPeek = false
Task { await self.evaluateCurrentState(at: referenceDate) }
}
private func refreshFromEvents(_ events: [EventModel], referenceDate: Date, reason: RefreshReason) {
let leadMinutes = Defaults[.reminderLeadTime]
let upcoming = events
.filter { !shouldHide($0) }
.compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) }
.sorted { $0.triggerDate < $1.triggerDate }
upcomingEntries = upcoming
updateActiveWindowReminders(for: referenceDate)
logger.debug("[Reminder] Reduced \(events.count, privacy: .public) events to \(upcoming.count, privacy: .public) upcoming reminders")
guard let first = upcoming.first else {
deactivateReminder()
logger.debug("[Reminder] No upcoming reminders found; awaiting next calendar update")
return
}
logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ at \(first.triggerDate.timeIntervalSinceReferenceDate, privacy: .public)")
handleEntrySelection(first, referenceDate: referenceDate)
let signature = upcoming.map { $0.event.id }
if lastUpcomingSignature == signature {
if reason == .eventStoreChange {
lastNoopEventStoreRefreshDate = Date()
}
} else {
lastNoopEventStoreRefreshDate = nil
}
lastUpcomingSignature = signature
}
func requestRefresh(force: Bool = false) {
let reason: RefreshReason = force ? .manual : .other
if force {
let now = Date()
if let lastManualRefreshDate,
now.timeIntervalSince(lastManualRefreshDate) < manualRefreshSuppressionInterval {
let delta = now.timeIntervalSince(lastManualRefreshDate)
logger.debug("[Reminder] Skipping manual refresh; last manual was \(delta, format: .fixed(precision: 2))s ago")
return
}
lastManualRefreshDate = now
}
scheduleRefresh(force: force, reason: reason)
}
private func refreshUpcomingReminder(force: Bool = false, reason: RefreshReason = .other) async {
guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder()
logger.debug("[Reminder] Refresh aborted (feature disabled)")
return
}
guard calendarManager.hasReminderAccess else {
deactivateReminder()
logger.debug("[Reminder] Refresh aborted; reminder permissions missing")
return
}
let now = Date()
if !force, let entry = nextReminder, entry.event.start > now {
logger.debug("[Reminder] Skipping fetch; cached entry \(entry.event.title, privacy: .public) still valid for reason=\(reason.rawValue, privacy: .public)")
await evaluateCurrentState(at: now)
return
}
if let snapshotDate = lastCalendarSnapshotDate,
now.timeIntervalSince(snapshotDate) < 5,
!lastCalendarEventSnapshot.isEmpty {
logger.debug("[Reminder] Using calendar snapshot (age=\(now.timeIntervalSince(snapshotDate), format: .fixed(precision: 2))s) for reason=\(reason.rawValue, privacy: .public)")
refreshFromEvents(lastCalendarEventSnapshot, referenceDate: now, reason: reason)
return
}
let calendars = selectedCalendarIDs()
guard !calendars.isEmpty else {
deactivateReminder()
logger.debug("[Reminder] Refresh aborted; no calendars selected")
return
}
let windowEnd = Calendar.current.date(byAdding: .hour, value: 24, to: now) ?? now.addingTimeInterval(24 * 60 * 60)
logger.debug("[Reminder] Fetching reminders (reason=\(reason.rawValue, privacy: .public), force=\(force, privacy: .public), calendars=\(calendars.count, privacy: .public))")
let fetchStart = Date()
let events = await calendarService.events(from: now, to: windowEnd, calendars: calendars)
let fetchDuration = Date().timeIntervalSince(fetchStart)
logger.debug("[Reminder] EventKit fetch completed (events=\(events.count, privacy: .public)) in \(fetchDuration, format: .fixed(precision: 2))s")
lastCalendarEventSnapshot = events
lastCalendarSnapshotDate = now
lastCalendarEventSnapshotSignature = events.map { $0.id }
await MainActor.run {
self.refreshFromEvents(events, referenceDate: now, reason: reason)
}
}
func evaluateCurrentState(at date: Date) async {
guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder()
@ -450,13 +247,9 @@ final class ReminderLiveActivityManager: ObservableObject {
}
if entry.event.start <= date {
activeReminder = nil
nextReminder = nil
stopTicker()
hasShownCriticalSneakPeek = false
logger.debug("[Reminder] Reminder reached start time; requesting evaluation refresh")
reminderCompletionSuppressionDeadline = Date().addingTimeInterval(reminderCompletionSuppressionInterval)
scheduleRefresh(force: true, reason: .evaluation)
clearActiveReminderState()
logger.debug("[Reminder] Reminder reached start time; reevaluating reminders from cache")
recalculateUpcomingEntries(referenceDate: date, reason: "evaluation-complete")
return
}
@ -528,83 +321,6 @@ final class ReminderLiveActivityManager: ObservableObject {
}
}
private func handleEventStoreDrivenChange(reason: RefreshReason) {
processEventStoreChange(at: Date(), reason: reason)
}
private func processEventStoreChange(at date: Date, reason: RefreshReason) {
if let manual = lastManualRefreshDate {
let delta = date.timeIntervalSince(manual)
if delta < manualRefreshSuppressionInterval {
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public) change; manual refresh cooldown \(delta, format: .fixed(precision: 2))s")
return
}
}
if let lastRefreshStart = lastRefreshDate {
let sinceStart = date.timeIntervalSince(lastRefreshStart)
if sinceStart >= 0 && sinceStart < eventStoreChangeNoiseInterval {
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); \(sinceStart, format: .fixed(precision: 2))s since start")
return
}
}
if let lastCompletion = lastRefreshCompletionDate {
let sinceCompletion = abs(date.timeIntervalSince(lastCompletion))
if sinceCompletion < eventStoreChangeNoiseInterval {
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); \(sinceCompletion, format: .fixed(precision: 2))s since completion")
return
}
}
if reason == .eventStoreChange,
let suppressionDeadline = reminderCompletionSuppressionDeadline,
date <= suppressionDeadline {
let remaining = suppressionDeadline.timeIntervalSince(date)
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); reminder completion suppression \(remaining, format: .fixed(precision: 2))s")
return
}
if reason == .eventStoreChange,
let noopDate = lastNoopEventStoreRefreshDate,
date.timeIntervalSince(noopDate) < noopEventStoreSuppressionInterval {
let remaining = noopEventStoreSuppressionInterval - date.timeIntervalSince(noopDate)
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); identical upcoming entries \(remaining, format: .fixed(precision: 2))s window")
return
}
if let lastForce = lastEventStoreForceRefreshDate {
let sinceForce = date.timeIntervalSince(lastForce)
if sinceForce < self.eventStoreForceThrottleInterval {
logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); throttle window remaining \(self.eventStoreForceThrottleInterval - sinceForce, format: .fixed(precision: 2))s")
return
}
}
if refreshTask != nil {
pendingEventStoreChange = (date, reason)
logger.debug("[Reminder] Queued \(reason.rawValue, privacy: .public) change while refresh is running")
return
}
triggerEventStoreForcedRefresh(at: date, reason: reason)
}
private func processPendingEventStoreChangeIfNeeded() {
guard let pendingChange = pendingEventStoreChange else { return }
pendingEventStoreChange = nil
processEventStoreChange(at: pendingChange.date, reason: pendingChange.reason)
}
private func triggerEventStoreForcedRefresh(at date: Date = Date(), reason: RefreshReason) {
lastEventStoreForceRefreshDate = date
if reason == .eventStoreChange, reminderCompletionSuppressionDeadline != nil {
reminderCompletionSuppressionDeadline = nil
}
logger.debug("[Reminder] Triggering event-store refresh for \(reason.rawValue, privacy: .public)")
scheduleRefresh(force: true, reason: reason)
}
static func additionalHeight(forRowCount rowCount: Int) -> CGFloat {
guard rowCount > 0 else { return 0 }
let rows = CGFloat(rowCount)

View file

@ -503,6 +503,8 @@ extension Defaults.Keys {
// MARK: Calendar
static let calendarSelectionState = Key<CalendarSelectionState>("calendarSelectionState", default: .all)
static let showFullEventTitles = Key<Bool>("showFullEventTitles", default: false)
static let autoScrollToNextEvent = Key<Bool>("autoScrollToNextEvent", default: true)
// MARK: Fullscreen Media Detection
static let alwaysHideInFullscreen = Key<Bool>("alwaysHideInFullscreen", default: false)