mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Update Timer Lock Screen Widget
This commit is contained in:
parent
6d6ffff5b0
commit
8be0c6540f
12 changed files with 636 additions and 7 deletions
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue