diff --git a/.github/assets/lockscreen-v1.2.gif b/.github/assets/lockscreen-v1.2.gif new file mode 100644 index 0000000..cc17a0c --- /dev/null +++ b/.github/assets/lockscreen-v1.2.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbbff3ac2fc835503542947eeef9b06fb09bfb5c02d1a76f2b02eee58eb2d781 +size 63448889 diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index a193772..1413148 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -394,6 +394,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { self?.debouncedUpdateWindowSize() }.store(in: &cancellables) + Defaults.publisher(.openNotchWidth, options: []).sink { [weak self] _ in + self?.debouncedUpdateWindowSize() + }.store(in: &cancellables) + ReminderLiveActivityManager.shared.$activeWindowReminders .receive(on: RunLoop.main) .sink { [weak self] _ in diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index d7e9074..0e1a7cc 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -894,6 +894,10 @@ "comment" : "A text label displaying the current value of the \"Notify before\" slider. The argument is the current value of the slider, expressed in minutes.", "isCommentAutoGenerated" : true }, + "%lld px" : { + "comment" : "A label displaying the current expanded notch width value. The value is shown in pixels.", + "isCommentAutoGenerated" : true + }, "%lld s" : { "comment" : "A text view displaying the duration of the \"sneak peek\" feature in the reminder live activity section of the calendar settings view. The text is followed by a unit of measurement (\"s\").", "isCommentAutoGenerated" : true @@ -8567,6 +8571,10 @@ }, "Expand Notch with Animation" : { + }, + "Expanded notch width" : { + "comment" : "A label describing the expanded notch width setting.", + "isCommentAutoGenerated" : true }, "Extensions" : { "localizations" : { @@ -14533,6 +14541,10 @@ } } }, + "Notch Width" : { + "comment" : "A section header for adjusting the expanded notch width.", + "isCommentAutoGenerated" : true + }, "Notify before" : { "comment" : "A label describing the slider that allows the user to set a reminder lead time.", "isCommentAutoGenerated" : true @@ -15692,6 +15704,10 @@ } } }, + "Reset Width" : { + "comment" : "A button to reset the expanded notch width to its default value.", + "isCommentAutoGenerated" : true + }, "Restart" : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index b7ce27f..8dea404 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -628,6 +628,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .appearance, title: "Enable window shadow", keywords: ["shadow", "appearance"], highlightID: SettingsTab.appearance.highlightID(for: "Enable window shadow")), SettingsSearchEntry(tab: .appearance, title: "Corner radius scaling", keywords: ["corner radius", "shape"], highlightID: SettingsTab.appearance.highlightID(for: "Corner radius scaling")), SettingsSearchEntry(tab: .appearance, title: "Use simpler close animation", keywords: ["close animation", "notch"], highlightID: SettingsTab.appearance.highlightID(for: "Use simpler close animation")), + SettingsSearchEntry(tab: .appearance, title: "Notch Width", keywords: ["expanded notch", "width", "resize"], highlightID: SettingsTab.appearance.highlightID(for: "Expanded notch width")), SettingsSearchEntry(tab: .appearance, title: "Enable colored spectrograms", keywords: ["spectrogram", "audio"], highlightID: SettingsTab.appearance.highlightID(for: "Enable colored spectrograms")), SettingsSearchEntry(tab: .appearance, title: "Enable blur effect behind album art", keywords: ["blur", "album art"], highlightID: SettingsTab.appearance.highlightID(for: "Enable blur effect behind album art")), SettingsSearchEntry(tab: .appearance, title: "Slider color", keywords: ["slider", "accent"], highlightID: SettingsTab.appearance.highlightID(for: "Slider color")), @@ -1783,8 +1784,8 @@ struct CalendarSettings: View { HStack { Button("Request Access") { Task { - await calendarManager.checkCalendarAuthorization(forceReload: true) - await calendarManager.checkReminderAuthorization(forceReload: true) + await calendarManager.checkCalendarAuthorization() + await calendarManager.checkReminderAuthorization() } } .buttonStyle(.borderedProminent) @@ -2173,6 +2174,8 @@ struct Appearance: View { @Default(.useMusicVisualizer) var useMusicVisualizer @Default(.customVisualizers) var customVisualizers @Default(.selectedVisualizer) var selectedVisualizer + @Default(.openNotchWidth) var openNotchWidth + @Default(.enableMinimalisticUI) var enableMinimalisticUI let icons: [String] = ["logo2"] @State private var selectedIcon: String = "logo2" @State private var selectedListVisualizer: CustomVisualizer? = nil @@ -2182,6 +2185,9 @@ struct Appearance: View { @State private var url: String = "" @State private var speed: CGFloat = 1.0 + private let notchWidthRange: ClosedRange = 640...900 + private let defaultOpenNotchWidth: CGFloat = 640 + private func highlightID(_ title: String) -> String { SettingsTab.appearance.highlightID(for: title) } @@ -2202,6 +2208,8 @@ struct Appearance: View { Text("General") } + notchWidthControls() + Section { Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) .settingsHighlight(id: highlightID("Enable colored spectrograms")) @@ -2463,6 +2471,61 @@ struct Appearance: View { return false } + + @ViewBuilder + private func notchWidthControls() -> some View { + Section { + let widthBinding = Binding( + get: { Double(openNotchWidth) }, + set: { newValue in + let clamped = min(max(newValue, notchWidthRange.lowerBound), notchWidthRange.upperBound) + let value = CGFloat(clamped) + if openNotchWidth != value { + openNotchWidth = value + } + } + ) + + VStack(alignment: .leading, spacing: 10) { + Slider( + value: widthBinding, + in: notchWidthRange, + step: 10 + ) { + HStack { + Text("Expanded notch width") + Spacer() + Text("\(Int(openNotchWidth)) px") + .foregroundStyle(.secondary) + } + } + .disabled(enableMinimalisticUI) + .settingsHighlight(id: highlightID("Expanded notch width")) + + HStack { + Spacer() + Button("Reset Width") { + openNotchWidth = defaultOpenNotchWidth + } + .disabled(abs(openNotchWidth - defaultOpenNotchWidth) < 0.5) + .buttonStyle(.bordered) + } + + let description = enableMinimalisticUI + ? "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value." + : "Extend the notch span so the clipboard, colour picker, and other trailing icons remain visible on scaled displays (e.g. More Space)." + + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + HStack { + Text("Notch Width") + customBadge(text: "Beta") + } + } + } } struct LockScreenSettings: View { diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index ae2fa61..718ab29 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -44,6 +44,9 @@ class BluetoothAudioManager: ObservableObject { private var isPmsetRefreshInFlight = false private var lastPmsetRefreshDate: Date? private let pmsetRefreshCooldown: TimeInterval = 5 + private var hudBatteryWaitTasks: [UUID: Task] = [:] + private let hudBatteryWaitInterval: TimeInterval = 0.3 + private let hudBatteryWaitTimeout: TimeInterval = 1.8 // MARK: - Initialization private init() { @@ -244,13 +247,16 @@ class BluetoothAudioManager: ObservableObject { .compactMap { $0.addressString } // Remove disconnected devices - let previousCount = connectedDevices.count + let removedDevices = connectedDevices.filter { device in + !currentlyConnectedAddresses.contains(device.address) + } connectedDevices.removeAll { device in !currentlyConnectedAddresses.contains(device.address) } - if connectedDevices.count < previousCount { + if !removedDevices.isEmpty { print("🎧 [BluetoothAudioManager] 👋 Audio device(s) disconnected") + removedDevices.forEach { cancelHUDBatteryWait(for: $0) } } isBluetoothAudioConnected = !connectedDevices.isEmpty @@ -304,7 +310,9 @@ class BluetoothAudioManager: ObservableObject { // Remove from connected devices let address = device.addressString ?? "Unknown" + let removed = connectedDevices.filter { $0.address == address } connectedDevices.removeAll { $0.address == address } + removed.forEach { cancelHUDBatteryWait(for: $0) } isBluetoothAudioConnected = !connectedDevices.isEmpty } @@ -1324,6 +1332,20 @@ class BluetoothAudioManager: ObservableObject { return nil } + private func cancelHUDBatteryWait(for device: BluetoothAudioDevice) { + let cancelBlock = { [weak self] in + guard let self else { return } + self.hudBatteryWaitTasks[device.id]?.cancel() + self.hudBatteryWaitTasks.removeValue(forKey: device.id) + } + + if Thread.isMainThread { + cancelBlock() + } else { + DispatchQueue.main.async(execute: cancelBlock) + } + } + private func normalizeBluetoothIdentifier(_ value: String) -> String { return value .lowercased() @@ -1346,21 +1368,63 @@ class BluetoothAudioManager: ObservableObject { /// Shows HUD notification for newly connected audio device private func showDeviceConnectedHUD(_ device: BluetoothAudioDevice) { guard Defaults[.showBluetoothDeviceConnections] else { return } - - print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD") - - // Convert battery percentage to 0.0-1.0 value - let reportedBattery = bestBatteryLevel(for: device) - let batteryValue: CGFloat = if let battery = reportedBattery { - CGFloat(clampBatteryPercentage(battery)) / 100.0 + cancelHUDBatteryWait(for: device) + + if let battery = bestBatteryLevel(for: device) { + presentDeviceConnectedHUD(device: device, batteryLevel: battery) + return + } + + requestPmsetFallback(reason: "hud missing battery") + + let task = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + let deadline = Date().addingTimeInterval(self.hudBatteryWaitTimeout) + while Date() < deadline { + try? await Task.sleep(nanoseconds: UInt64(self.hudBatteryWaitInterval * 1_000_000_000)) + guard !Task.isCancelled else { return } + + let batteryInfo = await MainActor.run { () -> (BluetoothAudioDevice, Int)? in + guard let refreshedDevice = self.connectedDevices.first(where: { $0.id == device.id }), + let battery = self.bestBatteryLevel(for: refreshedDevice) else { + return nil + } + return (refreshedDevice, battery) + } + + if let (refreshedDevice, battery) = batteryInfo { + await MainActor.run { + self.presentDeviceConnectedHUD(device: refreshedDevice, batteryLevel: battery) + } + self.cancelHUDBatteryWait(for: device) + return + } + } + + guard !Task.isCancelled else { return } + await MainActor.run { + self.presentDeviceConnectedHUD(device: device, batteryLevel: nil) + } + self.cancelHUDBatteryWait(for: device) + } + + hudBatteryWaitTasks[device.id] = task + } + + private func presentDeviceConnectedHUD(device: BluetoothAudioDevice, batteryLevel: Int?) { + guard Defaults[.showBluetoothDeviceConnections] else { return } + + print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD") + + let batteryValue: CGFloat = if let batteryLevel { + CGFloat(clampBatteryPercentage(batteryLevel)) / 100.0 } else { - 0.0 // 0 means battery info not available + 0.0 } HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5) - - // Show HUD via coordinator + coordinator.toggleSneakPeek( status: true, type: .bluetoothAudio, @@ -1382,6 +1446,8 @@ class BluetoothAudioManager: ObservableObject { dnc.removeObserver(self) observers.removeAll() cancellables.removeAll() + hudBatteryWaitTasks.values.forEach { $0.cancel() } + hudBatteryWaitTasks.removeAll() } @MainActor diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index 25440e9..0c35a69 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -20,21 +20,24 @@ class CalendarManager: ObservableObject { @Published var allCalendars: [CalendarModel] = [] @Published var eventCalendars: [CalendarModel] = [] @Published var reminderLists: [CalendarModel] = [] + @Published var selectedCalendarIDs: Set = [] @Published var calendarAuthorizationStatus: EKAuthorizationStatus = .notDetermined @Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined + private var selectedCalendars: [CalendarModel] = [] private let calendarService = CalendarService() - + private var lastEventsFetchDate: Date? + private let reloadRefreshInterval: TimeInterval = 15 private var eventStoreChangedObserver: NSObjectProtocol? var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) } var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } private init() { - self.currentWeekStartDate = CalendarManager.startOfDay(Date()) + currentWeekStartDate = CalendarManager.startOfDay(Date()) setupEventStoreChangedObserver() Task { - await self.bootstrapAuthorizations() + await reloadCalendarAndReminderLists() } } @@ -51,111 +54,100 @@ class CalendarManager: ObservableObject { queue: .main ) { [weak self] _ in Task { - await self?.reloadCalendarAndReminderLists() + guard let self else { return } + await self.reloadCalendarAndReminderLists() + await self.maybeRefreshEventsAfterReload() } } } @MainActor func reloadCalendarAndReminderLists() async { - let all = await calendarService.calendars() - self.eventCalendars = all.filter { !$0.isReminder } - self.reminderLists = all.filter { $0.isReminder } - self.allCalendars = all // for legacy compatibility, can be removed if not needed + let allCalendars = await calendarService.calendars() + eventCalendars = allCalendars.filter { !$0.isReminder } + reminderLists = allCalendars.filter { $0.isReminder } + self.allCalendars = allCalendars updateSelectedCalendars() } - 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) + @MainActor + private func maybeRefreshEventsAfterReload() async { + guard hasCalendarAccess else { return } + let now = Date() + if let lastFetch = lastEventsFetchDate, now.timeIntervalSince(lastFetch) < reloadRefreshInterval { + return + } + await updateEvents() } private func isAuthorized(_ status: EKAuthorizationStatus) -> Bool { switch status { - case .fullAccess, .authorized: + case .authorized, .fullAccess: return true default: return false } } - func checkCalendarAuthorization(forceReload: Bool = false) async { - refreshAuthorizationStatuses() - let status = calendarAuthorizationStatus - print("📅 Current calendar authorization status: \(status.rawValue)") + func checkCalendarAuthorization() async { + let status = EKEventStore.authorizationStatus(for: .event) + calendarAuthorizationStatus = status switch status { case .notDetermined: let granted = await calendarService.requestAccess(to: .event) - refreshAuthorizationStatuses() - if granted || hasCalendarAccess { + calendarAuthorizationStatus = granted ? .fullAccess : .denied + if granted { await reloadCalendarAndReminderLists() await updateEvents() } case .restricted, .denied: NSLog("Calendar access denied or restricted") - case .writeOnly: - NSLog("Calendar write-only access") - case .fullAccess: - guard forceReload else { return } + case .authorized, .fullAccess: await reloadCalendarAndReminderLists() await updateEvents() + case .writeOnly: + NSLog("Calendar write only") @unknown default: - if isAuthorized(status) { - guard forceReload else { return } - await reloadCalendarAndReminderLists() - await updateEvents() - } else { - print("Unknown authorization status: \(status.rawValue)") - } + NSLog("Unknown calendar authorization status") } } - func checkReminderAuthorization(forceReload: Bool = false) async { - refreshAuthorizationStatuses() - let status = reminderAuthorizationStatus - print("📅 Current reminder authorization status: \(status.rawValue)") + func checkReminderAuthorization() async { + let status = EKEventStore.authorizationStatus(for: .reminder) + reminderAuthorizationStatus = status switch status { case .notDetermined: let granted = await calendarService.requestAccess(to: .reminder) - refreshAuthorizationStatuses() - if granted || hasReminderAccess { + reminderAuthorizationStatus = granted ? .fullAccess : .denied + if granted { await reloadCalendarAndReminderLists() } case .restricted, .denied: NSLog("Reminder access denied or restricted") - case .writeOnly: - NSLog("Reminder write-only access") - case .fullAccess: - guard forceReload else { return } + case .authorized, .fullAccess: await reloadCalendarAndReminderLists() + case .writeOnly: + NSLog("Reminder write only") @unknown default: - if isAuthorized(status) { - guard forceReload else { return } - await reloadCalendarAndReminderLists() - } else { - print("Unknown reminder authorization status: \(status.rawValue)") - } + NSLog("Unknown reminder authorization status") } } func updateSelectedCalendars() { - selectedCalendars = allCalendars.filter { getCalendarSelected($0) } + switch Defaults[.calendarSelectionState] { + case .all: + selectedCalendarIDs = Set(allCalendars.map { $0.id }) + case .selected(let identifiers): + selectedCalendarIDs = identifiers + } + + selectedCalendars = allCalendars.filter { selectedCalendarIDs.contains($0.id) } } func getCalendarSelected(_ calendar: CalendarModel) -> Bool { - switch Defaults[.calendarSelectionState] { - case .all: - return true - case .selected(let identifiers): - return identifiers.contains(calendar.id) - } + selectedCalendarIDs.contains(calendar.id) } func setCalendarSelected(_ calendar: CalendarModel, isSelected: Bool) async { @@ -167,7 +159,6 @@ class CalendarManager: ObservableObject { let identifiers = Set(allCalendars.map { $0.id }).subtracting([calendar.id]) selectionState = .selected(identifiers) } - case .selected(var identifiers): if isSelected { identifiers.insert(calendar.id) @@ -175,9 +166,11 @@ class CalendarManager: ObservableObject { identifiers.remove(calendar.id) } - selectionState = - identifiers.isEmpty - ? .all : identifiers.count == allCalendars.count ? .all : .selected(identifiers) // if empty, select all + if identifiers.isEmpty || identifiers.count == allCalendars.count { + selectionState = .all + } else { + selectionState = .selected(identifiers) + } } Defaults[.calendarSelectionState] = selectionState @@ -186,7 +179,7 @@ class CalendarManager: ObservableObject { } static func startOfDay(_ date: Date) -> Date { - return Calendar.current.startOfDay(for: date) + Calendar.current.startOfDay(for: date) } func updateCurrentDate(_ date: Date) async { @@ -196,12 +189,13 @@ class CalendarManager: ObservableObject { private func updateEvents() async { let calendarIDs = selectedCalendars.map { $0.id } - let eventsResult = await calendarService.events( + let events = await calendarService.events( from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: calendarIDs ) - self.events = eventsResult + self.events = events + lastEventsFetchDate = Date() } func setReminderCompleted(reminderID: String, completed: Bool) async { diff --git a/DynamicIsland/managers/ReminderLiveActivityManager.swift b/DynamicIsland/managers/ReminderLiveActivityManager.swift index 90b7240..b37044f 100644 --- a/DynamicIsland/managers/ReminderLiveActivityManager.swift +++ b/DynamicIsland/managers/ReminderLiveActivityManager.swift @@ -41,6 +41,19 @@ final class ReminderLiveActivityManager: ObservableObject { private var evaluationTask: Task? private var hasShownCriticalSneakPeek = false private var latestEvents: [EventModel] = [] + private var pendingEventsSnapshot: [EventModel]? = nil + private var eventsUpdateDebounceTask: Task? { didSet { oldValue?.cancel() } } + private var upcomingComputationTask: Task? { didSet { oldValue?.cancel() } } + private let eventsDebounceInterval: TimeInterval = 0.35 + private var settingsUpdateTask: Task? { didSet { oldValue?.cancel() } } + private var pendingSettingsAction: (() -> Void)? + private var pendingSettingsReason: String? + private let settingsUpdateDebounceInterval: TimeInterval = 0.2 + private var suppressUpdatesForLock = false + + private var lastAppliedLeadTime = Defaults[.reminderLeadTime] + private var lastAppliedHideAllDay = Defaults[.hideAllDayEvents] + private var lastAppliedHideCompleted = Defaults[.hideCompletedReminders] private let calendarManager = CalendarManager.shared @@ -67,21 +80,35 @@ final class ReminderLiveActivityManager: ObservableObject { .store(in: &cancellables) Defaults.publisher(.reminderLeadTime, options: []) - .sink { [weak self] _ in + .map(\.newValue) + .sink { [weak self] newValue in guard let self else { return } - self.recalculateUpcomingEntries(reason: "lead-time") + guard newValue != self.lastAppliedLeadTime else { return } + self.scheduleSettingsRecalculation(reason: "lead-time") { + self.lastAppliedLeadTime = newValue + } } .store(in: &cancellables) Defaults.publisher(.hideAllDayEvents, options: []) - .sink { [weak self] _ in - self?.recalculateUpcomingEntries(reason: "hide-all-day") + .map(\.newValue) + .sink { [weak self] newValue in + guard let self else { return } + guard newValue != self.lastAppliedHideAllDay else { return } + self.scheduleSettingsRecalculation(reason: "hide-all-day") { + self.lastAppliedHideAllDay = newValue + } } .store(in: &cancellables) Defaults.publisher(.hideCompletedReminders, options: []) - .sink { [weak self] _ in - self?.recalculateUpcomingEntries(reason: "hide-completed") + .map(\.newValue) + .sink { [weak self] newValue in + guard let self else { return } + guard newValue != self.lastAppliedHideCompleted else { return } + self.scheduleSettingsRecalculation(reason: "hide-completed") { + self.lastAppliedHideCompleted = newValue + } } .store(in: &cancellables) @@ -96,12 +123,40 @@ final class ReminderLiveActivityManager: ObservableObject { .store(in: &cancellables) calendarManager.$events + .removeDuplicates() .receive(on: RunLoop.main) .sink { [weak self] events in - guard let self else { return } - self.handleCalendarEventsUpdate(events) + self?.handleCalendarEventsUpdate(events) } .store(in: &cancellables) + + LockScreenManager.shared.$isLocked + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] locked in + self?.handleLockStateChange(isLocked: locked) + } + .store(in: &cancellables) + } + + private func scheduleSettingsRecalculation(reason: String, action: @escaping () -> Void) { + pendingSettingsAction = action + pendingSettingsReason = reason + settingsUpdateTask = Task { [weak self] in + guard let self else { return } + let delay = UInt64(settingsUpdateDebounceInterval * 1_000_000_000) + try? await Task.sleep(nanoseconds: delay) + await self.applyPendingSettingsRecalculation() + } + } + + @MainActor + private func applyPendingSettingsRecalculation() { + guard let action = pendingSettingsAction, let reason = pendingSettingsReason else { return } + pendingSettingsAction = nil + pendingSettingsReason = nil + action() + recalculateUpcomingEntries(reason: reason) } private func cancelAllTimers() { @@ -120,28 +175,56 @@ final class ReminderLiveActivityManager: ObservableObject { } private func handleCalendarEventsUpdate(_ events: [EventModel]) { - latestEvents = events + pendingEventsSnapshot = events + guard !suppressUpdatesForLock else { return } + schedulePendingEventsSnapshotApplication() + } + + private func schedulePendingEventsSnapshotApplication() { + eventsUpdateDebounceTask = Task { [weak self] in + guard let self else { return } + let delay = UInt64(eventsDebounceInterval * 1_000_000_000) + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + await self.applyPendingEventsSnapshot() + } + } + + @MainActor + private func applyPendingEventsSnapshot() { + guard !suppressUpdatesForLock else { return } + guard let snapshot = pendingEventsSnapshot else { return } + pendingEventsSnapshot = nil + guard snapshot != latestEvents else { return } + + latestEvents = snapshot guard Defaults[.enableReminderLiveActivity] else { return } - logger.debug("[Reminder] Applying calendar snapshot update (events=\(events.count, privacy: .public))") + logger.debug("[Reminder] Applying calendar snapshot update (events=\(snapshot.count, privacy: .public))") recalculateUpcomingEntries(reason: "calendar-events") } - private func shouldHide(_ event: EventModel) -> Bool { - if event.isAllDay && Defaults[.hideAllDayEvents] { - return true + private func handleLockStateChange(isLocked: Bool) { + suppressUpdatesForLock = isLocked + if isLocked { + eventsUpdateDebounceTask = nil + upcomingComputationTask = nil + settingsUpdateTask = nil + pendingSettingsAction = nil + pendingSettingsReason = nil + pauseReminderActivityForLock() + } else { + if pendingEventsSnapshot != nil { + schedulePendingEventsSnapshotApplication() + } else { + recalculateUpcomingEntries(reason: "lock-resume") + } } - if case let .reminder(completed) = event.type, - completed && Defaults[.hideCompletedReminders] { - return true - } - return false } - private func makeEntry(from event: EventModel, leadMinutes: Int, referenceDate: Date) -> ReminderEntry? { - guard event.start > referenceDate else { return nil } - let leadSeconds = max(1, leadMinutes) * 60 - let trigger = event.start.addingTimeInterval(TimeInterval(-leadSeconds)) - return ReminderEntry(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds)) + private func pauseReminderActivityForLock() { + tickerTask = nil + evaluationTask?.cancel() + evaluationTask = nil } private func recalculateUpcomingEntries(referenceDate: Date = Date(), reason: String) { @@ -149,18 +232,33 @@ final class ReminderLiveActivityManager: ObservableObject { deactivateReminder() return } + let snapshot = latestEvents + let leadMinutes = Defaults[.reminderLeadTime] + let hideAllDay = Defaults[.hideAllDayEvents] + let hideCompleted = Defaults[.hideCompletedReminders] - guard calendarManager.hasReminderAccess else { + upcomingComputationTask = Task.detached(priority: .utility) { [weak self] in + guard let self else { return } + guard !Task.isCancelled else { return } + let upcoming = Self.buildUpcomingEntries( + events: snapshot, + leadMinutes: leadMinutes, + referenceDate: referenceDate, + hideAllDayEvents: hideAllDay, + hideCompletedReminders: hideCompleted + ) + guard !Task.isCancelled else { return } + await self.publishUpcomingEntries(upcoming, referenceDate: referenceDate, reason: reason) + } + } + + @MainActor + private func publishUpcomingEntries(_ upcoming: [ReminderEntry], referenceDate: Date, reason: String) { + guard Defaults[.enableReminderLiveActivity] else { deactivateReminder() return } - let leadMinutes = Defaults[.reminderLeadTime] - let upcoming = latestEvents - .filter { !shouldHide($0) } - .compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) } - .sorted { $0.triggerDate < $1.triggerDate } - upcomingEntries = upcoming updateActiveWindowReminders(for: referenceDate) @@ -174,6 +272,34 @@ final class ReminderLiveActivityManager: ObservableObject { handleEntrySelection(first, referenceDate: referenceDate) } + nonisolated private static func buildUpcomingEntries( + events: [EventModel], + leadMinutes: Int, + referenceDate: Date, + hideAllDayEvents: Bool, + hideCompletedReminders: Bool + ) -> [ReminderEntry] { + guard !Task.isCancelled else { return [] } + let leadSeconds = max(1, leadMinutes) * 60 + var entries: [ReminderEntry] = [] + entries.reserveCapacity(events.count) + for event in events { + if Task.isCancelled { return [] } + if hideAllDayEvents && event.isAllDay { + continue + } + if hideCompletedReminders, + case let .reminder(completed) = event.type, + completed { + continue + } + guard event.start > referenceDate else { continue } + let trigger = event.start.addingTimeInterval(TimeInterval(-leadSeconds)) + entries.append(.init(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds))) + } + return entries.sorted { $0.triggerDate < $1.triggerDate } + } + private func clearActiveReminderState() { nextReminder = nil if activeReminder != nil { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 97a06ee..c62ef82 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -400,6 +400,7 @@ extension Defaults.Keys { ) static let nonNotchHeight = Key("nonNotchHeight", default: 32) static let notchHeight = Key("notchHeight", default: 32) + static let openNotchWidth = Key("openNotchWidth", default: 640) //static let openLastTabByDefault = Key("openLastTabByDefault", default: false) // MARK: Appearance diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index a63464a..2e512dc 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -147,6 +147,29 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } } .store(in: &cancellables) + + Defaults.publisher(.openNotchWidth, options: []) + .map { $0.newValue } + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + guard self.notchState == .open else { return } + guard !Defaults[.enableMinimalisticUI] else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchSize != updatedTarget else { return } + withAnimation(.smooth) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: false), + animated: true, + force: false + ) + } + } + .store(in: &cancellables) } private func handleMinimalisticTimerHeightChange() { diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 0e15796..6e1c505 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -14,7 +14,10 @@ import SwiftUI let downloadSneakSize: CGSize = .init(width: 65, height: 1) let batterySneakSize: CGSize = .init(width: 160, height: 1) -let openNotchSize: CGSize = .init(width: 640, height: 190) +var openNotchSize: CGSize { + let width = Defaults[.openNotchWidth] + return .init(width: width, height: 190) +} private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) private let minimalisticLyricsExtraHeight: CGFloat = 40 let minimalisticTimerCountdownTopPadding: CGFloat = 12 diff --git a/ReadMe.md b/ReadMe.md index a3925ca..55c535e 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -91,7 +91,7 @@ Atoll turns the MacBook notch into a focused command surface for media, system i - Current weather conditions and forecast.

- + Do Not Disturb

## Overview