mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
166 lines
5.8 KiB
Swift
166 lines
5.8 KiB
Swift
//
|
|
// TimerLiveActivity.swift
|
|
// DynamicIsland
|
|
//
|
|
// Created by Ebullioscopic on 2025-01-13.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Defaults
|
|
|
|
struct TimerLiveActivity: View {
|
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
|
@ObservedObject var timerManager = TimerManager.shared
|
|
@State private var isHovering: Bool = false
|
|
@Default(.timerShowsCountdown) private var showsCountdown
|
|
@Default(.timerShowsProgress) private var showsProgress
|
|
@Default(.timerProgressStyle) private var progressStyle
|
|
@Default(.timerIconColorMode) private var colorMode
|
|
@Default(.timerSolidColor) private var solidColor
|
|
@Default(.timerPresets) private var timerPresets
|
|
|
|
private var iconAreaWidth: CGFloat {
|
|
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
|
|
}
|
|
|
|
private var countdownWidth: CGFloat {
|
|
guard showsCountdown else { return 0 }
|
|
let characters = max(timerManager.formattedRemainingTime().count, 4)
|
|
let base = CGFloat(characters) * 9.5 + 24
|
|
return max(base, 72)
|
|
}
|
|
|
|
private var clampedProgress: Double {
|
|
min(max(timerManager.progress, 0), 1)
|
|
}
|
|
|
|
private var glyphColor: Color {
|
|
switch colorMode {
|
|
case .adaptive:
|
|
return activePresetColor ?? timerManager.timerColor
|
|
case .solid:
|
|
return solidColor
|
|
}
|
|
}
|
|
|
|
private var showsRingProgress: Bool {
|
|
showsProgress && progressStyle == .ring
|
|
}
|
|
|
|
private var showsBarProgress: Bool {
|
|
showsProgress && progressStyle == .bar
|
|
}
|
|
|
|
private var activePresetColor: Color? {
|
|
guard let presetId = timerManager.activePresetId else { return nil }
|
|
return timerPresets.first { $0.id == presetId }?.color
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
iconSection
|
|
infoSection
|
|
if showsRingProgress {
|
|
ringSection
|
|
}
|
|
if showsCountdown {
|
|
countdownSection
|
|
}
|
|
}
|
|
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
|
.contentShape(Rectangle())
|
|
.onHover { hovering in
|
|
withAnimation(.smooth(duration: 0.18)) {
|
|
isHovering = hovering
|
|
}
|
|
}
|
|
}
|
|
|
|
private var iconSection: some View {
|
|
let iconSize = max(20, iconAreaWidth - 4)
|
|
return Image(systemName: "timer")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(glyphColor)
|
|
.frame(width: iconSize, height: iconSize)
|
|
.frame(width: iconAreaWidth, height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
|
}
|
|
|
|
private var infoSection: some View {
|
|
Rectangle()
|
|
.fill(.black)
|
|
.overlay(
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if timerManager.isFinished || timerManager.isOvertime {
|
|
GeometryReader { geo in
|
|
MarqueeText(
|
|
.constant(timerManager.timerName),
|
|
textColor: .white,
|
|
minDuration: 0.25,
|
|
frameWidth: geo.size.width - 12
|
|
)
|
|
}
|
|
.frame(height: 16)
|
|
} else {
|
|
Text(timerManager.timerName)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.lineLimit(1)
|
|
.foregroundStyle(.white)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.animation(.smooth, value: timerManager.isFinished)
|
|
)
|
|
.frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0))
|
|
}
|
|
|
|
private var ringSection: some View {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color.white.opacity(0.15), lineWidth: 3)
|
|
Circle()
|
|
.trim(from: 0, to: clampedProgress)
|
|
.stroke(glyphColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
|
}
|
|
.frame(width: 26, height: 26)
|
|
}
|
|
|
|
private var countdownSection: some View {
|
|
VStack(spacing: 4) {
|
|
Text(timerManager.formattedRemainingTime())
|
|
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
.foregroundColor(timerManager.isOvertime ? .red : .white)
|
|
.contentTransition(.numericText())
|
|
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
|
|
if showsBarProgress {
|
|
Capsule()
|
|
.fill(Color.white.opacity(0.12))
|
|
.frame(height: 3)
|
|
.overlay(alignment: .leading) {
|
|
Capsule()
|
|
.fill(glyphColor)
|
|
.frame(width: max(0, countdownWidth - 20) * max(0, CGFloat(clampedProgress)))
|
|
.animation(.smooth(duration: 0.25), value: clampedProgress)
|
|
}
|
|
}
|
|
}
|
|
.padding(.trailing, 8)
|
|
.frame(width: countdownWidth,
|
|
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
TimerLiveActivity()
|
|
.environmentObject(DynamicIslandViewModel())
|
|
.frame(width: 300, height: 32)
|
|
.background(.black)
|
|
.onAppear {
|
|
TimerManager.shared.startDemoTimer(duration: 300)
|
|
}
|
|
}
|