Improved Timer UI, removed the Tab

This commit is contained in:
Hariharan Mudaliar 2025-11-01 15:09:25 +05:30
commit a9f9ac3aa8
14 changed files with 1079 additions and 241 deletions

View file

@ -10,63 +10,147 @@ import Defaults
struct TimerLiveActivity: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var timerManager = TimerManager.shared
@State private var isHovering: Bool = false
@State private var gestureProgress: CGFloat = 0
@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 {
// Left side - Timer icon animation
timerIconSection
// Center - Expandable timer info (similar to music banner)
timerInfoSection
// Right side - Timer countdown text
timerCountdownSection
HStack(spacing: 10) {
iconSection
infoSection
if showsRingProgress {
ringSection
}
if showsCountdown {
countdownSection
}
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
}
private var timerIconSection: some View {
HStack {
// Simple timer icon with color, no background circle or animations
Image(systemName: "timer")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(timerManager.timerColor)
.frame(width: 24, height: 24)
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.smooth(duration: 0.18)) {
isHovering = hovering
}
}
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)))
}
private var timerInfoSection: some View {
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 timerCountdownSection: some View {
HStack {
VStack(spacing: 2) {
// Remaining time
Text(timerManager.formattedRemainingTime())
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(timerManager.isOvertime ? .red : .white)
// Progress indicator
if timerManager.totalDuration > 0 {
ProgressView(value: timerManager.progress)
.progressViewStyle(LinearProgressViewStyle(tint: timerManager.timerColor))
.frame(width: 30, height: 2)
}
}
.frame(maxWidth: .infinity)
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: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
.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)
}
}
@ -77,7 +161,6 @@ struct TimerLiveActivity: View {
.frame(width: 300, height: 32)
.background(.black)
.onAppear {
// Start a demo timer for preview
TimerManager.shared.startDemoTimer(duration: 300)
}
}