mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 15:44:32 +00:00
Merge pull request #172 from Ebullioscopic/feature/liveactivity
Focus Mode Enhancement
This commit is contained in:
commit
e48ecbc392
9 changed files with 347 additions and 43 deletions
|
|
@ -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 {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed {
|
||||||
DownloadLiveActivity()
|
DownloadLiveActivity()
|
||||||
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
|
.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()
|
DoNotDisturbLiveActivity()
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
|
||||||
LockScreenLiveActivity()
|
LockScreenLiveActivity()
|
||||||
|
|
|
||||||
|
|
@ -7084,6 +7084,9 @@
|
||||||
},
|
},
|
||||||
"Displays play/pause and skip buttons beside the notch while music is active. Disabled by default." : {
|
"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" : {
|
"Do Not Disturb" : {
|
||||||
|
|
||||||
|
|
@ -8847,9 +8850,16 @@
|
||||||
},
|
},
|
||||||
"Fit to Notch" : {
|
"Fit to Notch" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Focus active: %@" : {
|
||||||
|
"comment" : "An accessibility label for the focus widget, describing the currently active focus mode.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Focus Status" : {
|
"Focus Status" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Focus Widget" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Free · %@" : {
|
"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.",
|
"comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.",
|
||||||
|
|
@ -10731,6 +10741,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" : {
|
"Last Updated" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -17451,6 +17465,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" : {
|
"Show timer name" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -20378,6 +20396,10 @@
|
||||||
},
|
},
|
||||||
"When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls." : {
|
"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." : {
|
"When enabled, stats monitoring stops a few seconds after the notch closes." : {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
// an icon-first layout that collapses gracefully when Focus ends.
|
// an icon-first layout that collapses gracefully when Focus ends.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
import Defaults
|
import Defaults
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
@ -13,6 +14,7 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
@ObservedObject var manager = DoNotDisturbManager.shared
|
@ObservedObject var manager = DoNotDisturbManager.shared
|
||||||
@Default(.showDoNotDisturbLabel) private var showLabelSetting
|
@Default(.showDoNotDisturbLabel) private var showLabelSetting
|
||||||
|
@Default(.focusIndicatorNonPersistent) private var focusToastMode
|
||||||
|
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var showInactiveIcon = false
|
@State private var showInactiveIcon = false
|
||||||
|
|
@ -29,12 +31,12 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.black)
|
.fill(Color.black)
|
||||||
.frame(width: vm.closedNotchSize.width)
|
.frame(width: centerSegmentWidth)
|
||||||
|
|
||||||
labelWing
|
labelWing
|
||||||
.frame(width: labelWingWidth, height: wingHeight)
|
.frame(width: labelWingWidth, height: wingHeight)
|
||||||
}
|
}
|
||||||
.frame(height: vm.effectiveClosedNotchHeight)
|
.frame(width: notchEnvelopeWidth, height: vm.effectiveClosedNotchHeight)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(accessibilityDescription)
|
.accessibilityLabel(accessibilityDescription)
|
||||||
.onAppear(perform: handleInitialState)
|
.onAppear(perform: handleInitialState)
|
||||||
|
|
@ -44,6 +46,30 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
|
|
||||||
// MARK: - Layout helpers
|
// 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 {
|
private var wingHeight: CGFloat {
|
||||||
max(vm.effectiveClosedNotchHeight - 10, 20)
|
max(vm.effectiveClosedNotchHeight - 10, 20)
|
||||||
}
|
}
|
||||||
|
|
@ -53,19 +79,43 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var labelWingWidth: CGFloat {
|
private var labelWingWidth: CGFloat {
|
||||||
if shouldShowLabel {
|
guard shouldShowLabel else {
|
||||||
return max(desiredLabelWidth, minimalWingWidth)
|
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 notchEnvelopeWidth: CGFloat {
|
||||||
|
centerSegmentWidth + iconWingWidth + labelWingWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
private var minimalWingWidth: CGFloat {
|
private var minimalWingWidth: CGFloat {
|
||||||
max(vm.effectiveClosedNotchHeight - 12, 24)
|
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 {
|
private var desiredLabelWidth: CGFloat {
|
||||||
let measuredWidth = labelIntrinsicWidth + 8 // horizontal padding inside the label
|
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)
|
var width = max(measuredWidth, fallbackWidth)
|
||||||
|
|
||||||
if focusMode == .doNotDisturb && shouldShowLabel {
|
if focusMode == .doNotDisturb && shouldShowLabel {
|
||||||
|
|
@ -76,7 +126,7 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowLabel: Bool {
|
private var shouldShowLabel: Bool {
|
||||||
showLabelSetting && isExpanded && !labelText.isEmpty
|
focusToastMode ? (isExpanded && !labelText.isEmpty) : (showLabelSetting && isExpanded && !labelText.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Focus metadata
|
// MARK: - Focus metadata
|
||||||
|
|
@ -93,16 +143,23 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var labelText: String {
|
private var labelText: String {
|
||||||
let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
if focusToastMode {
|
||||||
if !trimmed.isEmpty {
|
return manager.isDoNotDisturbActive ? "On" : "Off"
|
||||||
return trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let fallback = focusMode.displayName
|
let trimmed = manager.currentFocusModeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if focusMode == .doNotDisturb {
|
if showLabelSetting {
|
||||||
return "Do Not Disturb"
|
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 {
|
private var accessibilityDescription: String {
|
||||||
|
|
@ -115,14 +172,45 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
|
|
||||||
private var currentIcon: Image {
|
private var currentIcon: Image {
|
||||||
if manager.isDoNotDisturbActive {
|
if manager.isDoNotDisturbActive {
|
||||||
return focusMode.activeIcon
|
return focusMode.resolvedActiveIcon(usePrivateSymbol: true)
|
||||||
} else if showInactiveIcon {
|
} else if showInactiveIcon {
|
||||||
return Image(systemName: focusMode.inactiveSymbol)
|
return inactiveIconMatchingActiveStyle
|
||||||
} else {
|
} 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 {
|
private var currentIconColor: Color {
|
||||||
if manager.isDoNotDisturbActive {
|
if manager.isDoNotDisturbActive {
|
||||||
return activeAccentColor
|
return activeAccentColor
|
||||||
|
|
@ -156,8 +244,9 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
if shouldShowLabel {
|
if shouldShowLabel {
|
||||||
Text(labelText)
|
Text(labelText)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||||
.foregroundColor(activeAccentColor)
|
.foregroundColor(labelColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
.contentTransition(.opacity)
|
.contentTransition(.opacity)
|
||||||
.background(
|
.background(
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
|
|
@ -174,6 +263,13 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var labelColor: Color {
|
||||||
|
if focusToastMode {
|
||||||
|
return manager.isDoNotDisturbActive ? activeAccentColor : .white
|
||||||
|
}
|
||||||
|
return activeAccentColor
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - State transitions
|
// MARK: - State transitions
|
||||||
|
|
||||||
private func handleInitialState() {
|
private func handleInitialState() {
|
||||||
|
|
@ -181,12 +277,15 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||||
isExpanded = true
|
isExpanded = true
|
||||||
}
|
}
|
||||||
|
if focusToastMode {
|
||||||
|
scheduleTransientCollapse()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) {
|
private func handleFocusStateChange(_ oldValue: Bool, _ isActive: Bool) {
|
||||||
|
cancelPendingTasks()
|
||||||
if isActive {
|
if isActive {
|
||||||
cancelPendingTasks()
|
|
||||||
withAnimation(.smooth(duration: 0.2)) {
|
withAnimation(.smooth(duration: 0.2)) {
|
||||||
showInactiveIcon = false
|
showInactiveIcon = false
|
||||||
}
|
}
|
||||||
|
|
@ -194,37 +293,55 @@ struct DoNotDisturbLiveActivity: View {
|
||||||
iconScale = 1.0
|
iconScale = 1.0
|
||||||
isExpanded = true
|
isExpanded = true
|
||||||
}
|
}
|
||||||
|
if focusToastMode {
|
||||||
|
scheduleTransientCollapse()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
triggerInactiveAnimation()
|
triggerInactiveAnimation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func triggerInactiveAnimation() {
|
private func triggerInactiveAnimation() {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
|
||||||
|
isExpanded = true
|
||||||
|
}
|
||||||
withAnimation(.smooth(duration: 0.2)) {
|
withAnimation(.smooth(duration: 0.2)) {
|
||||||
showInactiveIcon = true
|
showInactiveIcon = true
|
||||||
}
|
}
|
||||||
|
|
||||||
withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) {
|
if focusToastMode {
|
||||||
iconScale = 1.2
|
iconScale = 1.0
|
||||||
}
|
scaleResetTask?.cancel()
|
||||||
|
} else {
|
||||||
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 12)) {
|
||||||
|
iconScale = 1.2
|
||||||
|
}
|
||||||
|
|
||||||
scaleResetTask?.cancel()
|
scaleResetTask?.cancel()
|
||||||
scaleResetTask = Task { @MainActor in
|
scaleResetTask = Task { @MainActor in
|
||||||
try? await Task.sleep(for: .milliseconds(650))
|
try? await Task.sleep(for: .milliseconds(450))
|
||||||
withAnimation(.smooth(duration: 0.2)) {
|
withAnimation(.interpolatingSpring(stiffness: 180, damping: 18)) {
|
||||||
showInactiveIcon = false
|
iconScale = 1.0
|
||||||
|
}
|
||||||
|
withAnimation(.smooth(duration: 0.2)) {
|
||||||
|
showInactiveIcon = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
collapseTask?.cancel()
|
collapseTask?.cancel()
|
||||||
collapseTask = Task { @MainActor in
|
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)) {
|
withAnimation(.smooth(duration: 0.32)) {
|
||||||
isExpanded = false
|
isExpanded = false
|
||||||
|
if focusToastMode {
|
||||||
|
showInactiveIcon = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupTask?.cancel()
|
cleanupTask?.cancel()
|
||||||
|
guard !focusToastMode else { return }
|
||||||
cleanupTask = Task { @MainActor in
|
cleanupTask = Task { @MainActor in
|
||||||
try? await Task.sleep(for: .milliseconds(650))
|
try? await Task.sleep(for: .milliseconds(650))
|
||||||
withAnimation(.smooth(duration: 0.2)) {
|
withAnimation(.smooth(duration: 0.2)) {
|
||||||
|
|
@ -233,6 +350,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() {
|
private func cancelPendingTasks() {
|
||||||
scaleResetTask?.cancel()
|
scaleResetTask?.cancel()
|
||||||
collapseTask?.cancel()
|
collapseTask?.cancel()
|
||||||
|
|
@ -257,3 +387,29 @@ private struct FocusLabelWidthPreferenceKey: PreferenceKey {
|
||||||
value = max(value, nextValue())
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ struct LockScreenLiveActivityOverlay: View {
|
||||||
notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2)
|
notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var collapsedScale: CGFloat {
|
||||||
|
Self.collapsedScale(for: notchSize)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Color.clear
|
Color.clear
|
||||||
|
|
@ -48,7 +52,17 @@ struct LockScreenLiveActivityOverlay: View {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(width: totalWidth, height: notchSize.height)
|
.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)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
|
||||||
struct LockScreenWeatherWidget: View {
|
struct LockScreenWeatherWidget: View {
|
||||||
let snapshot: LockScreenWeatherSnapshot
|
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 inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
|
||||||
private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded)
|
private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded)
|
||||||
|
|
@ -20,6 +25,24 @@ struct LockScreenWeatherWidget: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some 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) {
|
HStack(alignment: stackAlignment, spacing: stackSpacing) {
|
||||||
if let charging = snapshot.charging {
|
if let charging = snapshot.charging {
|
||||||
chargingSegment(for: charging)
|
chargingSegment(for: charging)
|
||||||
|
|
@ -43,15 +66,6 @@ struct LockScreenWeatherWidget: View {
|
||||||
locationSegment
|
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
|
@ViewBuilder
|
||||||
|
|
@ -336,6 +350,61 @@ struct LockScreenWeatherWidget: View {
|
||||||
snapshot.showsLocation && (snapshot.locationName?.isEmpty == false)
|
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? {
|
private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
|
||||||
let icon = info.iconName
|
let icon = info.iconName
|
||||||
return icon.isEmpty ? nil : icon
|
return icon.isEmpty ? nil : icon
|
||||||
|
|
@ -549,6 +618,10 @@ struct LockScreenWeatherWidget: View {
|
||||||
components.append(accessibilityBatteryText(for: battery))
|
components.append(accessibilityBatteryText(for: battery))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldShowFocusWidget {
|
||||||
|
components.append("Focus active: \(focusDisplayName)")
|
||||||
|
}
|
||||||
|
|
||||||
return components.joined(separator: ". ")
|
return components.joined(separator: ". ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2056,6 +2056,7 @@ struct LiveActivitiesSettings: View {
|
||||||
|
|
||||||
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
||||||
@Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection
|
@Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection
|
||||||
|
@Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent
|
||||||
|
|
||||||
private func highlightID(_ title: String) -> String {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.liveActivities.highlightID(for: title)
|
SettingsTab.liveActivities.highlightID(for: title)
|
||||||
|
|
@ -2104,9 +2105,15 @@ struct LiveActivitiesSettings: View {
|
||||||
.settingsHighlight(id: highlightID("Show Focus Indicator"))
|
.settingsHighlight(id: highlightID("Show Focus Indicator"))
|
||||||
|
|
||||||
Defaults.Toggle("Show Focus Label", key: .showDoNotDisturbLabel)
|
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"))
|
.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 {
|
if doNotDisturbManager.isMonitoring {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Focus Status")
|
Text("Focus Status")
|
||||||
|
|
@ -2576,6 +2583,7 @@ struct LockScreenSettings: View {
|
||||||
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
|
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
|
||||||
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
|
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
|
||||||
@Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget
|
@Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget
|
||||||
|
@Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget
|
||||||
@Default(.lockScreenWeatherWidgetStyle) private var lockScreenWeatherWidgetStyle
|
@Default(.lockScreenWeatherWidgetStyle) private var lockScreenWeatherWidgetStyle
|
||||||
@Default(.lockScreenWeatherProviderSource) private var lockScreenWeatherProviderSource
|
@Default(.lockScreenWeatherProviderSource) private var lockScreenWeatherProviderSource
|
||||||
@Default(.lockScreenWeatherTemperatureUnit) private var lockScreenWeatherTemperatureUnit
|
@Default(.lockScreenWeatherTemperatureUnit) private var lockScreenWeatherTemperatureUnit
|
||||||
|
|
@ -2731,6 +2739,15 @@ struct LockScreenSettings: View {
|
||||||
Text("Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators.")
|
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()
|
LockScreenPositioningControls()
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,12 @@ enum FocusModeType: String, CaseIterable {
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeIcon: Image {
|
var activeIcon: Image {
|
||||||
if let internalSymbolName,
|
resolvedActiveIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvedActiveIcon(usePrivateSymbol: Bool = true) -> Image {
|
||||||
|
if usePrivateSymbol,
|
||||||
|
let internalSymbolName,
|
||||||
let image = Image(internalSystemName: internalSymbolName) {
|
let image = Image(internalSystemName: internalSymbolName) {
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class LockScreenLiveActivityWindowManager {
|
||||||
private weak var viewModel: DynamicIslandViewModel?
|
private weak var viewModel: DynamicIslandViewModel?
|
||||||
private var screenChangeObserver: NSObjectProtocol?
|
private var screenChangeObserver: NSObjectProtocol?
|
||||||
private var workspaceObservers: [NSObjectProtocol] = []
|
private var workspaceObservers: [NSObjectProtocol] = []
|
||||||
|
private var currentNotchSize: CGSize?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
registerScreenChangeObservers()
|
registerScreenChangeObservers()
|
||||||
|
|
@ -136,6 +137,8 @@ class LockScreenLiveActivityWindowManager {
|
||||||
hostingView.rootView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: context.notchSize)
|
hostingView.rootView = LockScreenLiveActivityOverlay(model: overlayModel, animator: overlayAnimator, notchSize: context.notchSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentNotchSize = context.notchSize
|
||||||
|
|
||||||
print("[\(timestamp())] LockScreenLiveActivityWindowManager: realigned window due to \(reason)")
|
print("[\(timestamp())] LockScreenLiveActivityWindowManager: realigned window due to \(reason)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,14 +178,18 @@ class LockScreenLiveActivityWindowManager {
|
||||||
|
|
||||||
window.orderFrontRegardless()
|
window.orderFrontRegardless()
|
||||||
window.alphaValue = 1
|
window.alphaValue = 1
|
||||||
|
|
||||||
|
currentNotchSize = notchSize
|
||||||
}
|
}
|
||||||
|
|
||||||
func showLocked() {
|
func showLocked() {
|
||||||
hideTask?.cancel()
|
hideTask?.cancel()
|
||||||
guard let context = lockContext() else { return }
|
guard let context = lockContext() else { return }
|
||||||
|
|
||||||
|
let collapsedScale = LockScreenLiveActivityOverlay.collapsedScale(for: context.notchSize)
|
||||||
|
|
||||||
overlayAnimator.update(isLocked: true)
|
overlayAnimator.update(isLocked: true)
|
||||||
overlayModel.scale = 0.6
|
overlayModel.scale = collapsedScale
|
||||||
overlayModel.opacity = 0
|
overlayModel.opacity = 0
|
||||||
|
|
||||||
present(notchSize: context.notchSize, on: context.screen)
|
present(notchSize: context.notchSize, on: context.screen)
|
||||||
|
|
@ -229,9 +236,16 @@ class LockScreenLiveActivityWindowManager {
|
||||||
private func hideWithAnimation() {
|
private func hideWithAnimation() {
|
||||||
guard let window else { return }
|
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)) {
|
withAnimation(.smooth(duration: LockScreenAnimationTimings.unlockCollapse)) {
|
||||||
overlayModel.opacity = 0
|
overlayModel.opacity = 0
|
||||||
overlayModel.scale = 0.7
|
overlayModel.scale = targetScale
|
||||||
}
|
}
|
||||||
|
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
|
@ -242,6 +256,7 @@ class LockScreenLiveActivityWindowManager {
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + LockScreenAnimationTimings.unlockCollapse + 0.02) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + LockScreenAnimationTimings.unlockCollapse + 0.02) {
|
||||||
window.orderOut(nil)
|
window.orderOut(nil)
|
||||||
|
self.currentNotchSize = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden")
|
print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden")
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,7 @@ extension Defaults.Keys {
|
||||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||||
|
static let enableLockScreenFocusWidget = Key<Bool>("enableLockScreenFocusWidget", default: true)
|
||||||
static let enableLockScreenReminderWidget = Key<Bool>("enableLockScreenReminderWidget", default: true)
|
static let enableLockScreenReminderWidget = Key<Bool>("enableLockScreenReminderWidget", default: true)
|
||||||
static let enableLockScreenTimerWidget = Key<Bool>("enableLockScreenTimerWidget", default: true)
|
static let enableLockScreenTimerWidget = Key<Bool>("enableLockScreenTimerWidget", default: true)
|
||||||
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
||||||
|
|
@ -616,6 +617,7 @@ extension Defaults.Keys {
|
||||||
static let enableDoNotDisturbDetection = Key<Bool>("enableDoNotDisturbDetection", default: true)
|
static let enableDoNotDisturbDetection = Key<Bool>("enableDoNotDisturbDetection", default: true)
|
||||||
static let showDoNotDisturbIndicator = Key<Bool>("showDoNotDisturbIndicator", default: true)
|
static let showDoNotDisturbIndicator = Key<Bool>("showDoNotDisturbIndicator", default: true)
|
||||||
static let showDoNotDisturbLabel = Key<Bool>("showDoNotDisturbLabel", default: true)
|
static let showDoNotDisturbLabel = Key<Bool>("showDoNotDisturbLabel", default: true)
|
||||||
|
static let focusIndicatorNonPersistent = Key<Bool>("focusIndicatorNonPersistent", default: false)
|
||||||
|
|
||||||
// MARK: Privacy Indicators (Camera & Microphone Detection)
|
// MARK: Privacy Indicators (Camera & Microphone Detection)
|
||||||
static let enableCameraDetection = Key<Bool>("enableCameraDetection", default: true)
|
static let enableCameraDetection = Key<Bool>("enableCameraDetection", default: true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue