From 29b5111a864ef1f47604498f5a4516d5c9c87505 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 23 Nov 2025 22:25:39 +0530 Subject: [PATCH] Something is working Finally --- DynamicIsland.xcodeproj/project.pbxproj | 2 + DynamicIsland/ContentView.swift | 251 +++-------------- DynamicIsland/Localizable.xcstrings | 5 + .../Providers/CalendarServiceProviding.swift | 70 ++--- .../components/Settings/SettingsView.swift | 12 +- DynamicIsland/managers/CalendarManager.swift | 93 +++++-- .../managers/LockScreenWeatherManager.swift | 17 +- .../managers/MusicControlWindowManager.swift | 14 +- .../ReminderLiveActivityManager.swift | 254 ++++++++++++++++-- .../models/DynamicIslandViewModel.swift | 3 - 10 files changed, 405 insertions(+), 316 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 8e52725..21588bc 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -419,6 +419,7 @@ INFOPLIST_KEY_NSHumanReadableCopyright = ""; 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_NSRemindersUsageDescription = "Atoll needs Reminders access to show your reminders"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -487,6 +488,7 @@ INFOPLIST_KEY_NSHumanReadableCopyright = ""; 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_NSRemindersUsageDescription = "Atoll needs Reminders access to show your reminders"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 4786404..d554847 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -58,16 +58,6 @@ struct ContentView: View { @State private var hoverTask: Task? @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 gestureProgress: CGFloat = .zero @@ -103,6 +93,15 @@ struct ContentView: View { #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) { guard !isMusicControlWindowVisible else { return } enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay) @@ -114,7 +113,6 @@ struct ContentView: View { return .easeInOut(duration: 0.4) } - private let extendedHoverPadding: CGFloat = 30 private let zeroHeightHoverPadding: CGFloat = 10 private let statsAdditionalRowHeight: CGFloat = 110 private let musicControlPauseGrace: TimeInterval = 5 @@ -174,23 +172,18 @@ struct ContentView: View { .animation(notchStateAnimation, value: vm.notchState) .animation(viewTransitionAnimation, value: coordinator.currentView) } - .conditionalModifier(Defaults[.openNotchOnHover] && interactionsEnabled) { view in - view.onHover { hovering in - handleHover(hovering) - } - } - .conditionalModifier(!Defaults[.openNotchOnHover] && interactionsEnabled) { view in + .conditionalModifier(interactionsEnabled) { view in view .onHover { hovering in - handleSimpleHover(hovering) + handleHover(hovering) } .onTapGesture { - if (vm.notchState == .closed) && Defaults[.enableHaptics] { + if vm.notchState == .closed && Defaults[.enableHaptics] { triggerHapticIfAllowed() } - doOpen() + openNotch() } - .conditionalModifier(Defaults[.enableGestures] && interactionsEnabled) { view in + .conditionalModifier(Defaults[.enableGestures]) { view in view .panGesture(direction: .down) { translation, phase in handleDownGesture(translation: translation, phase: phase) @@ -204,10 +197,10 @@ struct ContentView: View { } } .onAppear(perform: { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + runAfter(1) { withAnimation(vm.animation) { if coordinator.firstLaunch { - doOpen() + openNotch() } } } @@ -224,26 +217,20 @@ struct ContentView: View { // Reset hover state when notch state changes if newState == .closed && isHovering { - // Only reset visually, without triggering the hover logic again - isHoverStateChanging = true withAnimation { isHovering = false } - // Reset the flag after the animation completes - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isHoverStateChanging = false - } } } .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 { vm.close() } } } .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 { vm.close() } @@ -251,7 +238,7 @@ struct ContentView: View { } .onChange(of: vm.shouldRecheckHover) { _, _ in // 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 { vm.close() } @@ -260,15 +247,14 @@ struct ContentView: View { .onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in // When sneak peek finishes, check if user is still hovering and open notch if needed if !sneakPeekShowing { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + runAfter(0.2) { if isHovering && vm.notchState == .closed { - doOpen() + openNotch() } } } } - .onChange(of: coordinator.currentView) { oldValue, newValue in - // Update smart monitoring based on current view change + .onChange(of: coordinator.currentView) { _, newValue in if enableStatsFeature { let currentViewString = newValue == .stats ? "stats" : "other" statsManager.updateMonitoringState( @@ -276,84 +262,6 @@ struct ContentView: View { 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) .contextMenu { @@ -377,19 +285,10 @@ struct ContentView: View { .animation(.easeInOut(duration: 0.4), value: coordinator.currentView) .environmentObject(privacyManager) .onChange(of: dynamicNotchSize) { oldSize, newSize in - // Protect against hover interference during frame size changes - if oldSize != newSize { - // Cancel existing size change timer to prevent conflicts - sizeChangeWorkItem?.cancel() - - 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) + guard oldSize != newSize else { return } + let delay: TimeInterval = dynamicNotchResizeAnimation == nil ? 0.25 : 1.0 + runAfter(delay) { + vm.shouldRecheckHover.toggle() } } .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 { - // Clean up all timer work items to prevent memory leaks and conflicts - hoverWorkItem?.cancel() - debounceWorkItem?.cancel() - statsTransitionWorkItem?.cancel() - viewTransitionWorkItem?.cancel() - sizeChangeWorkItem?.cancel() + hoverTask?.cancel() cancelMusicControlWindowSync() hideMusicControlWindow() cancelMusicControlVisibilityTimer() @@ -804,7 +698,7 @@ struct ContentView: View { .onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in if isTargeted, vm.notchState == .closed { coordinator.currentView = .shelf - doOpen() + openNotch() } else if !isTargeted { print("DROP EVENT", vm.dropEvent) if vm.dropEvent { @@ -822,7 +716,7 @@ struct ContentView: View { } // MARK: - Private Methods - private func doOpen() { + private func openNotch() { withAnimation(.bouncy.speed(1.2)) { vm.open() } @@ -833,46 +727,42 @@ struct ContentView: View { /// Handle hover state changes with debouncing private func handleHover(_ hovering: Bool) { hoverTask?.cancel() - + if hovering { withAnimation(.bouncy.speed(1.2)) { isHovering = true } - + if vm.notchState == .closed && Defaults[.enableHaptics] { triggerHapticIfAllowed() } - + guard vm.notchState == .closed, !coordinator.sneakPeek.show, Defaults[.openNotchOnHover] else { return } - + hoverTask = Task { try? await Task.sleep(for: .seconds(Defaults[.minimumHoverDuration])) guard !Task.isCancelled else { return } - + await MainActor.run { guard self.vm.notchState == .closed, self.isHovering, !self.coordinator.sneakPeek.show else { return } - - self.doOpen() + + self.openNotch() } } } else { - if vm.notchState == .open && isViewTransitioning { - return - } hoverTask = Task { try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { return } - + await MainActor.run { - guard !self.isViewTransitioning else { return } withAnimation(.bouncy.speed(1.2)) { self.isHovering = false } - + if self.vm.notchState == .open && !self.hasAnyActivePopovers() { self.vm.close() } @@ -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 private func hasAnyActivePopovers() -> Bool { return vm.isBatteryPopoverActive || @@ -961,10 +792,6 @@ struct ContentView: View { } // Helper to check if stats tab has 4+ graphs (needs expanded height) - private func statsTabHasExpandedHeight() -> Bool { - return statsRowCount() > 1 - } - private func enabledStatsGraphCount() -> Int { var enabledCount = 0 if showCpuGraph { enabledCount += 1 } @@ -1003,7 +830,7 @@ struct ContentView: View { withAnimation(.smooth) { gestureProgress = .zero } - doOpen() + openNotch() } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index ff79186..515e229 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -4192,6 +4192,7 @@ } }, "Calendar access is denied. Please enable it in System Settings." : { + "extractionState" : "stale", "localizations" : { "ar" : { "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" : { "localizations" : { "fr" : { diff --git a/DynamicIsland/Providers/CalendarServiceProviding.swift b/DynamicIsland/Providers/CalendarServiceProviding.swift index 3a37e04..f7e8a13 100644 --- a/DynamicIsland/Providers/CalendarServiceProviding.swift +++ b/DynamicIsland/Providers/CalendarServiceProviding.swift @@ -12,6 +12,7 @@ import EventKit protocol CalendarServiceProviding { func requestAccess() async -> Bool + func requestAccess(to type: EKEntityType) async -> Bool func calendars() async -> [CalendarModel] func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel] } @@ -21,17 +22,22 @@ class CalendarService: CalendarServiceProviding { @MainActor 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 { - let eventsAccess = try await requestAccess(to: .event) - let remindersAccess = try await requestAccess(to: .reminder) - return eventsAccess || remindersAccess // At least one should work + return try await performAccessRequest(for: type) } catch { print("Calendar access error: \(error)") 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, *) { switch type { case .event: @@ -93,47 +99,27 @@ class CalendarService: CalendarServiceProviding { } private func fetchReminders(from start: Date, to end: Date, calendars: [EKCalendar]) async -> [EventModel] { - await withTaskGroup(of: [EKReminder].self) { group in - var allReminders: [EKReminder] = [] - - // Fetch incomplete reminders - group.addTask { - await withCheckedContinuation { continuation in - let predicate = self.store.predicateForIncompleteReminders( - withDueDateStarting: start, - ending: end, - calendars: calendars - ) - self.store.fetchReminders(matching: predicate) { reminders in - continuation.resume(returning: reminders ?? []) - } + guard !calendars.isEmpty else { return [] } + + return await withCheckedContinuation { continuation in + let predicate = store.predicateForReminders(in: calendars) + store.fetchReminders(matching: predicate) { reminders in + guard let reminders else { + continuation.resume(returning: []) + return } - } - - // Fetch completed reminders - group.addTask { - await withCheckedContinuation { continuation in - let predicate = self.store.predicateForCompletedReminders( - withCompletionDateStarting: start, - ending: end, - calendars: calendars - ) - self.store.fetchReminders(matching: predicate) { reminders in - continuation.resume(returning: reminders ?? []) + + let filtered = reminders.compactMap { reminder -> EventModel? in + guard let dueDate = reminder.dueDateComponents?.date, + dueDate >= start, + dueDate <= end else { + return nil } + 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) }) } } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 4f375fa..805b8f0 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1063,8 +1063,8 @@ struct CalendarSettings: View { var body: some View { Form { - if calendarManager.calendarAuthorizationStatus != .fullAccess { - Text("Calendar access is denied. Please enable it in System Settings.") + if !calendarManager.hasCalendarAccess || !calendarManager.hasReminderAccess { + Text("Calendar or Reminder access is denied. Please enable it in System Settings.") .foregroundColor(.red) .multilineTextAlignment(.center) .padding() @@ -1072,7 +1072,8 @@ struct CalendarSettings: View { HStack { Button("Request Access") { Task { - await calendarManager.checkCalendarAuthorization() + await calendarManager.checkCalendarAuthorization(forceReload: true) + await calendarManager.checkReminderAuthorization(forceReload: true) } } .buttonStyle(.borderedProminent) @@ -1179,6 +1180,7 @@ struct CalendarSettings: View { .onAppear { Task { await calendarManager.checkCalendarAuthorization() + await calendarManager.checkReminderAuthorization() } } .navigationTitle("Calendar") @@ -1186,7 +1188,7 @@ struct CalendarSettings: View { private func statusText(for status: EKAuthorizationStatus) -> String { switch status { - case .fullAccess: return "Full Access" + case .fullAccess, .authorized: return "Full Access" case .writeOnly: return "Write Only" case .denied: return "Denied" case .restricted: return "Restricted" @@ -1197,7 +1199,7 @@ struct CalendarSettings: View { private func color(for status: EKAuthorizationStatus) -> Color { switch status { - case .fullAccess: return .green + case .fullAccess, .authorized: return .green case .writeOnly: return .yellow case .denied, .restricted: return .red case .notDetermined: return .secondary diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index d0ef085..a2b5c5a 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -27,11 +27,14 @@ class CalendarManager: ObservableObject { private var eventStoreChangedObserver: NSObjectProtocol? + var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) } + var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } + private init() { self.currentWeekStartDate = CalendarManager.startOfDay(Date()) setupEventStoreChangedObserver() Task { - await reloadCalendarAndReminderLists() + await self.bootstrapAuthorizations() } } @@ -62,41 +65,83 @@ class CalendarManager: ObservableObject { updateSelectedCalendars() } - func checkCalendarAuthorization() async { - let status = EKEventStore.authorizationStatus(for: .event) - let reminderStatus = EKEventStore.authorizationStatus(for: .reminder) - DispatchQueue.main.async { - print("📅 Current calendar authorization status: \(status)") - self.calendarAuthorizationStatus = status - self.reminderAuthorizationStatus = reminderStatus + private func bootstrapAuthorizations() async { + await checkCalendarAuthorization(forceReload: true) + await checkReminderAuthorization(forceReload: true) + } + + private func refreshAuthorizationStatuses() { + 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 { case .notDetermined: - let granted = await calendarService.requestAccess() - let newEventStatus = EKEventStore.authorizationStatus(for: .event) - let newReminderStatus = EKEventStore.authorizationStatus(for: .reminder) - self.calendarAuthorizationStatus = newEventStatus - self.reminderAuthorizationStatus = newReminderStatus - if granted { + let granted = await calendarService.requestAccess(to: .event) + refreshAuthorizationStatuses() + if granted || hasCalendarAccess { await reloadCalendarAndReminderLists() - events = await calendarService.events( - from: currentWeekStartDate, - to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, - calendars: selectedCalendars.map { $0.id } - ) + await updateEvents() } case .restricted, .denied: - // Handle the case where the user has denied or restricted access NSLog("Calendar access denied or restricted") + case .writeOnly: + NSLog("Calendar write-only access") case .fullAccess: - NSLog("Full access") + guard forceReload else { return } await reloadCalendarAndReminderLists() await updateEvents() - case .writeOnly: - NSLog("Write only") @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)") + } } } diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index 7127580..a9f1626 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -971,7 +971,7 @@ private enum WeatherSymbolMapper { @MainActor private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManagerDelegate { private let manager: CLLocationManager - private var continuation: CheckedContinuation? + private var pendingContinuations: [CheckedContinuation] = [] private var lastLocation: CLLocation? override init() { @@ -997,7 +997,7 @@ private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManag } manager.requestLocation() return await withCheckedContinuation { continuation in - self.continuation = continuation + self.pendingContinuations.append(continuation) } default: return nil @@ -1006,12 +1006,17 @@ private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManag func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { lastLocation = locations.last - continuation?.resume(returning: lastLocation) - continuation = nil + flushContinuations(with: lastLocation) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - continuation?.resume(returning: nil) - continuation = nil + flushContinuations(with: nil) + } + + private func flushContinuations(with location: CLLocation?) { + guard !pendingContinuations.isEmpty else { return } + let continuations = pendingContinuations + pendingContinuations.removeAll() + continuations.forEach { $0.resume(returning: location) } } } diff --git a/DynamicIsland/managers/MusicControlWindowManager.swift b/DynamicIsland/managers/MusicControlWindowManager.swift index b68aad7..a7857b7 100644 --- a/DynamicIsland/managers/MusicControlWindowManager.swift +++ b/DynamicIsland/managers/MusicControlWindowManager.swift @@ -101,7 +101,7 @@ final class MusicControlWindowManager { window.orderOut(nil) window.alphaValue = 0 if tearDown { - tearDownHostingView() + tearDownWindowResources(using: window) } return } @@ -119,17 +119,19 @@ final class MusicControlWindowManager { window.orderOut(nil) window.alphaValue = 0 if tearDown { - self?.tearDownHostingView() + self?.tearDownWindowResources(using: window) } } } - private func tearDownHostingView() { - if let window { - window.contentView = nil - } + private func tearDownWindowResources(using window: NSWindow? = nil) { + let targetWindow = window ?? self.window + targetWindow?.contentView = nil + targetWindow?.orderOut(nil) hostingView = nil lastMetrics = nil + self.window = nil + hasDelegated = false } private func ensureHostingView(with overlay: MusicControlOverlay) -> NSHostingView { diff --git a/DynamicIsland/managers/ReminderLiveActivityManager.swift b/DynamicIsland/managers/ReminderLiveActivityManager.swift index 2ec534f..7b74353 100644 --- a/DynamicIsland/managers/ReminderLiveActivityManager.swift +++ b/DynamicIsland/managers/ReminderLiveActivityManager.swift @@ -10,6 +10,7 @@ import Defaults import EventKit import Foundation import CoreGraphics +import os @MainActor final class ReminderLiveActivityManager: ObservableObject { @@ -33,6 +34,22 @@ final class ReminderLiveActivityManager: ObservableObject { @Published private(set) var upcomingEntries: [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 cancellables = Set() private var tickerTask: Task? { didSet { oldValue?.cancel() } } @@ -42,11 +59,28 @@ final class ReminderLiveActivityManager: ObservableObject { private var pendingRefreshTask: Task? private var pendingRefreshForce = false private var pendingRefreshToken = UUID() + private var pendingRefreshReason: RefreshReason = .other + private var lastKnownCalendarIDs = Set() 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 let calendarService: CalendarServiceProviding private let calendarManager = CalendarManager.shared @@ -56,7 +90,7 @@ final class ReminderLiveActivityManager: ObservableObject { private init(calendarService: CalendarServiceProviding = CalendarService()) { self.calendarService = calendarService setupObservers() - scheduleRefresh(force: true) + scheduleRefresh(force: true, reason: .initialization) } private func setupObservers() { @@ -64,7 +98,7 @@ final class ReminderLiveActivityManager: ObservableObject { .sink { [weak self] change in guard let self else { return } if change.newValue { - self.scheduleRefresh(force: true) + self.scheduleRefresh(force: true, reason: .defaults) } else { self.deactivateReminder() } @@ -74,7 +108,7 @@ final class ReminderLiveActivityManager: ObservableObject { Defaults.publisher(.reminderLeadTime, options: []) .sink { [weak self] _ in guard let self else { return } - self.scheduleRefresh(force: true) + self.scheduleRefresh(force: true, reason: .defaults) } .store(in: &cancellables) @@ -89,23 +123,27 @@ final class ReminderLiveActivityManager: ObservableObject { .store(in: &cancellables) calendarManager.$allCalendars - .sink { [weak self] _ in + .sink { [weak self] calendars in 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) calendarManager.$events - .sink { [weak self] _ in + .receive(on: RunLoop.main) + .sink { [weak self] events in guard let self else { return } - self.scheduleRefresh(force: false) + self.handleCalendarEventsUpdate(events) } .store(in: &cancellables) NotificationCenter.default.publisher(for: .EKEventStoreChanged) .sink { [weak self] _ in guard let self else { return } - self.scheduleRefresh(force: true) + self.handleEventStoreDrivenChange(reason: .eventStoreChange) } .store(in: &cancellables) } @@ -122,6 +160,7 @@ final class ReminderLiveActivityManager: ObservableObject { refreshTask?.cancel() refreshTask = nil hasShownCriticalSneakPeek = false + pendingEventStoreChange = nil } private func deactivateReminder() { @@ -130,6 +169,8 @@ final class ReminderLiveActivityManager: ObservableObject { upcomingEntries = [] activeWindowReminders = [] cancelAllTimers() + lastUpcomingSignature = [] + lastNoopEventStoreRefreshDate = nil } private func selectedCalendarIDs() -> [String] { @@ -138,6 +179,24 @@ final class ReminderLiveActivityManager: ObservableObject { .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 { if event.isAllDay && Defaults[.hideAllDayEvents] { return true @@ -174,18 +233,28 @@ final class ReminderLiveActivityManager: ObservableObject { private func scheduleFallbackRefresh() { fallbackRefreshTask?.cancel() let delay: TimeInterval = 15 * 60 + logger.debug("[Reminder] Scheduling fallback refresh in \(delay, format: .fixed(precision: 0))s") fallbackRefreshTask = Task { [weak self] in guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - await self.scheduleRefresh(force: true) + do { + 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 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 @@ -193,6 +262,7 @@ final class ReminderLiveActivityManager: ObservableObject { 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) @@ -204,15 +274,18 @@ final class ReminderLiveActivityManager: ObservableObject { 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 @@ -220,6 +293,7 @@ final class ReminderLiveActivityManager: ObservableObject { 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) @@ -231,17 +305,24 @@ final class ReminderLiveActivityManager: ObservableObject { 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) + 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 @@ -262,6 +343,7 @@ final class ReminderLiveActivityManager: ObservableObject { } private func stopTicker() { + tickerTask?.cancel() tickerTask = nil } @@ -272,7 +354,7 @@ final class ReminderLiveActivityManager: ObservableObject { 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 upcoming = events .filter { !shouldHide($0) } @@ -282,42 +364,92 @@ final class ReminderLiveActivityManager: ObservableObject { 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; scheduling fallback refresh") scheduleFallbackRefresh() 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) { - 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 { 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) + self.refreshFromEvents(events, referenceDate: now, reason: reason) } } @@ -331,8 +463,14 @@ final class ReminderLiveActivityManager: ObservableObject { updateActiveWindowReminders(for: date) guard var entry = nextReminder else { - activeReminder = nil + if activeReminder != nil { + activeReminder = nil + } stopTicker() + hasShownCriticalSneakPeek = false + if fallbackRefreshTask == nil { + scheduleFallbackRefresh() + } return } @@ -341,7 +479,9 @@ final class ReminderLiveActivityManager: ObservableObject { nextReminder = nil stopTicker() 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 } @@ -408,10 +548,88 @@ final class ReminderLiveActivityManager: ObservableObject { entry.triggerDate <= date && entry.event.start >= date } if filtered != activeWindowReminders { + logger.debug("[Reminder] Active window reminder count -> \(filtered.count, privacy: .public)") 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 { guard rowCount > 0 else { return 0 } let rows = CGFloat(rowCount) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 8e79803..8a346ab 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -183,9 +183,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject { // Force music information update when notch is opened MusicManager.shared.forceUpdate() - - // Refresh reminders immediately to surface the latest state when the notch opens - ReminderLiveActivityManager.shared.requestRefresh(force: true) } private func calculateDynamicNotchSize() -> CGSize {