Something is working Finally

This commit is contained in:
Hariharan Mudaliar 2025-11-23 22:25:39 +05:30
commit 29b5111a86
10 changed files with 404 additions and 315 deletions

View file

@ -419,6 +419,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather."; INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSRemindersUsageDescription = "Atoll needs Reminders access to show your reminders";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@ -487,6 +488,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather."; INFOPLIST_KEY_NSLocationUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Dynamic Island uses your location to display accurate lock screen weather.";
INFOPLIST_KEY_NSRemindersUsageDescription = "Atoll needs Reminders access to show your reminders";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",

View file

@ -58,16 +58,6 @@ struct ContentView: View {
@State private var hoverTask: Task<Void, Never>? @State private var hoverTask: Task<Void, Never>?
@State private var isHovering: Bool = false @State private var isHovering: Bool = false
@State private var hoverWorkItem: DispatchWorkItem? // Used by handleSimpleHover for stats closing logic
@State private var debounceWorkItem: DispatchWorkItem? // Used by handleSimpleHover
@State private var isHoverStateChanging: Bool = false
@State private var isStatsTransitioning: Bool = false
@State private var isSwitchingToStats: Bool = false
@State private var isViewTransitioning: Bool = false
@State private var statsTransitionWorkItem: DispatchWorkItem?
@State private var viewTransitionWorkItem: DispatchWorkItem?
@State private var sizeChangeWorkItem: DispatchWorkItem?
@State private var lastHapticTime: Date = Date() @State private var lastHapticTime: Date = Date()
@State private var gestureProgress: CGFloat = .zero @State private var gestureProgress: CGFloat = .zero
@ -103,6 +93,15 @@ struct ContentView: View {
#endif #endif
} }
private func runAfter(_ delay: TimeInterval, _ action: @escaping @Sendable @MainActor () -> Void) {
guard delay >= 0 else { return }
Task { @MainActor in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
action()
}
}
private func requestMusicControlWindowSyncIfHidden(forceRefresh: Bool = false, delay: TimeInterval = 0) { private func requestMusicControlWindowSyncIfHidden(forceRefresh: Bool = false, delay: TimeInterval = 0) {
guard !isMusicControlWindowVisible else { return } guard !isMusicControlWindowVisible else { return }
enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay) enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay)
@ -114,7 +113,6 @@ struct ContentView: View {
return .easeInOut(duration: 0.4) return .easeInOut(duration: 0.4)
} }
private let extendedHoverPadding: CGFloat = 30
private let zeroHeightHoverPadding: CGFloat = 10 private let zeroHeightHoverPadding: CGFloat = 10
private let statsAdditionalRowHeight: CGFloat = 110 private let statsAdditionalRowHeight: CGFloat = 110
private let musicControlPauseGrace: TimeInterval = 5 private let musicControlPauseGrace: TimeInterval = 5
@ -174,23 +172,18 @@ struct ContentView: View {
.animation(notchStateAnimation, value: vm.notchState) .animation(notchStateAnimation, value: vm.notchState)
.animation(viewTransitionAnimation, value: coordinator.currentView) .animation(viewTransitionAnimation, value: coordinator.currentView)
} }
.conditionalModifier(Defaults[.openNotchOnHover] && interactionsEnabled) { view in .conditionalModifier(interactionsEnabled) { view in
view.onHover { hovering in
handleHover(hovering)
}
}
.conditionalModifier(!Defaults[.openNotchOnHover] && interactionsEnabled) { view in
view view
.onHover { hovering in .onHover { hovering in
handleSimpleHover(hovering) handleHover(hovering)
} }
.onTapGesture { .onTapGesture {
if (vm.notchState == .closed) && Defaults[.enableHaptics] { if vm.notchState == .closed && Defaults[.enableHaptics] {
triggerHapticIfAllowed() triggerHapticIfAllowed()
} }
doOpen() openNotch()
} }
.conditionalModifier(Defaults[.enableGestures] && interactionsEnabled) { view in .conditionalModifier(Defaults[.enableGestures]) { view in
view view
.panGesture(direction: .down) { translation, phase in .panGesture(direction: .down) { translation, phase in
handleDownGesture(translation: translation, phase: phase) handleDownGesture(translation: translation, phase: phase)
@ -204,10 +197,10 @@ struct ContentView: View {
} }
} }
.onAppear(perform: { .onAppear(perform: {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { runAfter(1) {
withAnimation(vm.animation) { withAnimation(vm.animation) {
if coordinator.firstLaunch { if coordinator.firstLaunch {
doOpen() openNotch()
} }
} }
} }
@ -224,26 +217,20 @@ struct ContentView: View {
// Reset hover state when notch state changes // Reset hover state when notch state changes
if newState == .closed && isHovering { if newState == .closed && isHovering {
// Only reset visually, without triggering the hover logic again
isHoverStateChanging = true
withAnimation { withAnimation {
isHovering = false isHovering = false
} }
// Reset the flag after the animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
isHoverStateChanging = false
}
} }
} }
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive { if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive {
vm.close() vm.close()
} }
} }
} }
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in .onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive { if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive {
vm.close() vm.close()
} }
@ -251,7 +238,7 @@ struct ContentView: View {
} }
.onChange(of: vm.shouldRecheckHover) { _, _ in .onChange(of: vm.shouldRecheckHover) { _, _ in
// Recheck hover state when popovers are closed // Recheck hover state when popovers are closed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { runAfter(0.1) {
if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive && !isHovering { if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive && !vm.isMediaOutputPopoverActive && !vm.isReminderPopoverActive && !isHovering {
vm.close() vm.close()
} }
@ -260,15 +247,14 @@ struct ContentView: View {
.onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in .onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in
// When sneak peek finishes, check if user is still hovering and open notch if needed // When sneak peek finishes, check if user is still hovering and open notch if needed
if !sneakPeekShowing { if !sneakPeekShowing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { runAfter(0.2) {
if isHovering && vm.notchState == .closed { if isHovering && vm.notchState == .closed {
doOpen() openNotch()
} }
} }
} }
} }
.onChange(of: coordinator.currentView) { oldValue, newValue in .onChange(of: coordinator.currentView) { _, newValue in
// Update smart monitoring based on current view change
if enableStatsFeature { if enableStatsFeature {
let currentViewString = newValue == .stats ? "stats" : "other" let currentViewString = newValue == .stats ? "stats" : "other"
statsManager.updateMonitoringState( statsManager.updateMonitoringState(
@ -276,84 +262,6 @@ struct ContentView: View {
currentView: currentViewString currentView: currentViewString
) )
} }
// Cancel any pending close actions and ALL existing transition timers during view transitions
hoverWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
viewTransitionWorkItem?.cancel()
sizeChangeWorkItem?.cancel()
// Reset all transition flags immediately to prevent conflicts
isStatsTransitioning = false
isSwitchingToStats = false
isViewTransitioning = false
// Check if this transition will cause a size change
let baseOpenSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
let expandedHeight = baseOpenSize.height + statsAdditionalRowHeight
let oldSize = oldValue == .stats && statsTabHasExpandedHeight() ?
CGSize(width: baseOpenSize.width, height: expandedHeight) : baseOpenSize
let newSize = newValue == .stats && statsTabHasExpandedHeight() ?
CGSize(width: baseOpenSize.width, height: expandedHeight) : baseOpenSize
let sizeWillChange = oldSize != newSize
// Set flags for transition tracking
isSwitchingToStats = (oldValue != .stats && newValue == .stats)
// Set view transition flag if size will change with proper cleanup
if sizeWillChange {
isViewTransitioning = true
// Clear after animation duration + generous buffer
let workItem = DispatchWorkItem {
isViewTransitioning = false
}
viewTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: workItem)
}
// Provide protection when switching to stats tab with proper cleanup
if newValue == .stats {
isStatsTransitioning = true
// Much longer delay if stats tab has expanded height (4+ graphs)
let hasExpandedHeight = statsTabHasExpandedHeight()
let protectionDelay: Double = hasExpandedHeight ? 4.0 : 2.0 // Much longer delays
let workItem = DispatchWorkItem {
isStatsTransitioning = false
isSwitchingToStats = false
}
statsTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + protectionDelay, execute: workItem)
}
// Also provide brief protection when switching FROM stats to prevent premature closure
if oldValue == .stats && newValue != .stats {
isStatsTransitioning = true
let workItem = DispatchWorkItem {
isStatsTransitioning = false
}
statsTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem)
}
}
.onChange(of: [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph]) { _, _ in
// Protect during graph count changes that might affect size
if coordinator.currentView == .stats {
// Cancel existing transition timers to prevent conflicts
viewTransitionWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
isViewTransitioning = true
isStatsTransitioning = true
// Clear after animation duration + generous buffer
let workItem = DispatchWorkItem {
isViewTransitioning = false
isStatsTransitioning = false
}
viewTransitionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: workItem)
}
} }
.sensoryFeedback(.alignment, trigger: haptics) .sensoryFeedback(.alignment, trigger: haptics)
.contextMenu { .contextMenu {
@ -377,19 +285,10 @@ struct ContentView: View {
.animation(.easeInOut(duration: 0.4), value: coordinator.currentView) .animation(.easeInOut(duration: 0.4), value: coordinator.currentView)
.environmentObject(privacyManager) .environmentObject(privacyManager)
.onChange(of: dynamicNotchSize) { oldSize, newSize in .onChange(of: dynamicNotchSize) { oldSize, newSize in
// Protect against hover interference during frame size changes guard oldSize != newSize else { return }
if oldSize != newSize { let delay: TimeInterval = dynamicNotchResizeAnimation == nil ? 0.25 : 1.0
// Cancel existing size change timer to prevent conflicts runAfter(delay) {
sizeChangeWorkItem?.cancel() vm.shouldRecheckHover.toggle()
isViewTransitioning = true
let workItem = DispatchWorkItem {
isViewTransitioning = false
vm.shouldRecheckHover.toggle()
}
sizeChangeWorkItem = workItem
let delay: TimeInterval = dynamicNotchResizeAnimation == nil ? 0.25 : 1.0
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
} }
} }
.shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5) .shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
@ -486,12 +385,7 @@ struct ContentView: View {
} }
} }
.onDisappear { .onDisappear {
// Clean up all timer work items to prevent memory leaks and conflicts hoverTask?.cancel()
hoverWorkItem?.cancel()
debounceWorkItem?.cancel()
statsTransitionWorkItem?.cancel()
viewTransitionWorkItem?.cancel()
sizeChangeWorkItem?.cancel()
cancelMusicControlWindowSync() cancelMusicControlWindowSync()
hideMusicControlWindow() hideMusicControlWindow()
cancelMusicControlVisibilityTimer() cancelMusicControlVisibilityTimer()
@ -804,7 +698,7 @@ struct ContentView: View {
.onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in .onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in
if isTargeted, vm.notchState == .closed { if isTargeted, vm.notchState == .closed {
coordinator.currentView = .shelf coordinator.currentView = .shelf
doOpen() openNotch()
} else if !isTargeted { } else if !isTargeted {
print("DROP EVENT", vm.dropEvent) print("DROP EVENT", vm.dropEvent)
if vm.dropEvent { if vm.dropEvent {
@ -822,7 +716,7 @@ struct ContentView: View {
} }
// MARK: - Private Methods // MARK: - Private Methods
private func doOpen() { private func openNotch() {
withAnimation(.bouncy.speed(1.2)) { withAnimation(.bouncy.speed(1.2)) {
vm.open() vm.open()
} }
@ -856,19 +750,15 @@ struct ContentView: View {
self.isHovering, self.isHovering,
!self.coordinator.sneakPeek.show else { return } !self.coordinator.sneakPeek.show else { return }
self.doOpen() self.openNotch()
} }
} }
} else { } else {
if vm.notchState == .open && isViewTransitioning {
return
}
hoverTask = Task { hoverTask = Task {
try? await Task.sleep(for: .milliseconds(100)) try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
guard !self.isViewTransitioning else { return }
withAnimation(.bouncy.speed(1.2)) { withAnimation(.bouncy.speed(1.2)) {
self.isHovering = false self.isHovering = false
} }
@ -881,65 +771,6 @@ struct ContentView: View {
} }
} }
// Simple hover handler for non-openNotchOnHover mode
private func handleSimpleHover(_ hovering: Bool) {
// Cancel any existing work items first
hoverWorkItem?.cancel()
debounceWorkItem?.cancel()
if hovering {
// Mouse entered - always update visual state immediately
withAnimation(vm.animation) {
isHovering = true
}
return
}
// Mouse exited - always update visual state immediately
withAnimation(vm.animation) {
isHovering = false
}
// Apply stronger protection when switching to stats, especially with 4+ graphs
// Also protect during any size change animations
if isStatsTransitioning || (isSwitchingToStats && statsTabHasExpandedHeight()) || isViewTransitioning {
return // Skip closing during transitions, but allow visual updates
}
// Check if we should close after a delay
let hasPopovers = hasAnyActivePopovers()
if vm.notchState == .open && !hasPopovers {
let isStatsTab = coordinator.currentView == .stats
let hasExpandedHeight = isStatsTab && statsTabHasExpandedHeight()
// Much longer delays for stats tab, especially when it has expanded height
let delay: Double
if hasExpandedHeight {
delay = 2.0 // Very long for stats with 4+ graphs
} else if isStatsTab {
delay = 1.0 // Long for stats with 1-3 graphs
} else {
delay = 0.2 // Short for other tabs
}
let closeTask = DispatchWorkItem {
// Triple-check conditions including transition state
let stillNotHovering = !isHovering
let stillOpen = vm.notchState == .open
let stillNoPopovers = !hasAnyActivePopovers()
let notTransitioning = !isStatsTransitioning && !isSwitchingToStats && !isViewTransitioning
if stillNotHovering && stillOpen && stillNoPopovers && notTransitioning {
vm.close()
}
}
hoverWorkItem = closeTask
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: closeTask)
}
}
// Helper function to check if any popovers are active // Helper function to check if any popovers are active
private func hasAnyActivePopovers() -> Bool { private func hasAnyActivePopovers() -> Bool {
return vm.isBatteryPopoverActive || return vm.isBatteryPopoverActive ||
@ -961,10 +792,6 @@ struct ContentView: View {
} }
// Helper to check if stats tab has 4+ graphs (needs expanded height) // Helper to check if stats tab has 4+ graphs (needs expanded height)
private func statsTabHasExpandedHeight() -> Bool {
return statsRowCount() > 1
}
private func enabledStatsGraphCount() -> Int { private func enabledStatsGraphCount() -> Int {
var enabledCount = 0 var enabledCount = 0
if showCpuGraph { enabledCount += 1 } if showCpuGraph { enabledCount += 1 }
@ -1003,7 +830,7 @@ struct ContentView: View {
withAnimation(.smooth) { withAnimation(.smooth) {
gestureProgress = .zero gestureProgress = .zero
} }
doOpen() openNotch()
} }
} }

View file

@ -4192,6 +4192,7 @@
} }
}, },
"Calendar access is denied. Please enable it in System Settings." : { "Calendar access is denied. Please enable it in System Settings." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
@ -4291,6 +4292,10 @@
} }
} }
}, },
"Calendar or Reminder access is denied. Please enable it in System Settings." : {
"comment" : "A message displayed when a user has denied access to the calendar or reminders on their device. It instructs the user to open System Settings to grant access.",
"isCommentAutoGenerated" : true
},
"Calendars" : { "Calendars" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {

View file

@ -12,6 +12,7 @@ import EventKit
protocol CalendarServiceProviding { protocol CalendarServiceProviding {
func requestAccess() async -> Bool func requestAccess() async -> Bool
func requestAccess(to type: EKEntityType) async -> Bool
func calendars() async -> [CalendarModel] func calendars() async -> [CalendarModel]
func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel] func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel]
} }
@ -21,17 +22,22 @@ class CalendarService: CalendarServiceProviding {
@MainActor @MainActor
func requestAccess() async -> Bool { func requestAccess() async -> Bool {
let eventsAccess = await requestAccess(to: .event)
let remindersAccess = await requestAccess(to: .reminder)
return eventsAccess || remindersAccess
}
@MainActor
func requestAccess(to type: EKEntityType) async -> Bool {
do { do {
let eventsAccess = try await requestAccess(to: .event) return try await performAccessRequest(for: type)
let remindersAccess = try await requestAccess(to: .reminder)
return eventsAccess || remindersAccess // At least one should work
} catch { } catch {
print("Calendar access error: \(error)") print("Calendar access error: \(error)")
return false return false
} }
} }
private func requestAccess(to type: EKEntityType) async throws -> Bool { private func performAccessRequest(for type: EKEntityType) async throws -> Bool {
if #available(macOS 14.0, *) { if #available(macOS 14.0, *) {
switch type { switch type {
case .event: case .event:
@ -93,47 +99,27 @@ class CalendarService: CalendarServiceProviding {
} }
private func fetchReminders(from start: Date, to end: Date, calendars: [EKCalendar]) async -> [EventModel] { private func fetchReminders(from start: Date, to end: Date, calendars: [EKCalendar]) async -> [EventModel] {
await withTaskGroup(of: [EKReminder].self) { group in guard !calendars.isEmpty else { return [] }
var allReminders: [EKReminder] = []
// Fetch incomplete reminders return await withCheckedContinuation { continuation in
group.addTask { let predicate = store.predicateForReminders(in: calendars)
await withCheckedContinuation { continuation in store.fetchReminders(matching: predicate) { reminders in
let predicate = self.store.predicateForIncompleteReminders( guard let reminders else {
withDueDateStarting: start, continuation.resume(returning: [])
ending: end, return
calendars: calendars
)
self.store.fetchReminders(matching: predicate) { reminders in
continuation.resume(returning: reminders ?? [])
}
} }
}
// Fetch completed reminders let filtered = reminders.compactMap { reminder -> EventModel? in
group.addTask { guard let dueDate = reminder.dueDateComponents?.date,
await withCheckedContinuation { continuation in dueDate >= start,
let predicate = self.store.predicateForCompletedReminders( dueDate <= end else {
withCompletionDateStarting: start, return nil
ending: end,
calendars: calendars
)
self.store.fetchReminders(matching: predicate) { reminders in
continuation.resume(returning: reminders ?? [])
} }
return EventModel(from: reminder)
} }
continuation.resume(returning: filtered)
} }
for await reminders in group {
allReminders.append(contentsOf: reminders)
}
// Remove duplicates and convert to EventModel
let uniqueReminders = Dictionary(grouping: allReminders, by: \.calendarItemIdentifier)
.compactMapValues { $0.first }
.values
return Array(uniqueReminders.compactMap { EventModel(from: $0) })
} }
} }
} }

View file

@ -1063,8 +1063,8 @@ struct CalendarSettings: View {
var body: some View { var body: some View {
Form { Form {
if calendarManager.calendarAuthorizationStatus != .fullAccess { if !calendarManager.hasCalendarAccess || !calendarManager.hasReminderAccess {
Text("Calendar access is denied. Please enable it in System Settings.") Text("Calendar or Reminder access is denied. Please enable it in System Settings.")
.foregroundColor(.red) .foregroundColor(.red)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding() .padding()
@ -1072,7 +1072,8 @@ struct CalendarSettings: View {
HStack { HStack {
Button("Request Access") { Button("Request Access") {
Task { Task {
await calendarManager.checkCalendarAuthorization() await calendarManager.checkCalendarAuthorization(forceReload: true)
await calendarManager.checkReminderAuthorization(forceReload: true)
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@ -1179,6 +1180,7 @@ struct CalendarSettings: View {
.onAppear { .onAppear {
Task { Task {
await calendarManager.checkCalendarAuthorization() await calendarManager.checkCalendarAuthorization()
await calendarManager.checkReminderAuthorization()
} }
} }
.navigationTitle("Calendar") .navigationTitle("Calendar")
@ -1186,7 +1188,7 @@ struct CalendarSettings: View {
private func statusText(for status: EKAuthorizationStatus) -> String { private func statusText(for status: EKAuthorizationStatus) -> String {
switch status { switch status {
case .fullAccess: return "Full Access" case .fullAccess, .authorized: return "Full Access"
case .writeOnly: return "Write Only" case .writeOnly: return "Write Only"
case .denied: return "Denied" case .denied: return "Denied"
case .restricted: return "Restricted" case .restricted: return "Restricted"
@ -1197,7 +1199,7 @@ struct CalendarSettings: View {
private func color(for status: EKAuthorizationStatus) -> Color { private func color(for status: EKAuthorizationStatus) -> Color {
switch status { switch status {
case .fullAccess: return .green case .fullAccess, .authorized: return .green
case .writeOnly: return .yellow case .writeOnly: return .yellow
case .denied, .restricted: return .red case .denied, .restricted: return .red
case .notDetermined: return .secondary case .notDetermined: return .secondary

View file

@ -27,11 +27,14 @@ class CalendarManager: ObservableObject {
private var eventStoreChangedObserver: NSObjectProtocol? private var eventStoreChangedObserver: NSObjectProtocol?
var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) }
var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) }
private init() { private init() {
self.currentWeekStartDate = CalendarManager.startOfDay(Date()) self.currentWeekStartDate = CalendarManager.startOfDay(Date())
setupEventStoreChangedObserver() setupEventStoreChangedObserver()
Task { Task {
await reloadCalendarAndReminderLists() await self.bootstrapAuthorizations()
} }
} }
@ -62,41 +65,83 @@ class CalendarManager: ObservableObject {
updateSelectedCalendars() updateSelectedCalendars()
} }
func checkCalendarAuthorization() async { private func bootstrapAuthorizations() async {
let status = EKEventStore.authorizationStatus(for: .event) await checkCalendarAuthorization(forceReload: true)
let reminderStatus = EKEventStore.authorizationStatus(for: .reminder) await checkReminderAuthorization(forceReload: true)
DispatchQueue.main.async { }
print("📅 Current calendar authorization status: \(status)")
self.calendarAuthorizationStatus = status private func refreshAuthorizationStatuses() {
self.reminderAuthorizationStatus = reminderStatus calendarAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
reminderAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder)
}
private func isAuthorized(_ status: EKAuthorizationStatus) -> Bool {
switch status {
case .fullAccess, .authorized:
return true
default:
return false
} }
}
func checkCalendarAuthorization(forceReload: Bool = false) async {
refreshAuthorizationStatuses()
let status = calendarAuthorizationStatus
print("📅 Current calendar authorization status: \(status.rawValue)")
switch status { switch status {
case .notDetermined: case .notDetermined:
let granted = await calendarService.requestAccess() let granted = await calendarService.requestAccess(to: .event)
let newEventStatus = EKEventStore.authorizationStatus(for: .event) refreshAuthorizationStatuses()
let newReminderStatus = EKEventStore.authorizationStatus(for: .reminder) if granted || hasCalendarAccess {
self.calendarAuthorizationStatus = newEventStatus
self.reminderAuthorizationStatus = newReminderStatus
if granted {
await reloadCalendarAndReminderLists() await reloadCalendarAndReminderLists()
events = await calendarService.events( await updateEvents()
from: currentWeekStartDate,
to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!,
calendars: selectedCalendars.map { $0.id }
)
} }
case .restricted, .denied: case .restricted, .denied:
// Handle the case where the user has denied or restricted access
NSLog("Calendar access denied or restricted") NSLog("Calendar access denied or restricted")
case .writeOnly:
NSLog("Calendar write-only access")
case .fullAccess: case .fullAccess:
NSLog("Full access") guard forceReload else { return }
await reloadCalendarAndReminderLists() await reloadCalendarAndReminderLists()
await updateEvents() await updateEvents()
case .writeOnly:
NSLog("Write only")
@unknown default: @unknown default:
print("Unknown authorization status") if isAuthorized(status) {
guard forceReload else { return }
await reloadCalendarAndReminderLists()
await updateEvents()
} else {
print("Unknown authorization status: \(status.rawValue)")
}
}
}
func checkReminderAuthorization(forceReload: Bool = false) async {
refreshAuthorizationStatuses()
let status = reminderAuthorizationStatus
print("📅 Current reminder authorization status: \(status.rawValue)")
switch status {
case .notDetermined:
let granted = await calendarService.requestAccess(to: .reminder)
refreshAuthorizationStatuses()
if granted || hasReminderAccess {
await reloadCalendarAndReminderLists()
}
case .restricted, .denied:
NSLog("Reminder access denied or restricted")
case .writeOnly:
NSLog("Reminder write-only access")
case .fullAccess:
guard forceReload else { return }
await reloadCalendarAndReminderLists()
@unknown default:
if isAuthorized(status) {
guard forceReload else { return }
await reloadCalendarAndReminderLists()
} else {
print("Unknown reminder authorization status: \(status.rawValue)")
}
} }
} }

View file

@ -971,7 +971,7 @@ private enum WeatherSymbolMapper {
@MainActor @MainActor
private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManagerDelegate { private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManagerDelegate {
private let manager: CLLocationManager private let manager: CLLocationManager
private var continuation: CheckedContinuation<CLLocation?, Never>? private var pendingContinuations: [CheckedContinuation<CLLocation?, Never>] = []
private var lastLocation: CLLocation? private var lastLocation: CLLocation?
override init() { override init() {
@ -997,7 +997,7 @@ private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManag
} }
manager.requestLocation() manager.requestLocation()
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
self.continuation = continuation self.pendingContinuations.append(continuation)
} }
default: default:
return nil return nil
@ -1006,12 +1006,17 @@ private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManag
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
lastLocation = locations.last lastLocation = locations.last
continuation?.resume(returning: lastLocation) flushContinuations(with: lastLocation)
continuation = nil
} }
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(returning: nil) flushContinuations(with: nil)
continuation = nil }
private func flushContinuations(with location: CLLocation?) {
guard !pendingContinuations.isEmpty else { return }
let continuations = pendingContinuations
pendingContinuations.removeAll()
continuations.forEach { $0.resume(returning: location) }
} }
} }

View file

@ -101,7 +101,7 @@ final class MusicControlWindowManager {
window.orderOut(nil) window.orderOut(nil)
window.alphaValue = 0 window.alphaValue = 0
if tearDown { if tearDown {
tearDownHostingView() tearDownWindowResources(using: window)
} }
return return
} }
@ -119,17 +119,19 @@ final class MusicControlWindowManager {
window.orderOut(nil) window.orderOut(nil)
window.alphaValue = 0 window.alphaValue = 0
if tearDown { if tearDown {
self?.tearDownHostingView() self?.tearDownWindowResources(using: window)
} }
} }
} }
private func tearDownHostingView() { private func tearDownWindowResources(using window: NSWindow? = nil) {
if let window { let targetWindow = window ?? self.window
window.contentView = nil targetWindow?.contentView = nil
} targetWindow?.orderOut(nil)
hostingView = nil hostingView = nil
lastMetrics = nil lastMetrics = nil
self.window = nil
hasDelegated = false
} }
private func ensureHostingView(with overlay: MusicControlOverlay) -> NSHostingView<MusicControlOverlay> { private func ensureHostingView(with overlay: MusicControlOverlay) -> NSHostingView<MusicControlOverlay> {

View file

@ -10,6 +10,7 @@ import Defaults
import EventKit import EventKit
import Foundation import Foundation
import CoreGraphics import CoreGraphics
import os
@MainActor @MainActor
final class ReminderLiveActivityManager: ObservableObject { final class ReminderLiveActivityManager: ObservableObject {
@ -33,6 +34,22 @@ final class ReminderLiveActivityManager: ObservableObject {
@Published private(set) var upcomingEntries: [ReminderEntry] = [] @Published private(set) var upcomingEntries: [ReminderEntry] = []
@Published private(set) var activeWindowReminders: [ReminderEntry] = [] @Published private(set) var activeWindowReminders: [ReminderEntry] = []
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 fallback
case evaluation
case hover
case ticker
case other
}
private var nextReminder: ReminderEntry? private var nextReminder: ReminderEntry?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var tickerTask: Task<Void, Never>? { didSet { oldValue?.cancel() } } private var tickerTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
@ -42,11 +59,28 @@ final class ReminderLiveActivityManager: ObservableObject {
private var pendingRefreshTask: Task<Void, Never>? private var pendingRefreshTask: Task<Void, Never>?
private var pendingRefreshForce = false private var pendingRefreshForce = false
private var pendingRefreshToken = UUID() private var pendingRefreshToken = UUID()
private var pendingRefreshReason: RefreshReason = .other
private var lastKnownCalendarIDs = Set<String>()
private var lastRefreshDate: Date? private var lastRefreshDate: Date?
private var lastRefreshCompletionDate: Date?
private let minimumRefreshInterval: TimeInterval = 60 private let minimumRefreshInterval: TimeInterval = 60
private let refreshDebounceInterval: TimeInterval = 0.3 private let refreshDebounceInterval: TimeInterval = 0.3
private var refreshTaskToken = UUID() private var refreshTaskToken = UUID()
private var hasShownCriticalSneakPeek = false 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 let calendarService: CalendarServiceProviding private let calendarService: CalendarServiceProviding
private let calendarManager = CalendarManager.shared private let calendarManager = CalendarManager.shared
@ -56,7 +90,7 @@ final class ReminderLiveActivityManager: ObservableObject {
private init(calendarService: CalendarServiceProviding = CalendarService()) { private init(calendarService: CalendarServiceProviding = CalendarService()) {
self.calendarService = calendarService self.calendarService = calendarService
setupObservers() setupObservers()
scheduleRefresh(force: true) scheduleRefresh(force: true, reason: .initialization)
} }
private func setupObservers() { private func setupObservers() {
@ -64,7 +98,7 @@ final class ReminderLiveActivityManager: ObservableObject {
.sink { [weak self] change in .sink { [weak self] change in
guard let self else { return } guard let self else { return }
if change.newValue { if change.newValue {
self.scheduleRefresh(force: true) self.scheduleRefresh(force: true, reason: .defaults)
} else { } else {
self.deactivateReminder() self.deactivateReminder()
} }
@ -74,7 +108,7 @@ final class ReminderLiveActivityManager: ObservableObject {
Defaults.publisher(.reminderLeadTime, options: []) Defaults.publisher(.reminderLeadTime, options: [])
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self else { return } guard let self else { return }
self.scheduleRefresh(force: true) self.scheduleRefresh(force: true, reason: .defaults)
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -89,23 +123,27 @@ final class ReminderLiveActivityManager: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
calendarManager.$allCalendars calendarManager.$allCalendars
.sink { [weak self] _ in .sink { [weak self] calendars in
guard let self else { return } guard let self else { return }
self.scheduleRefresh(force: true) let identifiers = Set(calendars.map { $0.id })
guard identifiers != self.lastKnownCalendarIDs else { return }
self.lastKnownCalendarIDs = identifiers
self.handleEventStoreDrivenChange(reason: .calendarListChange)
} }
.store(in: &cancellables) .store(in: &cancellables)
calendarManager.$events calendarManager.$events
.sink { [weak self] _ in .receive(on: RunLoop.main)
.sink { [weak self] events in
guard let self else { return } guard let self else { return }
self.scheduleRefresh(force: false) self.handleCalendarEventsUpdate(events)
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.publisher(for: .EKEventStoreChanged) NotificationCenter.default.publisher(for: .EKEventStoreChanged)
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self else { return } guard let self else { return }
self.scheduleRefresh(force: true) self.handleEventStoreDrivenChange(reason: .eventStoreChange)
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
@ -122,6 +160,7 @@ final class ReminderLiveActivityManager: ObservableObject {
refreshTask?.cancel() refreshTask?.cancel()
refreshTask = nil refreshTask = nil
hasShownCriticalSneakPeek = false hasShownCriticalSneakPeek = false
pendingEventStoreChange = nil
} }
private func deactivateReminder() { private func deactivateReminder() {
@ -130,6 +169,8 @@ final class ReminderLiveActivityManager: ObservableObject {
upcomingEntries = [] upcomingEntries = []
activeWindowReminders = [] activeWindowReminders = []
cancelAllTimers() cancelAllTimers()
lastUpcomingSignature = []
lastNoopEventStoreRefreshDate = nil
} }
private func selectedCalendarIDs() -> [String] { private func selectedCalendarIDs() -> [String] {
@ -138,6 +179,24 @@ final class ReminderLiveActivityManager: ObservableObject {
.map { $0.id } .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
guard Defaults[.enableReminderLiveActivity] else { return }
logger.debug("[Reminder] Applying calendar snapshot update (events=\(events.count, privacy: .public))")
refreshFromEvents(events, referenceDate: now, reason: .calendarEventsUpdate)
}
private func shouldHide(_ event: EventModel) -> Bool { private func shouldHide(_ event: EventModel) -> Bool {
if event.isAllDay && Defaults[.hideAllDayEvents] { if event.isAllDay && Defaults[.hideAllDayEvents] {
return true return true
@ -174,18 +233,28 @@ final class ReminderLiveActivityManager: ObservableObject {
private func scheduleFallbackRefresh() { private func scheduleFallbackRefresh() {
fallbackRefreshTask?.cancel() fallbackRefreshTask?.cancel()
let delay: TimeInterval = 15 * 60 let delay: TimeInterval = 15 * 60
logger.debug("[Reminder] Scheduling fallback refresh in \(delay, format: .fixed(precision: 0))s")
fallbackRefreshTask = Task { [weak self] in fallbackRefreshTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) do {
await self.scheduleRefresh(force: true) try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} catch {
self.logger.debug("[Reminder] Cancelled pending fallback refresh before execution")
return
}
await self.scheduleRefresh(force: true, reason: .fallback)
} }
} }
private func scheduleRefresh(force: Bool) { private func scheduleRefresh(force: Bool, reason: RefreshReason = .other) {
let wasPending = pendingRefreshTask != nil
pendingRefreshForce = pendingRefreshForce || force pendingRefreshForce = pendingRefreshForce || force
pendingRefreshToken = UUID() pendingRefreshToken = UUID()
pendingRefreshReason = reason
let token = pendingRefreshToken let token = pendingRefreshToken
logger.debug("[Reminder] scheduleRefresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public) pending=\(wasPending, privacy: .public)")
refreshTask?.cancel() refreshTask?.cancel()
pendingRefreshTask?.cancel() pendingRefreshTask?.cancel()
pendingRefreshTask = Task { [weak self] in pendingRefreshTask = Task { [weak self] in
@ -193,6 +262,7 @@ final class ReminderLiveActivityManager: ObservableObject {
do { do {
try await Task.sleep(nanoseconds: UInt64(self.refreshDebounceInterval * 1_000_000_000)) try await Task.sleep(nanoseconds: UInt64(self.refreshDebounceInterval * 1_000_000_000))
} catch { } catch {
self.logger.debug("[Reminder] Debounced refresh for reason=\(reason.rawValue, privacy: .public) was cancelled")
return return
} }
await self.executeScheduledRefresh(token: token) await self.executeScheduledRefresh(token: token)
@ -204,15 +274,18 @@ final class ReminderLiveActivityManager: ObservableObject {
pendingRefreshTask = nil pendingRefreshTask = nil
if Task.isCancelled { if Task.isCancelled {
logger.debug("[Reminder] Cancelled refresh token=\(token.uuidString, privacy: .public)")
return return
} }
let reason = pendingRefreshReason
let now = Date() let now = Date()
let force = pendingRefreshForce let force = pendingRefreshForce
if !force, let last = lastRefreshDate { if !force, let last = lastRefreshDate {
let elapsed = now.timeIntervalSince(last) let elapsed = now.timeIntervalSince(last)
if elapsed < minimumRefreshInterval { if elapsed < minimumRefreshInterval {
let remaining = max(minimumRefreshInterval - elapsed, refreshDebounceInterval) 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() pendingRefreshToken = UUID()
let nextToken = pendingRefreshToken let nextToken = pendingRefreshToken
pendingRefreshTask = Task { [weak self] in pendingRefreshTask = Task { [weak self] in
@ -220,6 +293,7 @@ final class ReminderLiveActivityManager: ObservableObject {
do { do {
try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
} catch { } catch {
self.logger.debug("[Reminder] Deferred refresh for reason=\(reason.rawValue, privacy: .public) was cancelled")
return return
} }
await self.executeScheduledRefresh(token: nextToken) await self.executeScheduledRefresh(token: nextToken)
@ -231,17 +305,24 @@ final class ReminderLiveActivityManager: ObservableObject {
pendingRefreshForce = false pendingRefreshForce = false
lastRefreshDate = now lastRefreshDate = now
let startTime = Date()
logger.debug("[Reminder] Starting refresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public)")
refreshTask?.cancel() refreshTask?.cancel()
let taskToken = UUID() let taskToken = UUID()
refreshTaskToken = taskToken refreshTaskToken = taskToken
let task = Task { [weak self] in let task = Task { [weak self] in
guard let self else { return } guard let self else { return }
await self.refreshUpcomingReminder(force: force) await self.refreshUpcomingReminder(force: force, reason: reason)
} }
refreshTask = task refreshTask = task
defer { defer {
if refreshTaskToken == taskToken { if refreshTaskToken == taskToken {
refreshTask = nil 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 _ = try? await task.value
@ -262,6 +343,7 @@ final class ReminderLiveActivityManager: ObservableObject {
} }
private func stopTicker() { private func stopTicker() {
tickerTask?.cancel()
tickerTask = nil tickerTask = nil
} }
@ -272,7 +354,7 @@ final class ReminderLiveActivityManager: ObservableObject {
Task { await self.evaluateCurrentState(at: referenceDate) } Task { await self.evaluateCurrentState(at: referenceDate) }
} }
private func refreshFromEvents(_ events: [EventModel], referenceDate: Date) { private func refreshFromEvents(_ events: [EventModel], referenceDate: Date, reason: RefreshReason) {
let leadMinutes = Defaults[.reminderLeadTime] let leadMinutes = Defaults[.reminderLeadTime]
let upcoming = events let upcoming = events
.filter { !shouldHide($0) } .filter { !shouldHide($0) }
@ -282,42 +364,92 @@ final class ReminderLiveActivityManager: ObservableObject {
upcomingEntries = upcoming upcomingEntries = upcoming
updateActiveWindowReminders(for: referenceDate) 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 { guard let first = upcoming.first else {
deactivateReminder() deactivateReminder()
logger.debug("[Reminder] No upcoming reminders found; scheduling fallback refresh")
scheduleFallbackRefresh() scheduleFallbackRefresh()
return return
} }
logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ at \(first.triggerDate.timeIntervalSinceReferenceDate, privacy: .public)")
handleEntrySelection(first, referenceDate: referenceDate) 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) { func requestRefresh(force: Bool = false) {
scheduleRefresh(force: force) 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)
} }
func refreshUpcomingReminder(force: Bool = false) async { private func refreshUpcomingReminder(force: Bool = false, reason: RefreshReason = .other) async {
guard Defaults[.enableReminderLiveActivity] else { guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder() deactivateReminder()
logger.debug("[Reminder] Refresh aborted (feature disabled)")
return
}
guard calendarManager.hasReminderAccess else {
deactivateReminder()
logger.debug("[Reminder] Refresh aborted; reminder permissions missing")
return return
} }
let now = Date() let now = Date()
if !force, let entry = nextReminder, entry.event.start > now { 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) await evaluateCurrentState(at: now)
return 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() let calendars = selectedCalendarIDs()
guard !calendars.isEmpty else { guard !calendars.isEmpty else {
deactivateReminder() deactivateReminder()
logger.debug("[Reminder] Refresh aborted; no calendars selected")
return return
} }
let windowEnd = Calendar.current.date(byAdding: .hour, value: 24, to: now) ?? now.addingTimeInterval(24 * 60 * 60) 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 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 { await MainActor.run {
self.refreshFromEvents(events, referenceDate: now) self.refreshFromEvents(events, referenceDate: now, reason: reason)
} }
} }
@ -331,8 +463,14 @@ final class ReminderLiveActivityManager: ObservableObject {
updateActiveWindowReminders(for: date) updateActiveWindowReminders(for: date)
guard var entry = nextReminder else { guard var entry = nextReminder else {
activeReminder = nil if activeReminder != nil {
activeReminder = nil
}
stopTicker() stopTicker()
hasShownCriticalSneakPeek = false
if fallbackRefreshTask == nil {
scheduleFallbackRefresh()
}
return return
} }
@ -341,7 +479,9 @@ final class ReminderLiveActivityManager: ObservableObject {
nextReminder = nil nextReminder = nil
stopTicker() stopTicker()
hasShownCriticalSneakPeek = false hasShownCriticalSneakPeek = false
scheduleRefresh(force: true) logger.debug("[Reminder] Reminder reached start time; requesting evaluation refresh")
reminderCompletionSuppressionDeadline = Date().addingTimeInterval(reminderCompletionSuppressionInterval)
scheduleRefresh(force: true, reason: .evaluation)
return return
} }
@ -408,10 +548,88 @@ final class ReminderLiveActivityManager: ObservableObject {
entry.triggerDate <= date && entry.event.start >= date entry.triggerDate <= date && entry.event.start >= date
} }
if filtered != activeWindowReminders { if filtered != activeWindowReminders {
logger.debug("[Reminder] Active window reminder count -> \(filtered.count, privacy: .public)")
activeWindowReminders = filtered activeWindowReminders = filtered
} }
} }
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 { static func additionalHeight(forRowCount rowCount: Int) -> CGFloat {
guard rowCount > 0 else { return 0 } guard rowCount > 0 else { return 0 }
let rows = CGFloat(rowCount) let rows = CGFloat(rowCount)

View file

@ -183,9 +183,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
// Force music information update when notch is opened // Force music information update when notch is opened
MusicManager.shared.forceUpdate() MusicManager.shared.forceUpdate()
// Refresh reminders immediately to surface the latest state when the notch opens
ReminderLiveActivityManager.shared.requestRefresh(force: true)
} }
private func calculateDynamicNotchSize() -> CGSize { private func calculateDynamicNotchSize() -> CGSize {