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_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",
|
||||
|
|
|
|||
|
|
@ -58,16 +58,6 @@ struct ContentView: View {
|
|||
|
||||
@State private var hoverTask: Task<Void, Never>?
|
||||
@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
|
||||
guard oldSize != newSize else { return }
|
||||
let delay: TimeInterval = dynamicNotchResizeAnimation == nil ? 0.25 : 1.0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||
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()
|
||||
}
|
||||
|
|
@ -856,19 +750,15 @@ struct ContentView: View {
|
|||
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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" : {
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
guard !calendars.isEmpty else { return [] }
|
||||
|
||||
// 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 ?? [])
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
for await reminders in group {
|
||||
allReminders.append(contentsOf: reminders)
|
||||
continuation.resume(returning: filtered)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
await reloadCalendarAndReminderLists()
|
||||
events = await calendarService.events(
|
||||
from: currentWeekStartDate,
|
||||
to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!,
|
||||
calendars: selectedCalendars.map { $0.id }
|
||||
)
|
||||
}
|
||||
case .restricted, .denied:
|
||||
// Handle the case where the user has denied or restricted access
|
||||
NSLog("Calendar access denied or restricted")
|
||||
case .fullAccess:
|
||||
NSLog("Full access")
|
||||
let granted = await calendarService.requestAccess(to: .event)
|
||||
refreshAuthorizationStatuses()
|
||||
if granted || hasCalendarAccess {
|
||||
await reloadCalendarAndReminderLists()
|
||||
await updateEvents()
|
||||
}
|
||||
case .restricted, .denied:
|
||||
NSLog("Calendar access denied or restricted")
|
||||
case .writeOnly:
|
||||
NSLog("Write only")
|
||||
NSLog("Calendar write-only access")
|
||||
case .fullAccess:
|
||||
guard forceReload else { return }
|
||||
await reloadCalendarAndReminderLists()
|
||||
await updateEvents()
|
||||
@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
|
||||
private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
private let manager: CLLocationManager
|
||||
private var continuation: CheckedContinuation<CLLocation?, Never>?
|
||||
private var pendingContinuations: [CheckedContinuation<CLLocation?, Never>] = []
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MusicControlOverlay> {
|
||||
|
|
|
|||
|
|
@ -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<AnyCancellable>()
|
||||
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 pendingRefreshForce = false
|
||||
private var pendingRefreshToken = UUID()
|
||||
private var pendingRefreshReason: RefreshReason = .other
|
||||
private var lastKnownCalendarIDs = Set<String>()
|
||||
private var lastRefreshDate: Date?
|
||||
private var lastRefreshCompletionDate: Date?
|
||||
private let minimumRefreshInterval: TimeInterval = 60
|
||||
private let refreshDebounceInterval: TimeInterval = 0.3
|
||||
private var refreshTaskToken = UUID()
|
||||
private var hasShownCriticalSneakPeek = false
|
||||
private let eventStoreChangeNoiseInterval: TimeInterval = 4
|
||||
private let eventStoreForceThrottleInterval: TimeInterval = 20
|
||||
private let manualRefreshSuppressionInterval: TimeInterval = 20
|
||||
private var lastEventStoreForceRefreshDate: Date?
|
||||
private var lastManualRefreshDate: Date?
|
||||
private var pendingEventStoreChange: (date: Date, reason: RefreshReason)?
|
||||
private var reminderCompletionSuppressionDeadline: Date?
|
||||
private let reminderCompletionSuppressionInterval: TimeInterval = 15
|
||||
private var lastUpcomingSignature: [String] = []
|
||||
private var lastNoopEventStoreRefreshDate: Date?
|
||||
private let noopEventStoreSuppressionInterval: TimeInterval = 30
|
||||
private var lastCalendarEventSnapshot: [EventModel] = []
|
||||
private var lastCalendarSnapshotDate: Date?
|
||||
private var lastCalendarEventSnapshotSignature: [String] = []
|
||||
|
||||
private 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 {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue