Update Timer Lock Screen Widget

This commit is contained in:
Hariharan Mudaliar 2025-11-27 14:05:05 +05:30
commit 8be0c6540f
12 changed files with 636 additions and 7 deletions

View 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)
}
}

View file

@ -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,

View file

@ -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]