diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index bf91817..f099972 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; }; 7E9911B02EFD7D1B002EDFD5 /* Reindeer.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */; }; 7E9918722EFDAE5B002EDFD5 /* MediaRemoteAdapter.framework in Resources */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; + 7ECFE0C62F4A2323000DD9BD /* MijickCalendarView in Frameworks */ = {isa = PBXBuildFile; productRef = 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */; }; 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; @@ -71,6 +72,7 @@ 7E6118172E24071E0050006C /* Collections in Frameworks */, 7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */, 7E6118152E24071E0050006C /* Sparkle in Frameworks */, + 7ECFE0C62F4A2323000DD9BD /* MijickCalendarView in Frameworks */, 7E288D122F1659EF0020C117 /* AtollExtensionKit in Frameworks */, 7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */, ); @@ -171,6 +173,7 @@ 146BC4362D366E9B0047C66A /* MacroVisionKit */, 7E61181E2E24071E0050006C /* SkyLightWindow */, 7E288D112F1659EF0020C117 /* AtollExtensionKit */, + 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */, ); productName = DynamicIsland; productReference = F54406E72CD4987A00C7AFD9 /* Atoll.app */; @@ -216,6 +219,7 @@ 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */, 7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */, 7E288D102F1659EF0020C117 /* XCRemoteSwiftPackageReference "AtollExtensionKit" */, + 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */, ); preferredProjectObjectVersion = 77; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; @@ -589,6 +593,14 @@ kind = branch; }; }; + 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Mijick/CalendarView"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.1; + }; + }; 9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/EmergeTools/Pow"; @@ -665,6 +677,11 @@ package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */; productName = SkyLightWindow; }; + 7ECFE0C52F4A2323000DD9BD /* MijickCalendarView */ = { + isa = XCSwiftPackageProductDependency; + package = 7ECFE0C42F4A2323000DD9BD /* XCRemoteSwiftPackageReference "CalendarView" */; + productName = MijickCalendarView; + }; 9A987A0F2C73CA8D005CA465 /* Collections */ = { isa = XCSwiftPackageProductDependency; package = F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 37ec1ab..7f72980 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6fbfa8cda0c383746edd66361426fdc2bd266fc082867294374a1818f02798f3", + "originHash" : "802e15fea0dccd2d325dc082d8d36162007adcd2fa301070eb26448d0a2cf6c5", "pins" : [ { "identity" : "atollextensionkit", @@ -10,6 +10,15 @@ "revision" : "ebe35277572a349fad26abeb6fc552cb5251d584" } }, + { + "identity" : "calendarview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mijick/CalendarView", + "state" : { + "revision" : "168cf14a50d33684caa2a411bbd0b3f988e9dff6", + "version" : "1.1.1" + } + }, { "identity" : "defaults", "kind" : "remoteSourceControl", diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 674e0bd..44ca54b 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -180,10 +180,26 @@ struct ContentView: View { } private let zeroHeightHoverPadding: CGFloat = 10 - private let statsAdditionalRowHeight: CGFloat = 110 + private let statsAdditionalRowHeight: CGFloat = statsSecondRowContentHeight + statsGridSpacingHeight private let musicControlPauseGrace: TimeInterval = 5 private let musicControlResumeDelay: TimeInterval = 0.24 + // MARK: - Tab switch direction for smooth transitions + + private var tabSwitchTransition: AnyTransition { + if coordinator.tabSwitchForward { + return .asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + ) + } else { + return .asymmetric( + insertion: .move(edge: .leading).combined(with: .opacity), + removal: .move(edge: .trailing).combined(with: .opacity) + ) + } + } + private var standardMediaControlsActive: Bool { showStandardMediaControls && !enableMinimalisticUI } @@ -398,6 +414,7 @@ struct ContentView: View { maxHeight: dynamicNotchSize.height + currentShadowPadding, alignment: .top ) + .frame(maxHeight: .infinity, alignment: .top) .environmentObject(privacyManager) .onChange(of: dynamicNotchSize) { oldSize, newSize in guard oldSize != newSize else { return } @@ -753,13 +770,16 @@ struct ContentView: View { } } } - .id(coordinator.currentView) // Force SwiftUI to treat each view as unique + .id(coordinator.currentView) + .transition(tabSwitchTransition) } } + .clipped() .zIndex(1) .allowsHitTesting(vm.notchState == .open) .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) .opacity(abs(gestureProgress) > 0.3 ? min(abs(gestureProgress * 2), 0.8) : 1) + .animation(.smooth(duration: 0.3), value: coordinator.currentView) } } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 9a98af1..ff5c2c4 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -70,6 +70,7 @@ struct DynamicNotchApp: App { } } + @CommandsBuilder var commands: some Commands { CommandGroup(replacing: .appSettings) { Button("Settings…") { @@ -116,6 +117,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var cancellables = Set() private var windowsHiddenForLock = false private var optionalShortcutHandlersRegistered = false + private weak var focusWithoutDevToolsMenuItem: NSMenuItem? + private weak var focusUseDevToolsMenuItem: NSMenuItem? // Debouncing mechanism for window size updates private var windowSizeUpdateWorkItem: DispatchWorkItem? @@ -146,6 +149,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } + + func applicationDidBecomeActive(_ notification: Notification) { + installTopMenuItemsIfNeeded() + } func applicationWillTerminate(_ notification: Notification) { // Cancel any pending window size updates @@ -370,6 +377,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { idleAnimationManager.initializeDefaultAnimations() applySelectedAppIcon() + installTopMenuItemsIfNeeded() + + Defaults.publisher(.focusMonitoringMode, options: []) + .sink { [weak self] _ in + self?.updateFocusMenuState() + } + .store(in: &cancellables) // Setup SystemHUD Manager SystemHUDManager.shared.setup(coordinator: coordinator) @@ -531,8 +545,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: Notification.Name.automaticallySwitchDisplayChanged, object: nil, queue: nil ) { [weak self] _ in guard let self = self, let window = self.window else { return } - window.alphaValue = - self.coordinator.selectedScreen == self.coordinator.preferredScreen ? 1 : 0 + DispatchQueue.main.async { + window.alphaValue = + self.coordinator.selectedScreen == self.coordinator.preferredScreen ? 1 : 0 + } } NotificationCenter.default.addObserver( @@ -644,6 +660,155 @@ class AppDelegate: NSObject, NSApplicationDelegate { } + private func installTopMenuItemsIfNeeded() { + guard let mainMenu = NSApp.mainMenu else { return } + if mainMenu.items.contains(where: { $0.identifier?.rawValue == "Atoll.Focus.Menu" }) { + updateFocusMenuState() + return + } + + let insertionIndex = preferredMenuInsertionIndex(in: mainMenu) + + let focusMenuItem = NSMenuItem(title: "Focus", action: nil, keyEquivalent: "") + focusMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Focus.Menu") + let focusSubmenu = NSMenu(title: "Focus") + + let withoutDevTools = NSMenuItem( + title: "Use without DevTools", + action: #selector(selectFocusWithoutDevTools), + keyEquivalent: "" + ) + withoutDevTools.target = self + + let useDevTools = NSMenuItem( + title: "Use DevTools", + action: #selector(selectFocusUseDevTools), + keyEquivalent: "" + ) + useDevTools.target = self + + focusSubmenu.addItem(withoutDevTools) + focusSubmenu.addItem(useDevTools) + focusMenuItem.submenu = focusSubmenu + mainMenu.insertItem(focusMenuItem, at: insertionIndex) + + focusWithoutDevToolsMenuItem = withoutDevTools + focusUseDevToolsMenuItem = useDevTools + + let accessibilityMenuItem = NSMenuItem(title: "Accessibility", action: nil, keyEquivalent: "") + accessibilityMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Accessibility.Menu") + let accessibilitySubmenu = NSMenu(title: "Accessibility") + + let requestAccessibility = NSMenuItem( + title: "Request Accessibility Access", + action: #selector(requestAccessibilityAccess), + keyEquivalent: "" + ) + requestAccessibility.target = self + + let openAccessibility = NSMenuItem( + title: "Open Accessibility Settings", + action: #selector(openAccessibilitySettings), + keyEquivalent: "" + ) + openAccessibility.target = self + + accessibilitySubmenu.addItem(requestAccessibility) + accessibilitySubmenu.addItem(openAccessibility) + accessibilityMenuItem.submenu = accessibilitySubmenu + mainMenu.insertItem(accessibilityMenuItem, at: insertionIndex + 1) + + let permissionsMenuItem = NSMenuItem(title: "Permissions", action: nil, keyEquivalent: "") + permissionsMenuItem.identifier = NSUserInterfaceItemIdentifier("Atoll.Permissions.Menu") + let permissionsSubmenu = NSMenu(title: "Permissions") + + let requestFullDisk = NSMenuItem( + title: "Request Full Disk Access", + action: #selector(requestFullDiskAccess), + keyEquivalent: "" + ) + requestFullDisk.target = self + + let openFullDisk = NSMenuItem( + title: "Open Full Disk Access Settings", + action: #selector(openFullDiskAccessSettings), + keyEquivalent: "" + ) + openFullDisk.target = self + + let openDevTools = NSMenuItem( + title: "Open Developer Tools Settings", + action: #selector(openDeveloperToolsSettingsFromMenu), + keyEquivalent: "" + ) + openDevTools.target = self + + permissionsSubmenu.addItem(requestFullDisk) + permissionsSubmenu.addItem(openFullDisk) + permissionsSubmenu.addItem(NSMenuItem.separator()) + permissionsSubmenu.addItem(openDevTools) + permissionsMenuItem.submenu = permissionsSubmenu + mainMenu.insertItem(permissionsMenuItem, at: insertionIndex + 2) + + updateFocusMenuState() + } + + private func preferredMenuInsertionIndex(in mainMenu: NSMenu) -> Int { + if let index = mainMenu.items.firstIndex(where: { $0.title == "Window" }) { + return index + } + if let index = mainMenu.items.firstIndex(where: { $0.title == "Help" }) { + return index + } + return max(mainMenu.numberOfItems, 0) + } + + private func updateFocusMenuState() { + let mode = Defaults[.focusMonitoringMode] + focusWithoutDevToolsMenuItem?.state = mode == .withoutDevTools ? .on : .off + focusUseDevToolsMenuItem?.state = mode == .useDevTools ? .on : .off + } + + @objc private func selectFocusWithoutDevTools() { + Defaults[.focusMonitoringMode] = .withoutDevTools + updateFocusMenuState() + } + + @objc private func selectFocusUseDevTools() { + Defaults[.focusMonitoringMode] = .useDevTools + updateFocusMenuState() + } + + @objc private func requestAccessibilityAccess() { + AccessibilityPermissionStore.shared.requestAuthorizationPrompt() + } + + @objc private func openAccessibilitySettings() { + AccessibilityPermissionStore.shared.openSystemSettings() + } + + @objc private func requestFullDiskAccess() { + FullDiskAccessPermissionStore.shared.requestAccessPrompt() + } + + @objc private func openFullDiskAccessSettings() { + FullDiskAccessPermissionStore.shared.openSystemSettings() + } + + @objc private func openDeveloperToolsSettingsFromMenu() { + let urls = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_DevTools", + "x-apple.systempreferences:com.apple.preference.security" + ] + + for candidate in urls { + guard let url = URL(string: candidate) else { continue } + if NSWorkspace.shared.open(url) { + return + } + } + } + private func registerOptionalShortcutHandlers() { guard !optionalShortcutHandlersRegistered else { return } optionalShortcutHandlersRegistered = true diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index efffb39..c29ce36 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -102,12 +102,21 @@ class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() private var cancellables = Set() + private static let tabOrder: [NotchViews] = [.home, .shelf, .timer, .stats, .colorPicker, .notes, .clipboard, .extensionExperience] + + /// Direction of the most recent tab switch (true = forward/right, false = backward/left) + @Published var tabSwitchForward: Bool = true + @Published var currentView: NotchViews = .home { didSet { if Defaults[.enableMinimalisticUI] && currentView != .home { currentView = .home return } + // Track direction before SwiftUI re-renders + let oldIdx = Self.tabOrder.firstIndex(of: oldValue) ?? 0 + let newIdx = Self.tabOrder.firstIndex(of: currentView) ?? 0 + tabSwitchForward = newIdx >= oldIdx handleStatsTabTransition(from: oldValue, to: currentView) } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index c1acf0f..b3c3e64 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1591,10 +1591,6 @@ } } }, - "±10 Seconds" : { - "comment" : "Name of the music skip behavior that skips the current track within ±10 seconds.", - "isCommentAutoGenerated" : true - }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -1911,10 +1907,6 @@ } } }, - "Accent" : { - "comment" : "Name of the tint mode for the caps lock indicator that uses the accent color.", - "isCommentAutoGenerated" : true - }, "Accent color" : { "localizations" : { "ar" : { @@ -2260,10 +2252,6 @@ } } }, - "Adaptive gradient" : { - "comment" : "Name of the timer icon color mode option that uses an adaptive gradient.", - "isCommentAutoGenerated" : true - }, "Add" : { "localizations" : { "ar" : { @@ -3853,10 +3841,6 @@ } } }, - "Apple Music" : { - "comment" : "Names for the different media player types available in the app.", - "isCommentAutoGenerated" : true - }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4268,10 +4252,6 @@ } } }, - "Bar" : { - "comment" : "Localized name for \"Bar\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Battery" : { "localizations" : { "ar" : { @@ -7617,6 +7597,9 @@ } } } + }, + "Custom Liquid is available on macOS 26 or later." : { + }, "Custom Liquid settings require the Liquid Glass material." : { "localizations" : { @@ -7627,6 +7610,9 @@ } } } + }, + "Custom liquid variant" : { + }, "Custom music live activity animation" : { "localizations" : { @@ -8505,10 +8491,6 @@ } } }, - "Digital" : { - "comment" : "Name for the \"Digital\" style of reminder presentation.", - "isCommentAutoGenerated" : true - }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -11000,6 +10982,10 @@ } } }, + "Focus detection mode" : { + "comment" : "The title of a confirmation dialog that lets users choose between using the focus detection mode with or without the use of the SwiftUI Inspector (DevTools).", + "isCommentAutoGenerated" : true + }, "Focus Status" : { "localizations" : { "pt-BR" : { @@ -11875,10 +11861,6 @@ } } }, - "Green" : { - "comment" : "Display name for the \"Green\" caps lock indicator tint mode.", - "isCommentAutoGenerated" : true - }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -15545,6 +15527,7 @@ } }, "Minutes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17754,10 +17737,6 @@ } } }, - "Now Playing" : { - "comment" : "Localized name for each media controller type.", - "isCommentAutoGenerated" : true - }, "Off" : { "comment" : "Text displayed in a status label at the top of the screen indicating whether low power mode is on or off.", "isCommentAutoGenerated" : true @@ -18124,10 +18103,6 @@ } } }, - "Panel" : { - "comment" : "Display name for the \"Panel\" clipboard display mode.", - "isCommentAutoGenerated" : true - }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -19722,10 +19697,6 @@ } } }, - "Ring" : { - "comment" : "Name of the timer progress style that shows a ring animation.", - "isCommentAutoGenerated" : true - }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -20546,10 +20517,6 @@ } } }, - "Separate Tab" : { - "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", - "isCommentAutoGenerated" : true - }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -21604,14 +21571,6 @@ } } }, - "Shows screen assistant as a dropdown attached to the AI button" : { - "comment" : "Description of the \"Popover\" screen assistant display mode.", - "isCommentAutoGenerated" : true - }, - "Shows screen assistant in a floating panel near the notch" : { - "comment" : "Description of the \"panel\" display mode for the screen assistant.", - "isCommentAutoGenerated" : true - }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -21674,10 +21633,6 @@ } } }, - "Skip forward or backward by ten seconds" : { - "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "Slider color" : { "localizations" : { "ar" : { @@ -22245,10 +22200,6 @@ } } }, - "Spotify" : { - "comment" : "Name of a media controller type for Spotify.", - "isCommentAutoGenerated" : true - }, "Square" : { "localizations" : { "ar" : { @@ -22357,10 +22308,6 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { - }, - "Standard previous/next track controls" : { - "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", - "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -23158,6 +23105,9 @@ } } } + }, + "This is optional. You can change it any time from the menu bar." : { + }, "This permanently removes every saved note and its attachments." : { "localizations" : { @@ -23707,10 +23657,6 @@ }, "shouldTranslate" : false }, - "Track Skip" : { - "comment" : "Display name for the \"Track Skip\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -24415,6 +24361,10 @@ } } }, + "Use DevTools" : { + "comment" : "One option in the confirmation dialog in the `OnboardingView` that lets the user choose to use the developer tools for focus monitoring.", + "isCommentAutoGenerated" : true + }, "Use music visualizer spectrogram" : { "localizations" : { "ar" : { @@ -24537,6 +24487,10 @@ } } }, + "Use without DevTools" : { + "comment" : "An option in the onboarding flow that lets users choose to use focus detection without the option provided by the developer tools.", + "isCommentAutoGenerated" : true + }, "Used" : { "comment" : "A label describing the percentage of memory that is currently in use.", "isCommentAutoGenerated" : true, @@ -25613,10 +25567,6 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, - "Youtube Music" : { - "comment" : "Name for the \"Youtube Music\" media controller type in settings.", - "isCommentAutoGenerated" : true - }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift index 36fe0ff..7211240 100644 --- a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift +++ b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift @@ -251,6 +251,290 @@ struct CalendarView: View { } } +struct StandaloneCalendarView: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject private var calendarManager = CalendarManager.shared + @State private var selectedDate = Date() + @State private var displayedMonth = Date() + @State private var datePickerScrollTarget: Date? + @Default(.hideAllDayEvents) private var hideAllDayEvents + @Default(.hideCompletedReminders) private var hideCompletedReminders + + private let calendar = Calendar.current + private let weekdaySymbols = Calendar.current.shortWeekdaySymbols + + private var monthTitle: String { + displayedMonth.formatted(.dateTime.month(.wide)) + } + + private var yearTitle: String { + displayedMonth.formatted(.dateTime.year()) + } + + private var monthDays: [Date] { + guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth), + let firstWeekInterval = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start), + let lastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end), + let lastWeekInterval = calendar.dateInterval(of: .weekOfMonth, for: lastDay) + else { return [] } + + var days: [Date] = [] + var current = firstWeekInterval.start + while current < lastWeekInterval.end { + days.append(current) + guard let next = calendar.date(byAdding: .day, value: 1, to: current) else { break } + current = next + } + return days + } + + private var filteredEvents: [EventModel] { + EventListView.filteredEvents( + events: calendarManager.events, + hideCompletedReminders: hideCompletedReminders, + hideAllDayEvents: hideAllDayEvents + ) + } + + private var resolvedNotchHeight: CGFloat { + let height = vm.notchSize.height + return height > 0 ? height : openNotchSize.height + } + + private var headerHeight: CGFloat { + max(24, vm.effectiveClosedNotchHeight) + } + + private var maxTabContentHeight: CGFloat { + let available = resolvedNotchHeight - headerHeight - 36 + return max(130, available) + } + + var body: some View { + GeometryReader { geometry in + let paneSpacing: CGFloat = 12 + let paneWidth = max((geometry.size.width - paneSpacing) / 2, 0) + let paneHeight = max(0, geometry.size.height) + + HStack(alignment: .top, spacing: paneSpacing) { + leftPickerPane + .frame(width: paneWidth, alignment: .topLeading) + .frame(height: paneHeight, alignment: .topLeading) + .layoutPriority(1) + + rightEventsPane + .frame(width: paneWidth, alignment: .topLeading) + .frame(height: paneHeight, alignment: .topLeading) + .layoutPriority(1) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .clipped() + } + .frame(height: maxTabContentHeight) + .clipped() + .onAppear { + selectedDate = Date.now + displayedMonth = selectedDate.startOfMonth + requestDatePickerCenterOnCurrentDate() + Task { + await calendarManager.updateCurrentDate(selectedDate) + } + } + .onChange(of: selectedDate) { _, newDate in + withAnimation(.smooth(duration: 0.22)) { + displayedMonth = newDate.startOfMonth + } + Task { + await calendarManager.updateCurrentDate(newDate) + } + } + .onChange(of: vm.notchState) { _, newState in + guard newState == .open else { return } + selectedDate = Date.now + displayedMonth = selectedDate.startOfMonth + requestDatePickerCenterOnCurrentDate() + Task { + await calendarManager.updateCurrentDate(selectedDate) + } + } + } + + private var leftPickerPane: some View { + GeometryReader { geometry in + let pickerViewportHeight = max(96, geometry.size.height - 56) + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(monthTitle) + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(.white) + Text(yearTitle) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Color(white: 0.65)) + } + Spacer() + HStack(spacing: 6) { + Button(action: showPreviousMonth) { + Image(systemName: "chevron.left") + .font(.system(size: 11, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Button(action: showNextMonth) { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } + .foregroundStyle(.white) + } + + ScrollViewReader { proxy in + VStack(spacing: 6) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 14), spacing: 6), count: 7), spacing: 6) { + ForEach(weekdaySymbols, id: \.self) { symbol in + Text(symbol.prefix(1)) + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(Color(white: 0.55)) + .frame(maxWidth: .infinity) + } + } + + ZStack { + ScrollView(.vertical, showsIndicators: false) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 14), spacing: 6), count: 7), spacing: 6) { + ForEach(monthDays, id: \.self) { day in + dayCell(for: day) + .id(calendar.startOfDay(for: day)) + } + } + .padding(.bottom, 2) + } + .onChange(of: datePickerScrollTarget) { _, target in + guard let target else { return } + centerDatePicker(on: target, proxy: proxy) + } + + LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .top) + + LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .frame(height: max(0, pickerViewportHeight - 22)) + .clipped() + } + .frame(height: pickerViewportHeight) + } + .frame(height: pickerViewportHeight) + .clipped() + } + .frame(maxHeight: .infinity, alignment: .top) + } + .padding(.horizontal, 6) + .padding(.top, 4) + .clipped() + } + + private var rightEventsPane: some View { + Group { + if filteredEvents.isEmpty { + EmptyEventsView(selectedDate: selectedDate) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + StandaloneEventCardList( + events: filteredEvents, + showFullEventTitles: Defaults[.showFullEventTitles], + onToggleReminder: { reminderID, completed in + Task { + await calendarManager.setReminderCompleted(reminderID: reminderID, completed: completed) + } + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + + private func dayCell(for day: Date) -> some View { + let isCurrentMonth = calendar.isDate(day, equalTo: displayedMonth, toGranularity: .month) + let isSelected = calendar.isDate(day, inSameDayAs: selectedDate) + let isToday = calendar.isDateInToday(day) + + return Button { + withAnimation(.smooth(duration: 0.18)) { + selectedDate = day + } + } label: { + ZStack { + if isSelected { + Circle() + .fill(Color.effectiveAccent) + .frame(width: 28, height: 28) + } + + Text(day.formatted(.dateTime.day())) + .font(.system(size: 12, weight: isSelected ? .semibold : .medium)) + .foregroundStyle(dayTextColor(isCurrentMonth: isCurrentMonth, isSelected: isSelected, isToday: isToday)) + } + .frame(maxWidth: .infinity, minHeight: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func dayTextColor(isCurrentMonth: Bool, isSelected: Bool, isToday: Bool) -> Color { + if isSelected { return .white } + if !isCurrentMonth { return Color(white: 0.35) } + if isToday { return Color.effectiveAccent } + return .white + } + + private func showPreviousMonth() { + guard let newMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) else { return } + withAnimation(.smooth(duration: 0.22)) { + displayedMonth = newMonth.startOfMonth + selectedDate = newMonth.startOfMonth + } + } + + private func showNextMonth() { + guard let newMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) else { return } + withAnimation(.smooth(duration: 0.22)) { + displayedMonth = newMonth.startOfMonth + selectedDate = newMonth.startOfMonth + } + } + + private func requestDatePickerCenterOnCurrentDate() { + datePickerScrollTarget = calendar.startOfDay(for: selectedDate) + } + + private func centerDatePicker(on target: Date, proxy: ScrollViewProxy) { + let normalizedTarget = calendar.startOfDay(for: target) + DispatchQueue.main.async { + withAnimation(.smooth(duration: 0.24)) { + proxy.scrollTo(normalizedTarget, anchor: .center) + } + if datePickerScrollTarget == normalizedTarget { + datePickerScrollTarget = nil + } + } + } +} + struct EmptyEventsView: View { let selectedDate: Date @@ -269,6 +553,170 @@ struct EmptyEventsView: View { } } +private extension Date { + var startOfMonth: Date { + Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: self)) ?? self + } +} + +private struct StandaloneEventCardList: View { + @Environment(\.openURL) private var openURL + let events: [EventModel] + let showFullEventTitles: Bool + let onToggleReminder: (String, Bool) -> Void + + var body: some View { + ZStack { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(events) { event in + eventCard(event) + } + } + .padding(.vertical, 2) + } + .clipped() + + LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .top) + + LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) + .frame(height: 16) + .allowsHitTesting(false) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .clipped() + } + + @ViewBuilder + private func eventCard(_ event: EventModel) -> some View { + if event.type.isReminder { + Button { + if let url = event.calendarAppURL() { + openURL(url) + } + } label: { + reminderCard(event) + } + .buttonStyle(.plain) + .padding(.horizontal, 2) + } else { + calendarEventCard(event) + .contentShape(Rectangle()) + .onTapGesture { + if let url = event.calendarAppURL() { + openURL(url) + } + } + .padding(.horizontal, 2) + } + } + + private func reminderCard(_ event: EventModel) -> some View { + let isCompleted: Bool + if case .reminder(let completed) = event.type { + isCompleted = completed + } else { + isCompleted = false + } + + return HStack(spacing: 10) { + ReminderToggle( + isOn: Binding( + get: { isCompleted }, + set: { newValue in onToggleReminder(event.id, newValue) } + ), + color: Color(event.calendar.color) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(event.title) + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(showFullEventTitles ? nil : 2) + + if event.isAllDay { + Text("All-day") + .font(.caption) + .foregroundColor(Color(white: 0.65)) + } + } + + Spacer(minLength: 8) + + if !event.isAllDay { + Text(event.start, style: .time) + .font(.caption) + .foregroundColor(Color(white: 0.75)) + } + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 50, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.white.opacity(0.05)) + ) + .opacity(isCompleted ? 0.55 : 1) + } + + private func calendarEventCard(_ event: EventModel) -> some View { + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(event.calendar.color)) + .frame(width: 4, height: 36) + + VStack(alignment: .leading, spacing: 4) { + Text(event.title) + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.white) + .lineLimit(showFullEventTitles ? nil : 2) + + if let location = event.location, !location.isEmpty { + Text(location) + .font(.caption) + .foregroundColor(Color(white: 0.65)) + .lineLimit(1) + } + + if let conferenceURL = event.conferenceURL { + ConferenceJoinButton(url: conferenceURL, event: event) + } + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 2) { + if event.isAllDay { + Text("All-day") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + } else { + Text(event.start, style: .time) + .font(.caption) + .foregroundColor(.white) + Text(event.end, style: .time) + .font(.caption2) + .foregroundColor(Color(white: 0.65)) + } + } + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: 50, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.white.opacity(0.05)) + ) + .opacity(event.eventStatus == .ended && Calendar.current.isDateInToday(event.start) ? 0.6 : 1) + } +} + struct EventListView: View { @Environment(\.openURL) private var openURL @ObservedObject private var calendarManager = CalendarManager.shared diff --git a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift index fe823ca..6d0d38b 100644 --- a/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift +++ b/DynamicIsland/components/Extensions/ExtensionLockScreenWidgetView.swift @@ -132,10 +132,8 @@ struct ExtensionLockScreenWidgetView: View { @ViewBuilder private func liquidGlassBackground(shape: RoundedRectangle) -> some View { - TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { context in - LiquidGlassBackground(variant: requestedLiquidGlassVariant, cornerRadius: descriptor.cornerRadius, trigger: context.date.timeIntervalSinceReferenceDate) { - Color.black.opacity(0.08) - } + LiquidGlassBackground(variant: requestedLiquidGlassVariant, cornerRadius: descriptor.cornerRadius) { + Color.black.opacity(0.08) } .overlay { tintOverlay(shape: shape) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 46d09a1..e7ffd82 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -355,13 +355,18 @@ struct LockScreenMusicPanel: View { collapseWorkItem?.cancel() collapseWorkItem = nil } + + private var isProgressTimelinePaused: Bool { + !musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0 + } // MARK: - Progress Bar private var progressBar: some View { TimelineView( .animation( - minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil + minimumInterval: 0.1, + paused: isProgressTimelinePaused ) ) { timeline in MusicSliderView( @@ -792,10 +797,8 @@ struct LockScreenMusicPanel: View { @ViewBuilder private var customLiquidPanelBackdrop: some View { - TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { context in - LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: panelCornerRadius, trigger: context.date.timeIntervalSinceReferenceDate) { - Color.white.opacity(0.04) - } + LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: panelCornerRadius) { + Color.white.opacity(0.04) } .frame(maxWidth: .infinity, maxHeight: .infinity) .allowsHitTesting(false) @@ -873,10 +876,8 @@ struct LockScreenMusicPanel: View { @ViewBuilder private func customLiquidAlbumArtBackground(cornerRadius: CGFloat) -> some View { - TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { context in - LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: cornerRadius, trigger: context.date.timeIntervalSinceReferenceDate) { - Color.clear - } + LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: cornerRadius) { + Color.clear } } diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index ee52a5a..e13289d 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -154,10 +154,8 @@ struct LockScreenTimerWidget: View { } private var customLiquidBackground: some View { - TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { context in - LiquidGlassBackground(variant: timerGlassVariant, cornerRadius: Self.cornerRadius, trigger: context.date.timeIntervalSinceReferenceDate) { - Color.black.opacity(0.12) - } + LiquidGlassBackground(variant: timerGlassVariant, cornerRadius: Self.cornerRadius) { + Color.black.opacity(0.12) } } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 7e68b84..1b3be04 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -552,11 +552,18 @@ struct NotchHomeView: View { } if Defaults[.showCalendar] { - CalendarView() - .onHover { isHovering in - vm.isHoveringCalendar = isHovering + Group { + if showStandardMediaControls { + CalendarView() + } else { + StandaloneCalendarView() } - .environmentObject(vm) + } + .frame(maxWidth: .infinity, alignment: .leading) + .onHover { isHovering in + vm.isHoveringCalendar = isHovering + } + .environmentObject(vm) } if Defaults[.showMirror], diff --git a/DynamicIsland/components/OSD/CustomOSDView.swift b/DynamicIsland/components/OSD/CustomOSDView.swift index 67d19ac..19ea3cc 100644 --- a/DynamicIsland/components/OSD/CustomOSDView.swift +++ b/DynamicIsland/components/OSD/CustomOSDView.swift @@ -28,6 +28,8 @@ struct CustomOSDView: View { @Binding var icon: String @Default(.osdMaterial) var osdMaterial + @Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode + @Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant @Default(.osdIconColorStyle) var osdIconColorStyle @Environment(\.colorScheme) var colorScheme @@ -160,11 +162,20 @@ struct CustomOSDView: View { .fill(.ultraThinMaterial) case .liquid: if #available(macOS 26.0, *) { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .glassEffect( - .clear.interactive(), - in: .rect(cornerRadius: 18) - ) + if osdLiquidGlassCustomizationMode == .customLiquid { + LiquidGlassBackground( + variant: osdLiquidGlassVariant, + cornerRadius: 18 + ) { + Color.white.opacity(0.04) + } + } else { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .glassEffect( + .clear.interactive(), + in: .rect(cornerRadius: 18) + ) + } } else { RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(.ultraThinMaterial) diff --git a/DynamicIsland/components/OSD/VerticalHUDView.swift b/DynamicIsland/components/OSD/VerticalHUDView.swift index f9eacff..e27e69c 100644 --- a/DynamicIsland/components/OSD/VerticalHUDView.swift +++ b/DynamicIsland/components/OSD/VerticalHUDView.swift @@ -46,6 +46,9 @@ struct VerticalHUDView: View { @Default(.verticalHUDHeight) var hudHeight @Default(.verticalHUDWidth) var hudWidth @Default(.verticalHUDUseAccentColor) var useAccentColor + @Default(.verticalHUDMaterial) var verticalHUDMaterial + @Default(.verticalHUDLiquidGlassCustomizationMode) var verticalHUDLiquidGlassCustomizationMode + @Default(.verticalHUDLiquidGlassVariant) var verticalHUDLiquidGlassVariant @Default(.verticalHUDInteractive) var isInteractive @Environment(\.colorScheme) var colorScheme @@ -68,8 +71,8 @@ struct VerticalHUDView: View { // The Actual HUD Pill ZStack { // Background - Capsule() - .fill(.ultraThinMaterial) + verticalBackground + .clipShape(Capsule()) .overlay { Capsule() .stroke(.white.opacity(0.1), lineWidth: 1) @@ -253,5 +256,39 @@ struct VerticalHUDView: View { } return .secondary } + + @ViewBuilder + private var verticalBackground: some View { + switch verticalHUDMaterial { + case .frosted: + Capsule().fill(.ultraThinMaterial) + case .liquid: + if #available(macOS 26.0, *) { + if verticalHUDLiquidGlassCustomizationMode == .customLiquid { + LiquidGlassBackground( + variant: verticalHUDLiquidGlassVariant, + cornerRadius: max(currentWidth, hudWidth) + ) { + Color.white.opacity(0.04) + } + } else { + Capsule() + .fill(.clear) + .glassEffect( + .clear.interactive(), + in: .capsule + ) + } + } else { + Capsule().fill(.ultraThinMaterial) + } + case .solidDark: + Capsule().fill(Color.black.opacity(0.85)) + case .solidLight: + Capsule().fill(Color.white.opacity(0.85)) + case .solidAuto: + Capsule().fill((colorScheme == .dark ? Color.black : Color.white).opacity(0.85)) + } + } } #endif diff --git a/DynamicIsland/components/Onboarding/OnboardingView.swift b/DynamicIsland/components/Onboarding/OnboardingView.swift index bed1fd7..5697ab3 100644 --- a/DynamicIsland/components/Onboarding/OnboardingView.swift +++ b/DynamicIsland/components/Onboarding/OnboardingView.swift @@ -22,6 +22,7 @@ import SwiftUI import AVFoundation +import Defaults enum OnboardingStep { case welcome @@ -36,6 +37,8 @@ private let calendarService = CalendarService() struct OnboardingView: View { @State private var step: OnboardingStep = .welcome + @State private var showFocusMonitoringChoice = false + @State private var didPresentFocusMonitoringChoice = false let onFinish: () -> Void let onOpenSettings: () -> Void @@ -120,6 +123,28 @@ struct OnboardingView: View { } } .frame(width: 400, height: 600) + .onAppear { + guard !didPresentFocusMonitoringChoice else { return } + didPresentFocusMonitoringChoice = true + showFocusMonitoringChoice = true + } + .confirmationDialog( + "Focus detection mode", + isPresented: $showFocusMonitoringChoice, + titleVisibility: .visible + ) { + Button("Use DevTools") { + Defaults[.focusMonitoringMode] = .useDevTools + } + + Button("Use without DevTools") { + Defaults[.focusMonitoringMode] = .withoutDevTools + } + + Button("Later", role: .cancel) {} + } message: { + Text("This is optional. You can change it any time from the menu bar.") + } } // MARK: - Permission Request Logic diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index d14e0b0..cb80cb8 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1369,6 +1369,9 @@ private struct HUDAndOSDSettingsView: View { @Default(.verticalHUDWidth) var verticalHUDWidth @Default(.verticalHUDPadding) var verticalHUDPadding @Default(.verticalHUDUseAccentColor) var verticalHUDUseAccentColor + @Default(.verticalHUDMaterial) var verticalHUDMaterial + @Default(.verticalHUDLiquidGlassCustomizationMode) var verticalHUDLiquidGlassCustomizationMode + @Default(.verticalHUDLiquidGlassVariant) var verticalHUDLiquidGlassVariant // Circular HUD Props @Default(.circularHUDShowValue) var circularHUDShowValue @@ -1391,6 +1394,27 @@ private struct HUDAndOSDSettingsView: View { Color(nsColor: .controlBackgroundColor) } + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var availableVerticalMaterials: [OSDMaterial] { + if #available(macOS 26.0, *) { + return OSDMaterial.allCases + } + return OSDMaterial.allCases.filter { $0 != .liquid } + } + + private var verticalLiquidVariantBinding: Binding { + Binding( + get: { Double(verticalHUDLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + verticalHUDLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } + var body: some View { VStack(spacing: 20) { HStack(spacing: 16) { @@ -1608,6 +1632,39 @@ private struct HUDAndOSDSettingsView: View { Toggle("Show Percentage", isOn: $verticalHUDShowValue) Toggle("Use Accent Color", isOn: $verticalHUDUseAccentColor) Toggle("Interactive (Drag to Change)", isOn: $verticalHUDInteractive) + Picker("Material", selection: $verticalHUDMaterial) { + ForEach(availableVerticalMaterials, id: \.self) { material in + Text(material.rawValue).tag(material) + } + } + + if verticalHUDMaterial == .liquid { + if #available(macOS 26.0, *) { + Picker("Glass mode", selection: $verticalHUDLiquidGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + + if verticalHUDLiquidGlassCustomizationMode == .customLiquid { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Custom liquid variant") + Spacer() + Text("v\(verticalHUDLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: verticalLiquidVariantBinding, in: liquidVariantRange, step: 1) + } + } + } else { + Text("Custom Liquid is available on macOS 26 or later.") + .font(.caption) + .foregroundStyle(.secondary) + } + } Defaults.Toggle("Color-coded Volume", key: .useColorCodedVolumeDisplay) if Defaults[.useColorCodedVolumeDisplay] { Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient) @@ -1719,6 +1776,12 @@ private struct HUDAndOSDSettingsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(paneBackgroundColor) .navigationTitle("Controls") + .onAppear { + if #unavailable(macOS 26.0), verticalHUDMaterial == .liquid { + verticalHUDMaterial = .frosted + verticalHUDLiquidGlassCustomizationMode = .standard + } + } } } @@ -4457,7 +4520,6 @@ private struct LockScreenGlassVariantPreviewCell: View { private let cornerRadius: CGFloat = 16 private let previewCornerRadius: CGFloat = 14 private let previewSize = CGSize(width: 190, height: 96) - @State private var previewToken: Double = 0 var body: some View { ZStack { @@ -4486,7 +4548,6 @@ private struct LockScreenGlassVariantPreviewCell: View { Logger.log("Lock screen glass preview disappeared", category: .performance) } .onChange(of: variant) { _, newValue in - previewToken = Date().timeIntervalSinceReferenceDate Logger.log("Lock screen glass preview variant changed to v\(newValue.rawValue)", category: .performance) } } @@ -4494,9 +4555,7 @@ private struct LockScreenGlassVariantPreviewCell: View { private var liquidGlassPreview: some View { LiquidGlassBackground( variant: variant, - cornerRadius: previewCornerRadius, - trigger: previewToken, - jitterEnabled: false + cornerRadius: previewCornerRadius ) { Color.white.opacity(0.04) } @@ -5608,34 +5667,34 @@ struct TimerSettings: View { let nextIndex = timerPresets.count + 1 let defaultColor = Defaults[.accentColor] let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor) - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets.append(newPreset) } } private func movePresetUp(_ index: Int) { guard index > timerPresets.startIndex else { return } - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets.swapAt(index, index - 1) } } private func movePresetDown(_ index: Int) { guard index < timerPresets.index(before: timerPresets.endIndex) else { return } - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets.swapAt(index, index + 1) } } private func removePreset(_ index: Int) { guard timerPresets.indices.contains(index) else { return } - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets.remove(at: index) } } private func resetPresets() { - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets = TimerPreset.defaultPresets } } @@ -6550,6 +6609,8 @@ struct CustomOSDSettings: View { @Default(.enableOSDBrightness) var enableOSDBrightness @Default(.enableOSDKeyboardBacklight) var enableOSDKeyboardBacklight @Default(.osdMaterial) var osdMaterial + @Default(.osdLiquidGlassCustomizationMode) var osdLiquidGlassCustomizationMode + @Default(.osdLiquidGlassVariant) var osdLiquidGlassVariant @Default(.osdIconColorStyle) var osdIconColorStyle @Default(.enableSystemHUD) var enableSystemHUD @ObservedObject private var accessibilityPermission = AccessibilityPermissionStore.shared @@ -6565,6 +6626,27 @@ struct CustomOSDSettings: View { private var hasAccessibilityPermission: Bool { accessibilityPermission.isAuthorized } + + private var availableOSDMaterials: [OSDMaterial] { + if #available(macOS 26.0, *) { + return OSDMaterial.allCases + } + return OSDMaterial.allCases.filter { $0 != .liquid } + } + + private var liquidVariantRange: ClosedRange { + Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) + } + + private var osdLiquidVariantBinding: Binding { + Binding( + get: { Double(osdLiquidGlassVariant.rawValue) }, + set: { newValue in + let raw = Int(newValue.rounded()) + osdLiquidGlassVariant = LiquidGlassVariant.clamped(raw) + } + ) + } var body: some View { Form { @@ -6598,7 +6680,7 @@ struct CustomOSDSettings: View { Section { Picker("Material", selection: $osdMaterial) { - ForEach(OSDMaterial.allCases, id: \.self) { material in + ForEach(availableOSDMaterials, id: \.self) { material in Text(material.rawValue).tag(material) } } @@ -6606,6 +6688,34 @@ struct CustomOSDSettings: View { .onChange(of: osdMaterial) { _, _ in previewValue = previewValue == 0.65 ? 0.651 : 0.65 } + + if osdMaterial == .liquid { + if #available(macOS 26.0, *) { + Picker("Glass mode", selection: $osdLiquidGlassCustomizationMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + + if osdLiquidGlassCustomizationMode == .customLiquid { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Custom liquid variant") + Spacer() + Text("v\(osdLiquidGlassVariant.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + Slider(value: osdLiquidVariantBinding, in: liquidVariantRange, step: 1) + } + } + } else { + Text("Custom Liquid is available on macOS 26 or later.") + .font(.caption) + .foregroundStyle(.secondary) + } + } Picker("Icon & Progress Color", selection: $osdIconColorStyle) { ForEach(OSDIconColorStyle.allCases, id: \.self) { style in @@ -6682,6 +6792,10 @@ struct CustomOSDSettings: View { .navigationTitle("Custom OSD") .onAppear { accessibilityPermission.refreshStatus() + if #unavailable(macOS 26.0), osdMaterial == .liquid { + osdMaterial = .frosted + osdLiquidGlassCustomizationMode = .standard + } } .onChange(of: accessibilityPermission.isAuthorized) { _, granted in if !granted { diff --git a/DynamicIsland/components/Settings/SettingsWindowController.swift b/DynamicIsland/components/Settings/SettingsWindowController.swift index a80b382..ca37d37 100644 --- a/DynamicIsland/components/Settings/SettingsWindowController.swift +++ b/DynamicIsland/components/Settings/SettingsWindowController.swift @@ -59,9 +59,10 @@ class SettingsWindowController: NSWindowController { window.titleVisibility = .visible window.toolbarStyle = .unified window.isMovableByWindowBackground = true + window.level = .normal // Make it behave like a regular app window with proper Spaces support - window.collectionBehavior = [.managed, .participatesInCycle, .fullScreenAuxiliary] + window.collectionBehavior = [.managed, .participatesInCycle] // Ensure proper window behavior window.hidesOnDeactivate = false @@ -85,6 +86,10 @@ class SettingsWindowController: NSWindowController { func showWindow() { // Ensure window exists _ = window + + // Reassert regular window semantics in case any prior state mutated this window. + window?.level = .normal + window?.collectionBehavior = [.managed, .participatesInCycle] // If window is already visible, bring it to front properly if window?.isVisible == true { diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index ff77a77..c75ab59 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -99,9 +99,6 @@ struct TabSelectionView: View { ) } } - DispatchQueue.main.async { - ensureValidSelection(with: tabsArray) - } return tabsArray } var body: some View { @@ -110,7 +107,7 @@ struct TabSelectionView: View { let isSelected = isSelected(tab) let activeAccent = tab.accentColor ?? .white TabButton(label: tab.label, icon: tab.icon, selected: isSelected) { - withAnimation(.smooth) { + withAnimation(.smooth(duration: 0.3)) { if tab.view == .extensionExperience { coordinator.selectedExtensionExperienceID = tab.experienceID } @@ -135,6 +132,9 @@ struct TabSelectionView: View { } } .clipShape(Capsule()) + .onAppear { + ensureValidSelection(with: tabs) + } } private var extensionTabsEnabled: Bool { diff --git a/DynamicIsland/helpers/LiquidGlassBackground.swift b/DynamicIsland/helpers/LiquidGlassBackground.swift index 2af27bb..924a2f2 100644 --- a/DynamicIsland/helpers/LiquidGlassBackground.swift +++ b/DynamicIsland/helpers/LiquidGlassBackground.swift @@ -20,6 +20,108 @@ import SwiftUI import AppKit import Defaults +private final class LiquidGlassContainerView: NSView { + weak var glassView: NSView? + var hostingView: NSHostingView? + + private var observedBackdropLayers: [CALayer] = [] + private var hasScheduledBackdropSetup = false + private let windowServerAwareKeyPath = "windowServerAware" + private let scaleKeyPath = "scale" + + deinit { + removeBackdropObservers() + } + + override func removeFromSuperview() { + removeBackdropObservers() + super.removeFromSuperview() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + scheduleBackdropSetup() + } + + override func layout() { + super.layout() + scheduleBackdropSetup() + } + + func scheduleBackdropSetup() { + guard !hasScheduledBackdropSetup else { return } + hasScheduledBackdropSetup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + guard let self else { return } + self.hasScheduledBackdropSetup = false + self.configureBackdropLayers() + } + } + + private func configureBackdropLayers() { + guard let glassView else { return } + guard let rootLayer = glassView.layer else { + scheduleBackdropSetup() + return + } + + setBackdropProperties(in: rootLayer) + let newBackdropLayers = collectBackdropLayers(in: rootLayer) + + removeBackdropObservers() + observedBackdropLayers = newBackdropLayers + for backdrop in observedBackdropLayers { + backdrop.addObserver(self, forKeyPath: windowServerAwareKeyPath, options: [.old, .new], context: nil) + backdrop.addObserver(self, forKeyPath: scaleKeyPath, options: [.old, .new], context: nil) + } + } + + private func setBackdropProperties(in layer: CALayer) { + if NSStringFromClass(type(of: layer)).contains("CABackdropLayer") { + layer.setValue(true, forKey: windowServerAwareKeyPath) + layer.setValue(1.0, forKey: scaleKeyPath) + } + layer.sublayers?.forEach { setBackdropProperties(in: $0) } + } + + private func collectBackdropLayers(in layer: CALayer) -> [CALayer] { + var results: [CALayer] = [] + if NSStringFromClass(type(of: layer)).contains("CABackdropLayer") { + results.append(layer) + } + layer.sublayers?.forEach { results.append(contentsOf: collectBackdropLayers(in: $0)) } + return results + } + + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer? + ) { + if keyPath == windowServerAwareKeyPath { + if change?[.newKey] as? Bool == false { + configureBackdropLayers() + } + } else if keyPath == scaleKeyPath { + guard let layer = object as? CALayer else { return } + if let newScale = (change?[.newKey] as? NSNumber)?.doubleValue, newScale != 1.0 { + layer.setValue(1.0, forKey: scaleKeyPath) + } + } else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + } + } + + private func removeBackdropObservers() { + for layer in observedBackdropLayers { + layer.removeObserver(self, forKeyPath: windowServerAwareKeyPath) + layer.removeObserver(self, forKeyPath: scaleKeyPath) + } + observedBackdropLayers.removeAll() + } +} + /// All 20 available liquid‑glass variants. /// Apple does not publicly describe how each value looks so experiment and pick the one you like! public enum LiquidGlassVariant: Int, CaseIterable, Identifiable, Defaults.Serializable, Sendable { @@ -57,8 +159,6 @@ public struct LiquidGlassBackground: NSViewRepresentable { private let content: Content private let cornerRadius: CGFloat private let variant: LiquidGlassVariant - private let trigger: Double - private let jitterEnabled: Bool /// Creates a new liquid‑glass container. /// - Parameters: /// - variant: Any ``LiquidGlassVariant`` (0–19). Defaults to `.v11`, which is visually super pleasing @@ -67,14 +167,10 @@ public struct LiquidGlassBackground: NSViewRepresentable { public init( variant: LiquidGlassVariant = .defaultVariant, cornerRadius: CGFloat = 10, - trigger: Double = 0, - jitterEnabled: Bool = true, @ViewBuilder content: () -> Content ) { self.variant = variant self.cornerRadius = cornerRadius - self.trigger = trigger - self.jitterEnabled = jitterEnabled self.content = content() } @@ -116,14 +212,30 @@ public struct LiquidGlassBackground: NSViewRepresentable { public func makeNSView(context: Context) -> NSView { // `NSGlassEffectView` is private. Look it up dynamically to avoid compile‑time coupling. if let glassType = NSClassFromString("NSGlassEffectView") as? NSView.Type { + let container = LiquidGlassContainerView(frame: .zero) + container.translatesAutoresizingMaskIntoConstraints = false + let glass = glassType.init(frame: .zero) + glass.translatesAutoresizingMaskIntoConstraints = false glass.setValue(cornerRadius, forKey: "cornerRadius") callPrivateVariantSetter(on: glass, value: variant.rawValue) - let hosting = NSHostingView(rootView: content) + let hosting = NSHostingView(rootView: AnyView(content)) hosting.translatesAutoresizingMaskIntoConstraints = false glass.setValue(hosting, forKey: "contentView") - return glass + + container.addSubview(glass) + NSLayoutConstraint.activate([ + glass.leadingAnchor.constraint(equalTo: container.leadingAnchor), + glass.trailingAnchor.constraint(equalTo: container.trailingAnchor), + glass.topAnchor.constraint(equalTo: container.topAnchor), + glass.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + + container.glassView = glass + container.hostingView = hosting + container.scheduleBackdropSetup() + return container } // Fallback for earlier macOS – use an ordinary blur. @@ -143,20 +255,17 @@ public struct LiquidGlassBackground: NSViewRepresentable { } public func updateNSView(_ nsView: NSView, context: Context) { - if let hosting = nsView.value(forKey: "contentView") as? NSHostingView { + if let container = nsView as? LiquidGlassContainerView, + let glass = container.glassView { + container.hostingView?.rootView = AnyView(content) + glass.setValue(cornerRadius, forKey: "cornerRadius") + callPrivateVariantSetter(on: glass, value: variant.rawValue) + container.scheduleBackdropSetup() + return + } + + if let hosting = nsView.subviews.first as? NSHostingView { hosting.rootView = content } - nsView.setValue(cornerRadius, forKey: "cornerRadius") - callPrivateVariantSetter(on: nsView, value: variant.rawValue) - - if jitterEnabled { - // Micro-jitter: imperceptibly adjust opacity so WindowServer re-samples the wallpaper every frame. - let jitter = sin(trigger * 100) * 0.000001 - nsView.alphaValue = 1.0 - CGFloat(abs(jitter)) - } else if nsView.alphaValue != 1.0 { - nsView.alphaValue = 1.0 - } - - nsView.needsDisplay = true } } diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index ee0b6d7..073693a 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -32,12 +32,31 @@ final class DoNotDisturbManager: ObservableObject { private let notificationCenter = DistributedNotificationCenter.default() private let metadataExtractionQueue = DispatchQueue(label: "com.dynamicisland.focus.metadata", qos: .userInitiated) + private let pollingQueue = DispatchQueue(label: "com.dynamicisland.focus.polling", qos: .utility) private let focusLogStream = FocusLogStream() + private let assertionsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/DoNotDisturb/DB/Assertions.json") + private var pollingSource: DispatchSourceTimer? + private var lastAssertionsModificationDate: Date? + private var modeCancellable: AnyCancellable? + + @Published private(set) var monitoringMode: FocusMonitoringMode = Defaults[.focusMonitoringMode] private init() { focusLogStream.onMetadataUpdate = { [weak self] identifier, name in self?.handleLogMetadataUpdate(identifier: identifier, name: name) } + + modeCancellable = Defaults.publisher(.focusMonitoringMode, options: []) + .sink { [weak self] change in + guard let self else { return } + + DispatchQueue.main.async { + self.monitoringMode = change.newValue + } + + self.applyMonitoringModeChange(change.newValue) + } } deinit { @@ -63,7 +82,8 @@ final class DoNotDisturbManager: ObservableObject { suspensionBehavior: .deliverImmediately ) - focusLogStream.start() + startAssertionsPolling() + applyMonitoringModeChange(Defaults[.focusMonitoringMode]) isMonitoring = true } @@ -74,6 +94,7 @@ final class DoNotDisturbManager: ObservableObject { notificationCenter.removeObserver(self, name: .focusModeDisabled, object: nil) focusLogStream.stop() + stopAssertionsPolling() isMonitoring = false DispatchQueue.main.async { @@ -159,6 +180,8 @@ final class DoNotDisturbManager: ObservableObject { } private func handleLogMetadataUpdate(identifier: String?, name: String?) { + guard Defaults[.focusMonitoringMode] == .useDevTools else { return } + metadataExtractionQueue.async { [weak self] in guard let self = self else { return } let trimmedIdentifier = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -308,6 +331,90 @@ private extension Notification.Name { static let focusModeDisabled = Notification.Name("_NSDoNotDisturbDisabledNotification") } +private extension DoNotDisturbManager { + func applyMonitoringModeChange(_ mode: FocusMonitoringMode) { + guard isMonitoring else { return } + + if mode == .useDevTools { + focusLogStream.start() + } else { + focusLogStream.stop() + } + } + + func startAssertionsPolling() { + stopAssertionsPolling() + lastAssertionsModificationDate = nil + + let timer = DispatchSource.makeTimerSource(queue: pollingQueue) + timer.schedule(deadline: .now() + .seconds(1), repeating: .seconds(2), leeway: .milliseconds(250)) + timer.setEventHandler { [weak self] in + self?.pollAssertionsState() + } + timer.resume() + pollingSource = timer + } + + func stopAssertionsPolling() { + pollingSource?.cancel() + pollingSource = nil + lastAssertionsModificationDate = nil + } + + func pollAssertionsState() { + guard FullDiskAccessAuthorization.hasPermission() else { return } + + if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path), + let modifiedAt = attributes[.modificationDate] as? Date, + let lastObservedModificationDate = lastAssertionsModificationDate, + modifiedAt <= lastObservedModificationDate { + return + } + + guard let data = try? Data(contentsOf: assertionsURL), + !data.isEmpty, + let root = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any], + let dataArray = root["data"] as? [[String: Any]], + let firstItem = dataArray.first else { + return + } + + let assertions = (firstItem["storeAssertionRecords"] as? [Any]) ?? [] + let isActive = !assertions.isEmpty + + if let attributes = try? FileManager.default.attributesOfItem(atPath: assertionsURL.path), + let modifiedAt = attributes[.modificationDate] as? Date { + lastAssertionsModificationDate = modifiedAt + } + + let identifierKeys = [ + "modeIdentifier", + "FocusModeIdentifier", + "focusModeIdentifier", + "identifier", + "Identifier", + "focusModeUUID", + "UUID", + "uuid" + ] + + let nameKeys = [ + "activityDisplayName", + "displayName", + "FocusModeName", + "focusModeName", + "focusMode", + "name", + "Name" + ] + + let identifier = isActive ? firstMatch(for: identifierKeys, in: assertions) : nil + let name = isActive ? firstMatch(for: nameKeys, in: assertions) : nil + + publishMetadata(identifier: identifier, name: name, isActive: isActive, source: "assertions-poll") + } +} + // MARK: - Focus Mode Types enum FocusModeType: String, CaseIterable { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index c77f251..b470a79 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -498,6 +498,22 @@ enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializab } } +enum FocusMonitoringMode: String, CaseIterable, Identifiable, Defaults.Serializable { + case withoutDevTools = "withoutDevTools" + case useDevTools = "useDevTools" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .withoutDevTools: + return "Use without DevTools" + case .useDevTools: + return "Use DevTools" + } + } +} + enum ReminderPresentationStyle: String, CaseIterable, Identifiable, Defaults.Serializable { case ringCountdown = "Ring" case digital = "Digital" @@ -973,6 +989,15 @@ extension Defaults.Keys { static let verticalHUDWidth = Key("verticalHUDWidth", default: 36) static let verticalHUDPadding = Key("verticalHUDPadding", default: 24) static let verticalHUDUseAccentColor = Key("verticalHUDUseAccentColor", default: false) + static let verticalHUDMaterial = Key("verticalHUDMaterial", default: .frosted) + static let verticalHUDLiquidGlassCustomizationMode = Key( + "verticalHUDLiquidGlassCustomizationMode", + default: .standard + ) + static let verticalHUDLiquidGlassVariant = Key( + "verticalHUDLiquidGlassVariant", + default: .defaultVariant + ) // Circular HUD Customization static let circularHUDShowValue = Key("circularHUDShowValue", default: true) @@ -985,6 +1010,14 @@ extension Defaults.Keys { static let enableOSDBrightness = Key("enableOSDBrightness", default: true) static let enableOSDKeyboardBacklight = Key("enableOSDKeyboardBacklight", default: true) static let osdMaterial = Key("osdMaterial", default: .frosted) + static let osdLiquidGlassCustomizationMode = Key( + "osdLiquidGlassCustomizationMode", + default: .standard + ) + static let osdLiquidGlassVariant = Key( + "osdLiquidGlassVariant", + default: .defaultVariant + ) static let osdIconColorStyle = Key("osdIconColorStyle", default: .white) // MARK: Screen Recording Detection Feature @@ -995,6 +1028,7 @@ extension Defaults.Keys { // MARK: Focus / Do Not Disturb Detection static let enableDoNotDisturbDetection = Key("enableDoNotDisturbDetection", default: true) + static let focusMonitoringMode = Key("focusMonitoringMode", default: .withoutDevTools) static let showDoNotDisturbIndicator = Key("showDoNotDisturbIndicator", default: true) static let showDoNotDisturbLabel = Key("showDoNotDisturbLabel", default: true) static let focusIndicatorNonPersistent = Key("focusIndicatorNonPersistent", default: false)