mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #128 from Ebullioscopic/feature/timer
Timer Lock Screen Widget
This commit is contained in:
commit
231ee29123
12 changed files with 636 additions and 7 deletions
|
|
@ -618,6 +618,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
await LockScreenWeatherManager.shared.refresh(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Warm up the lock screen timer widget manager so it can observe timer/default
|
||||
// changes immediately instead of waiting for the first lock event.
|
||||
let timerWidgetManager = LockScreenTimerWidgetManager.shared
|
||||
timerWidgetManager.handleLockStateChange(isLocked: LockScreenManager.shared.currentLockStatus)
|
||||
}
|
||||
|
||||
func playWelcomeSound() {
|
||||
|
|
|
|||
|
|
@ -1357,6 +1357,10 @@
|
|||
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"00:05:00" : {
|
||||
"comment" : "A time displayed in the timer panel on a lock screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"1x" : {
|
||||
"localizations" : {
|
||||
"fr" : {
|
||||
|
|
|
|||
269
DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift
Normal file
269
DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct LockScreenTimerWidget: View {
|
||||
static let preferredSize = CGSize(width: 420, height: 104)
|
||||
static let cornerRadius: CGFloat = 26
|
||||
|
||||
@ObservedObject private var animator: LockScreenTimerWidgetAnimator
|
||||
@ObservedObject private var timerManager = TimerManager.shared
|
||||
@Default(.lockScreenGlassStyle) private var glassStyle
|
||||
@Default(.lockScreenPanelUsesBlur) private var enableBlur
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
|
||||
@MainActor
|
||||
init(animator: LockScreenTimerWidgetAnimator? = nil) {
|
||||
if let animator {
|
||||
_animator = ObservedObject(wrappedValue: animator)
|
||||
} else {
|
||||
_animator = ObservedObject(wrappedValue: LockScreenTimerWidgetAnimator(isPresented: true))
|
||||
}
|
||||
}
|
||||
|
||||
private var clampedProgress: Double {
|
||||
min(max(timerManager.progress, 0), 1)
|
||||
}
|
||||
|
||||
private var titleFont: Font { .system(size: 18, weight: .semibold, design: .rounded) }
|
||||
private var statusFont: Font { .system(size: 12, weight: .medium, design: .rounded) }
|
||||
private var countdownFont: Font { .system(size: 42, weight: .semibold, design: .default) }
|
||||
|
||||
private var timerLabel: String {
|
||||
timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName
|
||||
}
|
||||
|
||||
private var countdownText: String {
|
||||
timerManager.formattedRemainingTime()
|
||||
}
|
||||
|
||||
private var activePresetColor: Color? {
|
||||
guard let presetId = timerManager.activePresetId else { return nil }
|
||||
return timerPresets.first { $0.id == presetId }?.color
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
(activePresetColor ?? timerManager.timerColor)
|
||||
.ensureMinimumBrightness(factor: 0.75)
|
||||
}
|
||||
|
||||
private var secondaryAccentColor: Color {
|
||||
accentColor.ensureMinimumBrightness(factor: 0.55).opacity(0.45)
|
||||
}
|
||||
|
||||
private var accentGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
accentColor.opacity(0.35),
|
||||
accentColor.ensureMinimumBrightness(factor: 0.5)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
private var usesLiquidGlass: Bool {
|
||||
if #available(macOS 26.0, *) {
|
||||
return glassStyle == .liquid
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var widgetBackground: some View {
|
||||
if enableBlur {
|
||||
if usesLiquidGlass {
|
||||
liquidBackground
|
||||
} else {
|
||||
frostedBackground
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
|
||||
.fill(Color.black.opacity(0.65))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var liquidBackground: some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
|
||||
.glassEffect(
|
||||
.clear.tint(accentColor.opacity(0.18)).interactive(),
|
||||
in: .rect(cornerRadius: Self.cornerRadius)
|
||||
)
|
||||
} else {
|
||||
frostedBackground
|
||||
}
|
||||
}
|
||||
|
||||
private var frostedBackground: some View {
|
||||
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
private var pauseIcon: String {
|
||||
timerManager.isPaused ? "play.fill" : "pause.fill"
|
||||
}
|
||||
|
||||
private var pauseLabel: String {
|
||||
timerManager.isPaused ? "Resume" : "Pause"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
controlButtons
|
||||
|
||||
titleSection
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
countdownSection
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.frame(width: Self.preferredSize.width, height: Self.preferredSize.height)
|
||||
.background(widgetBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.25), radius: 18, x: 0, y: 16)
|
||||
.overlay(alignment: .topLeading) {
|
||||
accentRibbon
|
||||
}
|
||||
.animation(.smooth(duration: 0.22), value: timerManager.isPaused)
|
||||
.animation(.smooth(duration: 0.22), value: timerManager.remainingTime)
|
||||
.scaleEffect(animator.isPresented ? 1 : 0.9)
|
||||
.opacity(animator.isPresented ? 1 : 0)
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.22), value: animator.isPresented)
|
||||
}
|
||||
|
||||
private var controlButtons: some View {
|
||||
HStack(spacing: 10) {
|
||||
CircleButton(
|
||||
icon: pauseIcon,
|
||||
foreground: accentColor,
|
||||
background: Color.white.opacity(0.15),
|
||||
borderColor: accentColor.opacity(0.8),
|
||||
action: togglePause,
|
||||
isEnabled: timerManager.allowsManualInteraction,
|
||||
helpText: pauseLabel
|
||||
)
|
||||
|
||||
CircleButton(
|
||||
icon: "xmark",
|
||||
foreground: Color.white.opacity(0.92),
|
||||
background: Color.white.opacity(0.18),
|
||||
borderColor: Color.white.opacity(0.35),
|
||||
action: stopTimer,
|
||||
isEnabled: timerManager.allowsManualInteraction,
|
||||
helpText: "Stop"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var titleSection: some View {
|
||||
Group {
|
||||
if timerManager.timerName.count > 18 {
|
||||
MarqueeText(
|
||||
.constant(timerLabel),
|
||||
font: .system(size: 18, weight: .semibold, design: .rounded),
|
||||
nsFont: .title3,
|
||||
textColor: accentColor,
|
||||
minDuration: 0.18,
|
||||
frameWidth: Self.preferredSize.width * 0.4
|
||||
)
|
||||
} else {
|
||||
Text(timerLabel)
|
||||
.font(titleFont)
|
||||
.foregroundStyle(accentColor)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
private var countdownSection: some View {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
Text(countdownText)
|
||||
.font(countdownFont)
|
||||
.foregroundStyle(timerManager.isOvertime ? Color.red : accentColor)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(minWidth: 140, maxWidth: 180, alignment: .trailing)
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
|
||||
private var accentRibbon: some View {
|
||||
Capsule()
|
||||
.fill(accentGradient)
|
||||
.frame(width: 110, height: 26)
|
||||
.blur(radius: 12)
|
||||
.offset(x: 18, y: -6)
|
||||
.opacity(0.45)
|
||||
}
|
||||
|
||||
private func togglePause() {
|
||||
guard timerManager.allowsManualInteraction else { return }
|
||||
if timerManager.isPaused {
|
||||
timerManager.resumeTimer()
|
||||
} else {
|
||||
timerManager.pauseTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
guard timerManager.allowsManualInteraction else {
|
||||
timerManager.endExternalTimer(triggerSmoothClose: false)
|
||||
return
|
||||
}
|
||||
timerManager.stopTimer()
|
||||
}
|
||||
|
||||
private struct CircleButton: View {
|
||||
let icon: String
|
||||
let foreground: Color
|
||||
let background: Color
|
||||
let borderColor: Color?
|
||||
let action: () -> Void
|
||||
let isEnabled: Bool
|
||||
let helpText: String
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(foreground)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(background.opacity(isEnabled ? 1 : 0.25))
|
||||
.clipShape(Circle())
|
||||
.overlay {
|
||||
if let borderColor {
|
||||
Circle()
|
||||
.stroke(borderColor.opacity(isEnabled ? 1 : 0.35), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(helpText)
|
||||
.disabled(!isEnabled)
|
||||
.opacity(isEnabled ? 1 : 0.4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LockScreenTimerWidget()
|
||||
.frame(width: LockScreenTimerWidget.preferredSize.width, height: LockScreenTimerWidget.preferredSize.height)
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
.onAppear {
|
||||
TimerManager.shared.startDemoTimer(duration: 1783)
|
||||
}
|
||||
}
|
||||
|
|
@ -379,6 +379,7 @@ struct GeneralSettings: View {
|
|||
Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur)
|
||||
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
||||
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
|
||||
Defaults.Toggle("Show lock screen timer", key: .enableLockScreenTimerWidget)
|
||||
if enableLockScreenWeatherWidget {
|
||||
Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) {
|
||||
ForEach(LockScreenWeatherWidgetStyle.allCases) { style in
|
||||
|
|
@ -1723,6 +1724,7 @@ struct Appearance: View {
|
|||
private struct LockScreenPositioningControls: View {
|
||||
@Default(.lockScreenWeatherVerticalOffset) private var weatherOffset
|
||||
@Default(.lockScreenMusicVerticalOffset) private var musicOffset
|
||||
@Default(.lockScreenTimerVerticalOffset) private var timerOffset
|
||||
private let offsetRange: ClosedRange<Double> = -160...160
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -1738,6 +1740,17 @@ private struct LockScreenPositioningControls: View {
|
|||
}
|
||||
)
|
||||
|
||||
let timerBinding = Binding<Double>(
|
||||
get: { timerOffset },
|
||||
set: { newValue in
|
||||
let clampedValue = clamp(newValue)
|
||||
if timerOffset != clampedValue {
|
||||
timerOffset = clampedValue
|
||||
}
|
||||
propagateTimerOffsetChange(animated: false)
|
||||
}
|
||||
)
|
||||
|
||||
let musicBinding = Binding<Double>(
|
||||
get: { musicOffset },
|
||||
set: { newValue in
|
||||
|
|
@ -1749,7 +1762,7 @@ private struct LockScreenPositioningControls: View {
|
|||
}
|
||||
)
|
||||
|
||||
LockScreenPositioningPreview(weatherOffset: weatherBinding, musicOffset: musicBinding)
|
||||
LockScreenPositioningPreview(weatherOffset: weatherBinding, timerOffset: timerBinding, musicOffset: musicBinding)
|
||||
.frame(height: 260)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
|
|
@ -1761,6 +1774,16 @@ private struct LockScreenPositioningControls: View {
|
|||
resetAction: resetWeatherOffset
|
||||
)
|
||||
|
||||
Divider()
|
||||
.frame(height: 64)
|
||||
|
||||
offsetColumn(
|
||||
title: "Timer",
|
||||
value: timerOffset,
|
||||
resetTitle: "Reset Timer",
|
||||
resetAction: resetTimerOffset
|
||||
)
|
||||
|
||||
Divider()
|
||||
.frame(height: 64)
|
||||
|
||||
|
|
@ -1790,6 +1813,11 @@ private struct LockScreenPositioningControls: View {
|
|||
propagateWeatherOffsetChange(animated: true)
|
||||
}
|
||||
|
||||
private func resetTimerOffset() {
|
||||
timerOffset = 0
|
||||
propagateTimerOffsetChange(animated: true)
|
||||
}
|
||||
|
||||
private func resetMusicOffset() {
|
||||
musicOffset = 0
|
||||
propagateMusicOffsetChange(animated: true)
|
||||
|
|
@ -1801,6 +1829,12 @@ private struct LockScreenPositioningControls: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func propagateTimerOffsetChange(animated: Bool) {
|
||||
Task { @MainActor in
|
||||
LockScreenTimerWidgetManager.shared.refreshPositionForOffsets(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
private func propagateMusicOffsetChange(animated: Bool) {
|
||||
Task { @MainActor in
|
||||
LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated)
|
||||
|
|
@ -1831,11 +1865,14 @@ private struct LockScreenPositioningControls: View {
|
|||
|
||||
private struct LockScreenPositioningPreview: View {
|
||||
@Binding var weatherOffset: Double
|
||||
@Binding var timerOffset: Double
|
||||
@Binding var musicOffset: Double
|
||||
|
||||
@State private var weatherStartOffset: Double = 0
|
||||
@State private var timerStartOffset: Double = 0
|
||||
@State private var musicStartOffset: Double = 0
|
||||
@State private var isWeatherDragging = false
|
||||
@State private var isTimerDragging = false
|
||||
@State private var isMusicDragging = false
|
||||
|
||||
private let offsetRange: ClosedRange<Double> = -160...160
|
||||
|
|
@ -1852,8 +1889,10 @@ private struct LockScreenPositioningPreview: View {
|
|||
)
|
||||
let centerX = screenRect.midX
|
||||
let weatherBaseY = screenRect.minY + (screenRect.height * 0.28)
|
||||
let musicBaseY = screenRect.minY + (screenRect.height * 0.68)
|
||||
let timerBaseY = screenRect.minY + (screenRect.height * 0.5)
|
||||
let musicBaseY = screenRect.minY + (screenRect.height * 0.78)
|
||||
let weatherSize = CGSize(width: screenRect.width * 0.42, height: screenRect.height * 0.22)
|
||||
let timerSize = CGSize(width: screenRect.width * 0.5, height: screenRect.height * 0.2)
|
||||
let musicSize = CGSize(width: screenRect.width * 0.56, height: screenRect.height * 0.34)
|
||||
|
||||
ZStack {
|
||||
|
|
@ -1871,6 +1910,10 @@ private struct LockScreenPositioningPreview: View {
|
|||
.position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset))
|
||||
.gesture(weatherDragGesture(in: screenRect, baseY: weatherBaseY, panelSize: weatherSize))
|
||||
|
||||
timerPanel(size: timerSize)
|
||||
.position(x: centerX, y: timerBaseY - CGFloat(timerOffset))
|
||||
.gesture(timerDragGesture(in: screenRect, baseY: timerBaseY, panelSize: timerSize))
|
||||
|
||||
musicPanel(size: musicSize)
|
||||
.position(x: centerX, y: musicBaseY - CGFloat(musicOffset))
|
||||
.gesture(musicDragGesture(in: screenRect, baseY: musicBaseY, panelSize: musicSize))
|
||||
|
|
@ -1928,6 +1971,29 @@ private struct LockScreenPositioningPreview: View {
|
|||
.shadow(color: Color.purple.opacity(0.24), radius: 12, x: 0, y: 9)
|
||||
}
|
||||
|
||||
private func timerPanel(size: CGSize) -> some View {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.orange.opacity(0.75), Color.purple.opacity(0.55)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.overlay {
|
||||
VStack(spacing: 6) {
|
||||
Text("Timer")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("00:05:00")
|
||||
.font(.system(size: 18, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.shadow(color: Color.orange.opacity(0.3), radius: 12, x: 0, y: 8)
|
||||
}
|
||||
|
||||
private func weatherDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
|
|
@ -1970,6 +2036,27 @@ private struct LockScreenPositioningPreview: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func timerDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if !isTimerDragging {
|
||||
isTimerDragging = true
|
||||
timerStartOffset = timerOffset
|
||||
}
|
||||
|
||||
let proposed = timerStartOffset - Double(value.translation.height)
|
||||
timerOffset = clampedOffset(
|
||||
proposed,
|
||||
baseCenterY: baseY,
|
||||
panelHeight: panelSize.height,
|
||||
screenRect: screenRect
|
||||
)
|
||||
}
|
||||
.onEnded { _ in
|
||||
isTimerDragging = false
|
||||
}
|
||||
}
|
||||
|
||||
private func clampedOffset(
|
||||
_ proposed: Double,
|
||||
baseCenterY: CGFloat,
|
||||
|
|
|
|||
|
|
@ -140,9 +140,18 @@ struct TimerLiveActivity: View {
|
|||
return timerPresets.first { $0.id == presetId }?.color
|
||||
}
|
||||
|
||||
private var shouldShowControlWindow: Bool {
|
||||
controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive && !timerManager.isExternalTimerActive
|
||||
}
|
||||
private var shouldShowControlWindow: Bool {
|
||||
#if os(macOS)
|
||||
let lockSuppressed = !LockScreenManager.shared.currentLockStatus
|
||||
#else
|
||||
let lockSuppressed = true
|
||||
#endif
|
||||
return controlWindowEnabled
|
||||
&& !shouldDisplayLabel
|
||||
&& timerManager.isTimerActive
|
||||
&& !timerManager.isExternalTimerActive
|
||||
&& lockSuppressed
|
||||
}
|
||||
|
||||
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ class LockScreenManager: ObservableObject {
|
|||
LockScreenLiveActivityWindowManager.shared.showLocked()
|
||||
LockScreenWeatherManager.shared.showWeatherWidget()
|
||||
LockScreenReminderWidgetManager.shared.showReminderWidget()
|
||||
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: true)
|
||||
TimerControlWindowManager.shared.hide(animated: false)
|
||||
|
||||
// THEN trigger lock icon in Dynamic Island (only if enabled in settings)
|
||||
if Defaults[.enableLockScreenLiveActivity] {
|
||||
|
|
@ -130,6 +132,7 @@ class LockScreenManager: ObservableObject {
|
|||
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
|
||||
LockScreenWeatherManager.shared.hideWeatherWidget()
|
||||
LockScreenReminderWidgetManager.shared.hideReminderWidget()
|
||||
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false)
|
||||
|
||||
// Update state immediately
|
||||
if Defaults[.enableLockScreenLiveActivity] {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ class LockScreenPanelManager {
|
|||
latestFrame = targetFrame
|
||||
hideTask?.cancel()
|
||||
panelAnimator.isPresented = false
|
||||
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false)
|
||||
|
||||
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
|
||||
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
|
||||
|
|
@ -144,6 +145,8 @@ class LockScreenPanelManager {
|
|||
|
||||
latestFrame = targetFrame
|
||||
|
||||
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
|
||||
|
||||
// Update corner radius to match the SwiftUI panel's style
|
||||
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
||||
if animated {
|
||||
|
|
@ -167,6 +170,7 @@ class LockScreenPanelManager {
|
|||
|
||||
guard panelWindow != nil else { return }
|
||||
updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: animated)
|
||||
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
|
||||
}
|
||||
|
||||
func hidePanel() {
|
||||
|
|
@ -197,9 +201,10 @@ class LockScreenPanelManager {
|
|||
let collapsedSize = LockScreenMusicPanel.collapsedSize
|
||||
let originX = screenFrame.midX - (collapsedSize.width / 2)
|
||||
let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32
|
||||
let defaultLowering: CGFloat = -28
|
||||
let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset])
|
||||
let clampedOffset = min(max(userOffset, -160), 160)
|
||||
let originY = baseOriginY + clampedOffset
|
||||
let originY = baseOriginY + defaultLowering + clampedOffset
|
||||
return NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
240
DynamicIsland/managers/LockScreenTimerWidgetManager.swift
Normal file
240
DynamicIsland/managers/LockScreenTimerWidgetManager.swift
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SkyLightWindow
|
||||
import QuartzCore
|
||||
|
||||
@MainActor
|
||||
final class LockScreenTimerWidgetAnimator: ObservableObject {
|
||||
@Published var isPresented: Bool
|
||||
|
||||
init(isPresented: Bool = false) {
|
||||
self.isPresented = isPresented
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LockScreenTimerWidgetManager {
|
||||
static let shared = LockScreenTimerWidgetManager()
|
||||
|
||||
private let timerManager = TimerManager.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var isLocked: Bool = false
|
||||
|
||||
private init() {
|
||||
observeTimerState()
|
||||
observeDefaults()
|
||||
}
|
||||
|
||||
func handleLockStateChange(isLocked: Bool) {
|
||||
self.isLocked = isLocked
|
||||
if isLocked {
|
||||
updateVisibility()
|
||||
} else {
|
||||
LockScreenTimerWidgetPanelManager.shared.hide()
|
||||
}
|
||||
}
|
||||
|
||||
func refreshPositionForOffsets(animated: Bool) {
|
||||
LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: animated)
|
||||
}
|
||||
|
||||
func notifyMusicPanelFrameChanged(animated: Bool) {
|
||||
LockScreenTimerWidgetPanelManager.shared.refreshRelativeToMusicPanel(animated: animated)
|
||||
}
|
||||
|
||||
private func observeTimerState() {
|
||||
timerManager.$isTimerActive
|
||||
.combineLatest(timerManager.$activeSource)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _, _ in
|
||||
self?.updateVisibility()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func observeDefaults() {
|
||||
Defaults.publisher(.enableLockScreenTimerWidget, options: [])
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] change in
|
||||
guard let self else { return }
|
||||
if change.newValue {
|
||||
self.updateVisibility()
|
||||
} else {
|
||||
LockScreenTimerWidgetPanelManager.shared.hide()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.lockScreenTimerVerticalOffset, options: [])
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.refreshPositionForOffsets(animated: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateVisibility() {
|
||||
guard shouldDisplayWidget() else {
|
||||
LockScreenTimerWidgetPanelManager.shared.hide()
|
||||
return
|
||||
}
|
||||
LockScreenTimerWidgetPanelManager.shared.showWidget()
|
||||
}
|
||||
|
||||
private func shouldDisplayWidget() -> Bool {
|
||||
guard Defaults[.enableLockScreenTimerWidget] else { return false }
|
||||
guard isLocked else { return false }
|
||||
return timerManager.hasManualTimerRunning
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LockScreenTimerWidgetPanelManager {
|
||||
static let shared = LockScreenTimerWidgetPanelManager()
|
||||
|
||||
private var window: NSWindow?
|
||||
private var hasDelegated = false
|
||||
private let animator = LockScreenTimerWidgetAnimator()
|
||||
private var hideTask: Task<Void, Never>?
|
||||
|
||||
private init() {}
|
||||
|
||||
func showWidget() {
|
||||
guard let screen = NSScreen.main else { return }
|
||||
let window = ensureWindow()
|
||||
let frame = targetFrame(on: screen)
|
||||
window.setFrame(frame, display: true)
|
||||
window.alphaValue = 1
|
||||
window.orderFrontRegardless()
|
||||
hideTask?.cancel()
|
||||
hideTask = nil
|
||||
animator.isPresented = true
|
||||
}
|
||||
|
||||
func hide(animated: Bool = true) {
|
||||
guard let window else { return }
|
||||
hideTask?.cancel()
|
||||
animator.isPresented = false
|
||||
|
||||
let delay: UInt64 = animated ? 360_000_000 : 0
|
||||
if delay == 0 {
|
||||
window.orderOut(nil)
|
||||
hideTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
hideTask = Task { [weak window, weak self] in
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
await MainActor.run {
|
||||
window?.orderOut(nil)
|
||||
self?.hideTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshPosition(animated: Bool) {
|
||||
guard let window, window.isVisible, let screen = NSScreen.main else { return }
|
||||
let frame = targetFrame(on: screen)
|
||||
if animated {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.22
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrame(frame, display: true)
|
||||
}
|
||||
} else {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshRelativeToMusicPanel(animated: Bool) {
|
||||
guard window?.isVisible == true else { return }
|
||||
refreshPosition(animated: animated)
|
||||
}
|
||||
|
||||
private func ensureWindow() -> NSWindow {
|
||||
if let window {
|
||||
if window.contentView == nil {
|
||||
window.contentView = hostingView()
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
let frame = NSRect(origin: .zero, size: LockScreenTimerWidget.preferredSize)
|
||||
let newWindow = NSWindow(
|
||||
contentRect: frame,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
newWindow.isReleasedWhenClosed = false
|
||||
newWindow.isOpaque = false
|
||||
newWindow.backgroundColor = .clear
|
||||
newWindow.hasShadow = false
|
||||
newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
|
||||
newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||
newWindow.ignoresMouseEvents = false
|
||||
newWindow.isMovable = false
|
||||
newWindow.contentView = hostingView()
|
||||
|
||||
ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface)
|
||||
|
||||
window = newWindow
|
||||
|
||||
if !hasDelegated {
|
||||
SkyLightOperator.shared.delegateWindow(newWindow)
|
||||
hasDelegated = true
|
||||
}
|
||||
|
||||
return newWindow
|
||||
}
|
||||
|
||||
private func hostingView() -> NSHostingView<LockScreenTimerWidget> {
|
||||
let view = LockScreenTimerWidget(animator: animator)
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
hosting.frame = NSRect(origin: .zero, size: LockScreenTimerWidget.preferredSize)
|
||||
hosting.wantsLayer = true
|
||||
hosting.layer?.masksToBounds = true
|
||||
hosting.layer?.cornerRadius = LockScreenTimerWidget.cornerRadius
|
||||
return hosting
|
||||
}
|
||||
|
||||
private func targetFrame(on screen: NSScreen) -> NSRect {
|
||||
let size = LockScreenTimerWidget.preferredSize
|
||||
let originX = screen.frame.midX - (size.width / 2)
|
||||
let defaultLowering: CGFloat = -18
|
||||
var baseY = screen.frame.midY + 24 + defaultLowering
|
||||
|
||||
if let musicFrame = LockScreenPanelManager.shared.latestFrame {
|
||||
baseY = musicFrame.maxY + 28 + defaultLowering
|
||||
}
|
||||
|
||||
let offset = CGFloat(clampedTimerOffset())
|
||||
var originY = baseY + offset
|
||||
|
||||
if let musicFrame = LockScreenPanelManager.shared.latestFrame {
|
||||
originY = max(originY, musicFrame.maxY + 12)
|
||||
}
|
||||
|
||||
if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame {
|
||||
originY = min(originY, weatherFrame.minY - size.height - 20)
|
||||
} else {
|
||||
let topLimit = screen.frame.maxY - size.height - 72
|
||||
originY = min(originY, topLimit)
|
||||
}
|
||||
|
||||
let minY = screen.frame.minY + 100
|
||||
originY = max(originY, minY)
|
||||
|
||||
return NSRect(x: originX, y: originY, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
private func clampedTimerOffset() -> Double {
|
||||
let raw = Defaults[.lockScreenTimerVerticalOffset]
|
||||
return min(max(raw, -160), 160)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,10 @@ final class TimerControlWindowManager {
|
|||
|
||||
@discardableResult
|
||||
func present(using viewModel: DynamicIslandViewModel, metrics: TimerControlWindowMetrics) -> Bool {
|
||||
guard !LockScreenManager.shared.currentLockStatus else {
|
||||
hide(animated: false)
|
||||
return false
|
||||
}
|
||||
guard let screen = resolveScreen(from: viewModel) else { return false }
|
||||
guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else {
|
||||
hide()
|
||||
|
|
|
|||
|
|
@ -395,7 +395,8 @@ class TimerManager: ObservableObject {
|
|||
|
||||
// Fall back to default sound if no custom sound or custom sound doesn't exist
|
||||
if soundURL == nil {
|
||||
soundURL = Bundle.main.url(forResource: "dynamic", withExtension: "m4a")
|
||||
soundURL = Bundle.main.url(forResource: "timer", withExtension: "mp3")
|
||||
?? Bundle.main.url(forResource: "dynamic", withExtension: "m4a")
|
||||
}
|
||||
|
||||
guard let finalSoundURL = soundURL else {
|
||||
|
|
|
|||
|
|
@ -452,6 +452,7 @@ extension Defaults.Keys {
|
|||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||
static let enableLockScreenReminderWidget = Key<Bool>("enableLockScreenReminderWidget", default: true)
|
||||
static let enableLockScreenTimerWidget = Key<Bool>("enableLockScreenTimerWidget", default: true)
|
||||
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
||||
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
|
||||
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
|
||||
|
|
@ -467,6 +468,7 @@ extension Defaults.Keys {
|
|||
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
|
||||
static let lockScreenWeatherVerticalOffset = Key<Double>("lockScreenWeatherVerticalOffset", default: 0)
|
||||
static let lockScreenMusicVerticalOffset = Key<Double>("lockScreenMusicVerticalOffset", default: 0)
|
||||
static let lockScreenTimerVerticalOffset = Key<Double>("lockScreenTimerVerticalOffset", default: 0)
|
||||
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
|
||||
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
||||
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
|
||||
|
|
|
|||
BIN
DynamicIsland/timer.mp3
Normal file
BIN
DynamicIsland/timer.mp3
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue