mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Improved Timer UI, removed the Tab
This commit is contained in:
parent
d7c8b1ef61
commit
a9f9ac3aa8
14 changed files with 1079 additions and 241 deletions
|
|
@ -6,95 +6,25 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import Defaults
|
||||
|
||||
struct TimerIconAnimation: View {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@State private var pulseScale: CGFloat = 1.0
|
||||
@State private var rotationAngle: Double = 0
|
||||
@Default(.accentColor) private var accentColor
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background circle with timer color
|
||||
Circle()
|
||||
.fill(timerManager.timerColor.gradient)
|
||||
.fill(accentColor.gradient)
|
||||
.frame(width: 24, height: 24)
|
||||
.scaleEffect(pulseScale)
|
||||
.animation(
|
||||
timerManager.isTimerActive && !timerManager.isPaused
|
||||
? .easeInOut(duration: 1.0).repeatForever(autoreverses: true)
|
||||
: .easeInOut(duration: 0.3),
|
||||
value: pulseScale
|
||||
)
|
||||
|
||||
// Timer icon
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.rotationEffect(.degrees(rotationAngle))
|
||||
.animation(
|
||||
timerManager.isTimerActive && !timerManager.isPaused
|
||||
? .linear(duration: 2.0).repeatForever(autoreverses: false)
|
||||
: .easeInOut(duration: 0.3),
|
||||
value: rotationAngle
|
||||
)
|
||||
|
||||
// Progress ring
|
||||
Circle()
|
||||
.trim(from: 0, to: timerManager.progress)
|
||||
.stroke(
|
||||
Color.white.opacity(0.8),
|
||||
style: StrokeStyle(lineWidth: 2, lineCap: .round)
|
||||
)
|
||||
.frame(width: 26, height: 26)
|
||||
.rotationEffect(.degrees(-90)) // Start from top
|
||||
.animation(.easeInOut(duration: 0.5), value: timerManager.progress)
|
||||
}
|
||||
.onAppear {
|
||||
startAnimations()
|
||||
}
|
||||
.onChange(of: timerManager.isTimerActive) { _, isActive in
|
||||
if isActive {
|
||||
startAnimations()
|
||||
} else {
|
||||
stopAnimations()
|
||||
}
|
||||
}
|
||||
.onChange(of: timerManager.isPaused) { _, isPaused in
|
||||
if isPaused {
|
||||
stopAnimations()
|
||||
} else if timerManager.isTimerActive {
|
||||
startAnimations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimations() {
|
||||
guard timerManager.isTimerActive && !timerManager.isPaused else { return }
|
||||
|
||||
// Start pulsing
|
||||
pulseScale = 1.1
|
||||
|
||||
// Start rotation
|
||||
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
|
||||
rotationAngle = 360
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAnimations() {
|
||||
// Stop pulsing
|
||||
pulseScale = 1.0
|
||||
|
||||
// Stop rotation
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
rotationAngle = 0
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimerIconView: NSViewRepresentable {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let hostingView = NSHostingView(rootView: TimerIconAnimation())
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
362
DynamicIsland/components/Timer/TimerPopover.swift
Normal file
362
DynamicIsland/components/Timer/TimerPopover.swift
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
//
|
||||
// TimerPopover.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 2025-11-01.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct TimerPopover: View {
|
||||
@ObservedObject var timerManager = TimerManager.shared
|
||||
@Default(.timerPresets) private var timerPresets
|
||||
@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
|
||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var customHours: Int = 0
|
||||
@State private var customMinutes: Int = 10
|
||||
@State private var customSeconds: Int = 0
|
||||
|
||||
private var customDurationInSeconds: TimeInterval {
|
||||
TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HeaderView(statusText: statusText)
|
||||
|
||||
if timerManager.isTimerActive {
|
||||
ActiveTimerSection(timerManager: timerManager)
|
||||
} else {
|
||||
CustomTimerSection(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds, startAction: startCustomTimer)
|
||||
.onChange(of: customHours) { _, _ in updateStoredCustomDuration() }
|
||||
.onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() }
|
||||
.onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() }
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, -8)
|
||||
|
||||
PresetList(presets: timerPresets, activePresetId: timerManager.activePresetId, startAction: startPreset)
|
||||
.animation(.smooth, value: timerManager.activePresetId)
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 300)
|
||||
.background(
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
.cornerRadius(12)
|
||||
)
|
||||
.onAppear {
|
||||
syncCustomDuration(with: customTimerDuration)
|
||||
}
|
||||
.onChange(of: customTimerDuration) { _, newValue in
|
||||
syncCustomDuration(with: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if timerManager.isOvertime {
|
||||
return "Overtime"
|
||||
} else if timerManager.isPaused {
|
||||
return "Paused"
|
||||
} else if timerManager.isTimerActive {
|
||||
return "Running"
|
||||
} else {
|
||||
return "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
private func syncCustomDuration(with value: Double) {
|
||||
let components = TimerPreset.components(for: value)
|
||||
customHours = components.hours
|
||||
customMinutes = components.minutes
|
||||
customSeconds = components.seconds
|
||||
}
|
||||
|
||||
private func updateStoredCustomDuration() {
|
||||
let newValue = customDurationInSeconds
|
||||
customTimerDuration = newValue
|
||||
}
|
||||
|
||||
private func startCustomTimer() {
|
||||
let duration = customDurationInSeconds
|
||||
guard duration > 0 else { return }
|
||||
withAnimation(.smooth) {
|
||||
timerManager.startTimer(duration: duration, name: "Custom Timer")
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func startPreset(_ preset: TimerPreset) {
|
||||
withAnimation(.smooth) {
|
||||
timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeaderView: View {
|
||||
let statusText: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
TimerIconAnimation()
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Timer")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(statusText)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActiveTimerSection: View {
|
||||
@ObservedObject var timerManager: TimerManager
|
||||
@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
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(timerManager.timerName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(timerManager.formattedRemainingTime())
|
||||
.font(.system(size: 28, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(timerManager.isOvertime ? Color.red : Color.primary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
if showsProgress && progressStyle == .bar {
|
||||
ProgressView(value: min(timerManager.progress, 1.0))
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: progressTint))
|
||||
.animation(.smooth(duration: 0.2), value: timerManager.progress)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: togglePause) {
|
||||
Label(timerManager.isPaused ? "Resume" : "Pause", systemImage: timerManager.isPaused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button(role: .destructive, action: stopTimer) {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.animation(.smooth, value: timerManager.isPaused)
|
||||
}
|
||||
|
||||
private func togglePause() {
|
||||
withAnimation(.smooth) {
|
||||
if timerManager.isPaused {
|
||||
timerManager.resumeTimer()
|
||||
} else {
|
||||
timerManager.pauseTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
withAnimation(.smooth) {
|
||||
timerManager.stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private var progressTint: Color {
|
||||
switch colorMode {
|
||||
case .adaptive:
|
||||
return activePresetColor ?? timerManager.timerColor
|
||||
case .solid:
|
||||
return solidColor
|
||||
}
|
||||
}
|
||||
|
||||
private var activePresetColor: Color? {
|
||||
guard let presetId = timerManager.activePresetId else { return nil }
|
||||
return timerPresets.first { $0.id == presetId }?.color
|
||||
}
|
||||
}
|
||||
|
||||
private struct CustomTimerSection: View {
|
||||
@Binding var hours: Int
|
||||
@Binding var minutes: Int
|
||||
@Binding var seconds: Int
|
||||
let startAction: () -> Void
|
||||
|
||||
private var totalSeconds: Int {
|
||||
hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Custom Timer")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
DurationStepper(title: "Hours", value: $hours, range: 0...23)
|
||||
DurationStepper(title: "Minutes", value: $minutes, range: 0...59)
|
||||
DurationStepper(title: "Seconds", value: $seconds, range: 0...59)
|
||||
}
|
||||
}
|
||||
|
||||
Text(formattedDuration)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(action: startAction) {
|
||||
Label("Start Custom Timer", systemImage: "play.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(totalSeconds == 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private var formattedDuration: String {
|
||||
let components = TimerPreset.DurationComponents(hours: hours, minutes: minutes, seconds: seconds)
|
||||
let interval = TimerPreset.duration(from: components)
|
||||
return TimerPreset(name: "", duration: interval, color: .clear).formattedDuration
|
||||
}
|
||||
}
|
||||
|
||||
private struct DurationStepper: View {
|
||||
let title: String
|
||||
@Binding var value: Int
|
||||
let range: ClosedRange<Int>
|
||||
|
||||
var body: some View {
|
||||
Stepper(value: $value, in: range) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Spacer()
|
||||
Text("\(value)")
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.frame(width: 88)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PresetList: View {
|
||||
let presets: [TimerPreset]
|
||||
let activePresetId: UUID?
|
||||
let startAction: (TimerPreset) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Presets")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.padding(.leading, 4)
|
||||
|
||||
if presets.isEmpty {
|
||||
Text("Configure presets in Settings")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(presets) { preset in
|
||||
TimerPresetRow(preset: preset, isActive: activePresetId == preset.id) {
|
||||
startAction(preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimerPresetRow: View {
|
||||
let preset: TimerPreset
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(preset.color.gradient)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(preset.name)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text(preset.formattedDuration)
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isActive ? "checkmark" : "play.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(isActive ? preset.color : Color.secondary)
|
||||
.padding(6)
|
||||
.background(isActive ? preset.color.opacity(0.2) : Color.clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(isActive ? preset.color.opacity(0.18) : Color.white.opacity(0.05))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TimerPopover()
|
||||
.frame(width: 300)
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue