From 2ce6a3ae9660f709b8c57aef9a8258ef5d929660 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 12 Dec 2025 13:43:16 +0530 Subject: [PATCH 1/3] Transient DND Mode --- DynamicIsland/ContentView.swift | 2 +- DynamicIsland/Localizable.xcstrings | 12 +++ .../Focus/DoNotDisturbLiveActivity.swift | 75 +++++++++++++++---- .../components/Settings/SettingsView.swift | 9 ++- DynamicIsland/models/Constants.swift | 1 + 5 files changed, 83 insertions(+), 16 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index c8347ed..60fd3ea 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -471,7 +471,7 @@ struct ContentView: View { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && doNotDisturbManager.isDoNotDisturbActive && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || Defaults[.focusIndicatorNonPersistent]) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { LockScreenLiveActivity() diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 07f523e..f72d62b 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -10731,6 +10731,10 @@ } } }, + "Labels are forced to compact on/off text while brief toast mode is enabled." : { + "comment" : "A help text explaining why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.", + "isCommentAutoGenerated" : true + }, "Last Updated" : { "localizations" : { "fr" : { @@ -17451,6 +17455,10 @@ } } }, + "Show the active Focus name inside the indicator." : { + "comment" : "A description of why the \"Show Focus Label\" toggle is disabled when the \"Show Focus as brief toast\" toggle is enabled.", + "isCommentAutoGenerated" : true + }, "Show timer name" : { }, @@ -20378,6 +20386,10 @@ }, "When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." : { + }, + "When enabled, Focus appears briefly (on/off) and then collapses instead of staying visible." : { + "comment" : "A help text for the setting that allows the focus indicator to collapse instead of staying visible.", + "isCommentAutoGenerated" : true }, "When enabled, stats monitoring stops a few seconds after the notch closes." : { diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift index a4b3f9e..ef430b9 100644 --- a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -13,6 +13,7 @@ struct DoNotDisturbLiveActivity: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var manager = DoNotDisturbManager.shared @Default(.showDoNotDisturbLabel) private var showLabelSetting + @Default(.focusIndicatorNonPersistent) private var focusToastMode @State private var isExpanded = false @State private var showInactiveIcon = false @@ -53,10 +54,15 @@ struct DoNotDisturbLiveActivity: View { } private var labelWingWidth: CGFloat { - if shouldShowLabel { - return max(desiredLabelWidth, minimalWingWidth) + guard shouldShowLabel else { + return focusToastMode ? 0 : ((isExpanded || showInactiveIcon) ? minimalWingWidth : 0) } - return (isExpanded || showInactiveIcon) ? minimalWingWidth : 0 + + if focusToastMode { + return max(labelIntrinsicWidth + 26, minimalWingWidth) + } + + return max(desiredLabelWidth, minimalWingWidth) } private var minimalWingWidth: CGFloat { @@ -76,7 +82,7 @@ struct DoNotDisturbLiveActivity: View { } private var shouldShowLabel: Bool { - showLabelSetting && isExpanded && !labelText.isEmpty + focusToastMode ? (isExpanded && !labelText.isEmpty) : (showLabelSetting && isExpanded && !labelText.isEmpty) } // MARK: - Focus metadata @@ -93,16 +99,23 @@ struct DoNotDisturbLiveActivity: View { } private var labelText: String { - let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed + if focusToastMode { + return manager.isDoNotDisturbActive ? "On" : "Off" } - let fallback = focusMode.displayName - if focusMode == .doNotDisturb { - return "Do Not Disturb" + let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) + if showLabelSetting { + if !trimmed.isEmpty { + return trimmed + } else if focusMode == .doNotDisturb { + return "Do Not Disturb" + } else { + let fallback = focusMode.displayName + return fallback.isEmpty ? "Focus" : fallback + } } - return fallback.isEmpty ? "Focus" : fallback + + return "" } private var accessibilityDescription: String { @@ -158,6 +171,7 @@ struct DoNotDisturbLiveActivity: View { .font(.system(size: 12, weight: .semibold, design: .rounded)) .foregroundColor(activeAccentColor) .lineLimit(1) + .minimumScaleFactor(0.8) .contentTransition(.opacity) .background( GeometryReader { proxy in @@ -181,12 +195,15 @@ struct DoNotDisturbLiveActivity: View { withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { isExpanded = true } + if focusToastMode { + scheduleTransientCollapse() + } } } private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) { + cancelPendingTasks() if isActive { - cancelPendingTasks() withAnimation(.smooth(duration: 0.2)) { showInactiveIcon = false } @@ -194,12 +211,18 @@ struct DoNotDisturbLiveActivity: View { iconScale = 1.0 isExpanded = true } + if focusToastMode { + scheduleTransientCollapse() + } } else { triggerInactiveAnimation() } } private func triggerInactiveAnimation() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + isExpanded = true + } withAnimation(.smooth(duration: 0.2)) { showInactiveIcon = true } @@ -208,9 +231,16 @@ struct DoNotDisturbLiveActivity: View { iconScale = 1.2 } + let toastMode = focusToastMode scaleResetTask?.cancel() scaleResetTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(650)) + try? await Task.sleep(for: .milliseconds(toastMode ? 650 : 450)) + withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) { + iconScale = 1.0 + } + if toastMode { + return + } withAnimation(.smooth(duration: 0.2)) { showInactiveIcon = false } @@ -218,13 +248,17 @@ struct DoNotDisturbLiveActivity: View { collapseTask?.cancel() collapseTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(320)) + try? await Task.sleep(for: .milliseconds(focusToastMode ? 900 : 320)) withAnimation(.smooth(duration: 0.32)) { isExpanded = false + if focusToastMode { + showInactiveIcon = false + } } } cleanupTask?.cancel() + guard !focusToastMode else { return } cleanupTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(650)) withAnimation(.smooth(duration: 0.2)) { @@ -233,6 +267,19 @@ struct DoNotDisturbLiveActivity: View { } } + private func scheduleTransientCollapse() { + collapseTask?.cancel() + cleanupTask?.cancel() + + collapseTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(1150)) + withAnimation(.smooth(duration: 0.32)) { + isExpanded = false + showInactiveIcon = false + } + } + } + private func cancelPendingTasks() { scaleResetTask?.cancel() collapseTask?.cancel() diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 7e3aa50..94d4a5c 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2056,6 +2056,7 @@ struct LiveActivitiesSettings: View { @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection + @Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent private func highlightID(_ title: String) -> String { SettingsTab.liveActivities.highlightID(for: title) @@ -2104,9 +2105,15 @@ struct LiveActivitiesSettings: View { .settingsHighlight(id: highlightID("Show Focus Indicator")) Defaults.Toggle("Show Focus Label", key: .showDoNotDisturbLabel) - .disabled(!enableDoNotDisturbDetection) + .disabled(!enableDoNotDisturbDetection || focusIndicatorNonPersistent) + .help(focusIndicatorNonPersistent ? "Labels are forced to compact on/off text while brief toast mode is enabled." : "Show the active Focus name inside the indicator.") .settingsHighlight(id: highlightID("Show Focus Label")) + Defaults.Toggle("Show Focus as brief toast", key: .focusIndicatorNonPersistent) + .disabled(!enableDoNotDisturbDetection) + .settingsHighlight(id: highlightID("Show Focus as brief toast")) + .help("When enabled, Focus appears briefly (on/off) and then collapses instead of staying visible.") + if doNotDisturbManager.isMonitoring { HStack { Text("Focus Status") diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 37b9ae1..e696e87 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -616,6 +616,7 @@ extension Defaults.Keys { static let enableDoNotDisturbDetection = Key("enableDoNotDisturbDetection", default: true) static let showDoNotDisturbIndicator = Key("showDoNotDisturbIndicator", default: true) static let showDoNotDisturbLabel = Key("showDoNotDisturbLabel", default: true) + static let focusIndicatorNonPersistent = Key("focusIndicatorNonPersistent", default: false) // MARK: Privacy Indicators (Camera & Microphone Detection) static let enableCameraDetection = Key("enableCameraDetection", default: true) From 83cca50a665dda6ee5882c8bd2818fb64bc25b7d Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 13 Dec 2025 00:54:17 +0530 Subject: [PATCH 2/3] Update DND UI for toast mode --- .../Focus/DoNotDisturbLiveActivity.swift | 153 +++++++++++++++--- .../LockScreenLiveActivityOverlay.swift | 16 +- .../managers/DoNotDisturbManager.swift | 7 +- .../LockScreenLiveActivityWindowManager.swift | 19 ++- 4 files changed, 169 insertions(+), 26 deletions(-) diff --git a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift index ef430b9..76e1c01 100644 --- a/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift +++ b/DynamicIsland/components/Focus/DoNotDisturbLiveActivity.swift @@ -6,6 +6,7 @@ // an icon-first layout that collapses gracefully when Focus ends. // +import AppKit import Defaults import SwiftUI @@ -30,12 +31,12 @@ struct DoNotDisturbLiveActivity: View { Rectangle() .fill(Color.black) - .frame(width: vm.closedNotchSize.width) + .frame(width: centerSegmentWidth) labelWing .frame(width: labelWingWidth, height: wingHeight) } - .frame(height: vm.effectiveClosedNotchHeight) + .frame(width: notchEnvelopeWidth, height: vm.effectiveClosedNotchHeight) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityDescription) .onAppear(perform: handleInitialState) @@ -45,6 +46,30 @@ struct DoNotDisturbLiveActivity: View { // MARK: - Layout helpers + private var collapsedNotchWidth: CGFloat { + let width = currentClosedNotchWidth + let scale = (resolvedScreen?.backingScaleFactor).flatMap { $0 > 0 ? $0 : nil } + ?? NSScreen.main?.backingScaleFactor + ?? 2 + let aligned = (width * scale).rounded(.down) / scale + return max(0, aligned) + } + + private var currentClosedNotchWidth: CGFloat { + if vm.notchState == .closed, vm.notchSize.width > 0 { + return vm.notchSize.width + } + return vm.closedNotchSize.width + } + + private var resolvedScreen: NSScreen? { + if let name = vm.screen, + let match = NSScreen.screens.first(where: { $0.localizedName == name }) { + return match + } + return NSScreen.main + } + private var wingHeight: CGFloat { max(vm.effectiveClosedNotchHeight - 10, 20) } @@ -65,13 +90,32 @@ struct DoNotDisturbLiveActivity: View { return max(desiredLabelWidth, minimalWingWidth) } + private var notchEnvelopeWidth: CGFloat { + centerSegmentWidth + iconWingWidth + labelWingWidth + } + private var minimalWingWidth: CGFloat { max(vm.effectiveClosedNotchHeight - 12, 24) } + private var closedNotchContentInset: CGFloat { + cornerRadiusInsets.closed.top + cornerRadiusInsets.closed.bottom + } + + private var collapsedToastBaseWidth: CGFloat { + max(0, collapsedNotchWidth - closedNotchContentInset) + } + + private var centerSegmentWidth: CGFloat { + if focusToastMode && iconWingWidth == 0 && labelWingWidth == 0 { + return collapsedToastBaseWidth + } + return collapsedNotchWidth + } + private var desiredLabelWidth: CGFloat { let measuredWidth = labelIntrinsicWidth + 8 // horizontal padding inside the label - let fallbackWidth = max(vm.closedNotchSize.width * 0.52, 136) + let fallbackWidth = max(collapsedNotchWidth * 0.52, 136) var width = max(measuredWidth, fallbackWidth) if focusMode == .doNotDisturb && shouldShowLabel { @@ -128,14 +172,45 @@ struct DoNotDisturbLiveActivity: View { private var currentIcon: Image { if manager.isDoNotDisturbActive { - return focusMode.activeIcon + return focusMode.resolvedActiveIcon(usePrivateSymbol: true) } else if showInactiveIcon { - return Image(systemName: focusMode.inactiveSymbol) + return inactiveIconMatchingActiveStyle } else { - return focusMode.activeIcon + return focusMode.resolvedActiveIcon(usePrivateSymbol: true) } } + private var inactiveIconMatchingActiveStyle: Image { + if focusMode == .work { + return focusMode.resolvedActiveIcon(usePrivateSymbol: true).renderingMode(.template) + } + + if focusMode == .gaming, + SymbolAvailabilityCache.shared.isSymbolAvailable("rocket.circle.fill") { + return Image(systemName: "rocket.circle.fill") + } + + if let internalName = focusMode.internalSymbolName { + if let outlineName = outlineVariant(for: internalName), + SymbolAvailabilityCache.shared.isSymbolAvailable(outlineName), + let outlinedImage = Image(internalSystemName: outlineName) { + return outlinedImage.renderingMode(.template) + } + + if let filledImage = Image(internalSystemName: internalName) { + return filledImage.renderingMode(.template) + } + } + + return Image(systemName: focusMode.inactiveSymbol) + } + + private func outlineVariant(for internalName: String) -> String? { + guard internalName.hasSuffix(".fill") else { return nil } + let trimmed = String(internalName.dropLast(5)) + return trimmed.isEmpty ? nil : trimmed + } + private var currentIconColor: Color { if manager.isDoNotDisturbActive { return activeAccentColor @@ -169,7 +244,7 @@ struct DoNotDisturbLiveActivity: View { if shouldShowLabel { Text(labelText) .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundColor(activeAccentColor) + .foregroundColor(labelColor) .lineLimit(1) .minimumScaleFactor(0.8) .contentTransition(.opacity) @@ -188,6 +263,13 @@ struct DoNotDisturbLiveActivity: View { } } + private var labelColor: Color { + if focusToastMode { + return manager.isDoNotDisturbActive ? activeAccentColor : .white + } + return activeAccentColor + } + // MARK: - State transitions private func handleInitialState() { @@ -227,22 +309,23 @@ struct DoNotDisturbLiveActivity: View { showInactiveIcon = true } - withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) { - iconScale = 1.2 - } + if focusToastMode { + iconScale = 1.0 + scaleResetTask?.cancel() + } else { + withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) { + iconScale = 1.2 + } - let toastMode = focusToastMode - scaleResetTask?.cancel() - scaleResetTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(toastMode ? 650 : 450)) - withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) { - iconScale = 1.0 - } - if toastMode { - return - } - withAnimation(.smooth(duration: 0.2)) { - showInactiveIcon = false + scaleResetTask?.cancel() + scaleResetTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(450)) + withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) { + iconScale = 1.0 + } + withAnimation(.smooth(duration: 0.2)) { + showInactiveIcon = false + } } } @@ -304,3 +387,29 @@ private struct FocusLabelWidthPreferenceKey: PreferenceKey { value = max(value, nextValue()) } } + +private final class SymbolAvailabilityCache { + static let shared = SymbolAvailabilityCache() + private var cache: [String: Bool] = [:] + private let lock = NSLock() + + func isSymbolAvailable(_ name: String) -> Bool { + lock.lock() + if let cached = cache[name] { + lock.unlock() + return cached + } + lock.unlock() + + #if canImport(AppKit) + let available = NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + #else + let available = false + #endif + + lock.lock() + cache[name] = available + lock.unlock() + return available + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index 512e475..7b0b796 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -22,6 +22,10 @@ struct LockScreenLiveActivityOverlay: View { notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) } + private var collapsedScale: CGFloat { + Self.collapsedScale(for: notchSize) + } + var body: some View { HStack(spacing: 0) { Color.clear @@ -48,7 +52,17 @@ struct LockScreenLiveActivityOverlay: View { ) ) .frame(width: totalWidth, height: notchSize.height) - .scaleEffect(x: model.scale, y: 1, anchor: .center) + .scaleEffect(x: max(model.scale, collapsedScale), y: 1, anchor: .center) .opacity(model.opacity) } } + +extension LockScreenLiveActivityOverlay { + static func collapsedScale(for notchSize: CGSize) -> CGFloat { + let indicatorSize = max(0, notchSize.height - 12) + let horizontalPadding = cornerRadiusInsets.closed.bottom + let totalWidth = notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) + guard totalWidth > 0 else { return 1 } + return notchSize.width / totalWidth + } +} diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index e323fed..0afcc92 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -352,7 +352,12 @@ enum FocusModeType: String, CaseIterable { } var activeIcon: Image { - if let internalSymbolName, + resolvedActiveIcon() + } + + func resolvedActiveIcon(usePrivateSymbol: Bool = true) -> Image { + if usePrivateSymbol, + let internalSymbolName, let image = Image(internalSystemName: internalSymbolName) { return image } diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index b5fb65a..4cc22a2 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -24,6 +24,7 @@ class LockScreenLiveActivityWindowManager { private weak var viewModel: DynamicIslandViewModel? private var screenChangeObserver: NSObjectProtocol? private var workspaceObservers: [NSObjectProtocol] = [] + private var currentNotchSize: CGSize? private init() { registerScreenChangeObservers() @@ -136,6 +137,8 @@ class LockScreenLiveActivityWindowManager { hostingView.rootView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: context.notchSize) } + currentNotchSize = context.notchSize + print("[\(timestamp())] LockScreenLiveActivityWindowManager: realigned window due to \(reason)") } @@ -175,14 +178,18 @@ class LockScreenLiveActivityWindowManager { window.orderFrontRegardless() window.alphaValue = 1 + + currentNotchSize = notchSize } func showLocked() { hideTask?.cancel() guard let context = lockContext() else { return } + let collapsedScale = LockScreenLiveActivityOverlay.collapsedScale(for: context.notchSize) + overlayAnimator.update(isLocked: true) - overlayModel.scale = 0.6 + overlayModel.scale = collapsedScale overlayModel.opacity = 0 present(notchSize: context.notchSize, on: context.screen) @@ -229,9 +236,16 @@ class LockScreenLiveActivityWindowManager { private func hideWithAnimation() { guard let window else { return } + let targetScale: CGFloat + if let notchSize = currentNotchSize { + targetScale = LockScreenLiveActivityOverlay.collapsedScale(for: notchSize) + } else { + targetScale = 0.7 + } + withAnimation(.smooth(duration: LockScreenAnimationTimings.unlockCollapse)) { overlayModel.opacity = 0 - overlayModel.scale = 0.7 + overlayModel.scale = targetScale } NSAnimationContext.runAnimationGroup { context in @@ -242,6 +256,7 @@ class LockScreenLiveActivityWindowManager { DispatchQueue.main.asyncAfter(deadline: .now() + LockScreenAnimationTimings.unlockCollapse + 0.02) { window.orderOut(nil) + self.currentNotchSize = nil } print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden") From 3fd190f24a9de347e05bf73b8ad6ccaf03299278 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 13 Dec 2025 23:29:35 +0530 Subject: [PATCH 3/3] Lock Screen indicator for focus mode --- DynamicIsland/Localizable.xcstrings | 10 ++ .../LockScreen/LockScreenWeatherWidget.swift | 91 +++++++++++++++++-- .../components/Settings/SettingsView.swift | 10 ++ DynamicIsland/models/Constants.swift | 1 + 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index f72d62b..f5b5b24 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -7084,6 +7084,9 @@ }, "Displays play/pause and skip buttons beside the notch while music is active. Disabled by default." : { + }, + "Displays the current Focus state above the weather capsule whenever Focus detection is enabled." : { + }, "Do Not Disturb" : { @@ -8847,9 +8850,16 @@ }, "Fit to Notch" : { + }, + "Focus active: %@" : { + "comment" : "An accessibility label for the focus widget, describing the currently active focus mode.", + "isCommentAutoGenerated" : true }, "Focus Status" : { + }, + "Focus Widget" : { + }, "Free ยท %@" : { "comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.", diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 59af845..edfe261 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -1,8 +1,13 @@ import SwiftUI +import Defaults struct LockScreenWeatherWidget: View { let snapshot: LockScreenWeatherSnapshot + @ObservedObject private var focusManager = DoNotDisturbManager.shared + @Default(.enableDoNotDisturbDetection) private var focusDetectionEnabled + @Default(.showDoNotDisturbIndicator) private var focusIndicatorEnabled + @Default(.enableLockScreenFocusWidget) private var lockScreenFocusWidgetEnabled private let inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded) private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded) @@ -20,6 +25,24 @@ struct LockScreenWeatherWidget: View { } var body: some View { + VStack(alignment: .leading, spacing: focusWidgetSpacing) { + if shouldShowFocusWidget { + focusWidget + } + mainWidgetRow + } + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(Color.white.opacity(0.65)) + .padding(.horizontal, 10) + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + .background(Color.clear) + .shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } + + private var mainWidgetRow: some View { HStack(alignment: stackAlignment, spacing: stackSpacing) { if let charging = snapshot.charging { chargingSegment(for: charging) @@ -43,15 +66,6 @@ struct LockScreenWeatherWidget: View { locationSegment } } - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(Color.white.opacity(0.65)) - .padding(.horizontal, 10) - .padding(.top, topPadding) - .padding(.bottom, bottomPadding) - .background(Color.clear) - .shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) } @ViewBuilder @@ -336,6 +350,61 @@ struct LockScreenWeatherWidget: View { snapshot.showsLocation && (snapshot.locationName?.isEmpty == false) } + private var focusWidgetSpacing: CGFloat { + guard shouldShowFocusWidget else { return 0 } + return isInline ? 14 : 20 + } + + private var shouldShowFocusWidget: Bool { + lockScreenFocusWidgetEnabled && + focusDetectionEnabled && + focusIndicatorEnabled && + focusManager.isDoNotDisturbActive && + !focusDisplayName.isEmpty + } + + private var focusDisplayName: String { + let trimmed = focusManager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + if focusMode == .doNotDisturb { + return "Do Not Disturb" + } + let fallback = focusMode.displayName + return fallback.isEmpty ? "Focus" : fallback + } + + private var focusMode: FocusModeType { + FocusModeType.resolve( + identifier: focusManager.currentFocusModeIdentifier, + name: focusManager.currentFocusModeName + ) + } + + private var focusIcon: Image { + focusMode + .resolvedActiveIcon(usePrivateSymbol: true) + .renderingMode(.template) + } + + private var focusWidget: some View { + HStack(alignment: .center, spacing: 8) { + focusIcon + .font(.system(size: 20, weight: .semibold)) + .frame(width: 26, height: 26) + + Text(focusDisplayName) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + .padding(.horizontal, 2) + .accessibilityLabel("Focus active: \(focusDisplayName)") + } + private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? { let icon = info.iconName return icon.isEmpty ? nil : icon @@ -549,6 +618,10 @@ struct LockScreenWeatherWidget: View { components.append(accessibilityBatteryText(for: battery)) } + if shouldShowFocusWidget { + components.append("Focus active: \(focusDisplayName)") + } + return components.joined(separator: ". ") } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 94d4a5c..c3808a4 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2583,6 +2583,7 @@ struct LockScreenSettings: View { @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget @Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget + @Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget @Default(.lockScreenWeatherWidgetStyle) private var lockScreenWeatherWidgetStyle @Default(.lockScreenWeatherProviderSource) private var lockScreenWeatherProviderSource @Default(.lockScreenWeatherTemperatureUnit) private var lockScreenWeatherTemperatureUnit @@ -2738,6 +2739,15 @@ struct LockScreenSettings: View { Text("Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators.") } + Section { + Defaults.Toggle("Show focus widget", key: .enableLockScreenFocusWidget) + .settingsHighlight(id: highlightID("Show focus widget")) + } header: { + Text("Focus Widget") + } footer: { + Text("Displays the current Focus state above the weather capsule whenever Focus detection is enabled.") + } + LockScreenPositioningControls() Section { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index e696e87..2320c2e 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -452,6 +452,7 @@ extension Defaults.Keys { // Enable lock screen media widget (shows the standalone panel when screen is locked) static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) + static let enableLockScreenFocusWidget = Key("enableLockScreenFocusWidget", default: true) static let enableLockScreenReminderWidget = Key("enableLockScreenReminderWidget", default: true) static let enableLockScreenTimerWidget = Key("enableLockScreenTimerWidget", default: true) static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60)