Atoll/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift
2025-11-28 20:04:51 +05:30

257 lines
8.2 KiB
Swift

import SwiftUI
import Defaults
struct LockScreenTimerWidget: View {
static let preferredSize = CGSize(width: 420, height: 96)
static let cornerRadius: CGFloat = 26
@ObservedObject private var animator: LockScreenTimerWidgetAnimator
@ObservedObject private var timerManager = TimerManager.shared
@Default(.lockScreenGlassStyle) private var glassStyle
@Default(.lockScreenTimerWidgetUsesBlur) private var enableTimerBlur
@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 countdownFont: Font { displayFont(size: 56) }
private func displayFont(size: CGFloat) -> Font {
.custom("SF Pro Display", size: size)
}
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 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 enableTimerBlur {
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: 18) {
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
}
.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: Color.white.opacity(0.95),
background: accentColor.opacity(0.32),
action: togglePause,
isEnabled: timerManager.allowsManualInteraction,
helpText: pauseLabel
)
CircleButton(
icon: "xmark",
foreground: Color.white.opacity(0.95),
background: Color.black.opacity(0.35),
action: stopTimer,
isEnabled: timerManager.allowsManualInteraction,
helpText: "Stop"
)
}
}
private var countdownSection: some View {
VStack(alignment: .trailing, spacing: 0) {
Text(countdownText)
.font(countdownFont)
.monospacedDigit()
.foregroundStyle(timerManager.isOvertime ? Color.red : accentColor)
.contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(width: 220, alignment: .center)
.padding(.trailing, 2)
}
private var titleSection: some View {
VStack(alignment: .center, spacing: 0) {
MarqueeText(
.constant(timerLabel),
font: displayFont(size: 18),
nsFont: .title3,
textColor: accentColor,
minDuration: 0.18,
frameWidth: Self.preferredSize.width * 0.35
)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
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() {
let allowsManualInteraction = timerManager.allowsManualInteraction
LockScreenTimerWidgetPanelManager.shared.hide()
Task.detached(priority: .userInitiated) { [allowsManualInteraction] in
try? await Task.sleep(nanoseconds: LockScreenTimerWidgetPanelManager.hideAnimationDurationNanoseconds)
guard !Task.isCancelled else { return }
await MainActor.run {
if allowsManualInteraction {
TimerManager.shared.stopTimer()
} else {
TimerManager.shared.endExternalTimer(triggerSmoothClose: false)
}
}
}
}
private struct CircleButton: View {
let icon: String
let foreground: Color
let background: 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())
.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)
}
}