mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Something is working Finally
This commit is contained in:
parent
afda162c1f
commit
29b5111a86
10 changed files with 404 additions and 315 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" : {
|
||||||
|
|
|
||||||
|
|
@ -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) })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue