Atoll/DynamicIsland/components/Notch/NotchTimerView.swift
Hariharan Mudaliar 547ed4d323
Update Timer UI
2025-12-09 17:15:33 +05:30

600 lines
20 KiB
Swift

//
// NotchTimerView.swift
// DynamicIsland
//
// Timer tab interface for the Atoll
//
import SwiftUI
import Defaults
#if canImport(AppKit)
import AppKit
#endif
struct NotchTimerView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var timerManager = TimerManager.shared
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.timerPresets) private var timerPresets
@Default(.timerIconColorMode) private var colorMode
@Default(.timerSolidColor) private var solidColor
@Default(.timerShowsProgress) private var showsProgress
@Default(.timerProgressStyle) private var progressStyle
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
@State private var customHours: Int = 0
@State private var customMinutes: Int = 10
@State private var customSeconds: Int = 0
@State private var isSyncingCustomDuration = false
@State private var lockedAccentColor: Color?
var body: some View {
Group {
if enableTimerFeature {
HStack(alignment: .top, spacing: timerManager.isTimerActive ? 0 : 20) {
leftColumn
if shouldShowPresetColumn {
Divider()
.frame(height: max(0, maxTabContentHeight - 8))
.opacity(0.2)
presetColumn
}
}
.frame(maxHeight: maxTabContentHeight, alignment: .top)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.transition(.opacity.combined(with: .blurReplace))
.onAppear { syncCustomDuration(with: customTimerDuration) }
.onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(with: newValue) }
.onChange(of: customHours) { _, _ in updateStoredCustomDuration() }
.onChange(of: customMinutes) { _, _ in updateStoredCustomDuration() }
.onChange(of: customSeconds) { _, _ in updateStoredCustomDuration() }
} else {
disabledState
}
}
.onAppear {
lockAccentColorIfNeeded()
}
.onChange(of: timerManager.isTimerActive) { _, isActive in
if isActive {
lockAccentColorIfNeeded()
} else {
lockedAccentColor = nil
}
}
.onChange(of: timerManager.activePresetId) { _, _ in
if timerManager.isTimerActive && lockedAccentColor == nil {
lockAccentColorIfNeeded()
}
}
}
private var leftColumn: some View {
VStack(alignment: .leading, spacing: 16) {
if timerManager.isTimerActive {
activeTimerCard
} else {
customTimerComposer
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxHeight: maxTabContentHeight, alignment: .top)
.padding(.bottom, 2)
}
private var presetColumn: some View {
VStack(spacing: 6) {
if timerPresets.isEmpty {
Text("Configure presets in Settings to see them here.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
} else {
let computedHeight = CGFloat(timerPresets.count) * 60 + 4
let listHeight = min(max(0, maxTabContentHeight - 16), computedHeight)
List {
ForEach(timerPresets) { preset in
TimerPresetCard(preset: preset, isActive: timerManager.activePresetId == preset.id) {
timerManager.startTimer(duration: preset.duration, name: preset.name, preset: preset)
coordinator.currentView = .timer
}
.listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.never)
.frame(height: listHeight)
}
}
.frame(width: 210, alignment: .leading)
.frame(maxHeight: maxTabContentHeight, alignment: .top)
.padding(.bottom, 2)
}
private var activeTimerCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 14) {
leadingControlSection
.frame(width: 128, alignment: .leading)
timerTitleSection
countdownSection
}
if showsProgress {
progressSection
}
}
.padding(16)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
@ViewBuilder
private var leadingControlSection: some View {
if timerManager.allowsManualInteraction {
HStack(spacing: 10) {
TimerControlButton(
icon: pauseIconName,
foreground: .white.opacity(0.95),
background: timerAccentColor.opacity(0.32),
accessibilityLabel: pauseAccessibilityLabel,
action: togglePauseAction
)
TimerControlButton(
icon: "xmark",
foreground: .white.opacity(0.95),
background: Color.white.opacity(0.16),
accessibilityLabel: "Stop",
action: stopTimerAction
)
}
} else {
externalTimerNotice
}
}
private var timerTitleSection: some View {
GeometryReader { geometry in
let status = timerStatusText
let spacing: CGFloat = status == nil ? 0 : 8
let badgeWidth: CGFloat = status.map(statusBadgeWidth) ?? 0
let marqueeWidth = max(48, geometry.size.width - badgeWidth - spacing)
HStack(alignment: .center, spacing: spacing) {
MarqueeText(
.constant(timerDisplayName),
font: .system(size: 20, weight: .semibold),
nsFont: .title3,
textColor: .white,
minDuration: 0.2,
frameWidth: marqueeWidth
)
.frame(width: marqueeWidth, height: 24, alignment: .leading)
if let status {
statusBadge(status)
.frame(width: badgeWidth)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: 32)
}
private var countdownSection: some View {
VStack(alignment: .trailing, spacing: 4) {
Text(timerManager.formattedRemainingTime())
.font(.system(size: 36, weight: .black, design: .monospaced))
.foregroundStyle(timerManager.isOvertime ? Color.red : .white)
.contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: timerManager.remainingTime)
if timerManager.isOvertime {
Text("Overtime")
.font(.caption)
.foregroundStyle(.red)
}
}
.frame(width: 150, alignment: .trailing)
}
@ViewBuilder
private var progressSection: some View {
if progressStyle == .bar {
GeometryReader { geometry in
Capsule()
.fill(Color.white.opacity(0.12))
.frame(height: 4)
.overlay(alignment: .leading) {
Capsule()
.fill(timerAccentColor)
.frame(width: geometry.size.width * max(0, CGFloat(min(timerManager.progress, 1.0))))
.animation(.smooth(duration: 0.25), value: timerManager.progress)
}
}
.frame(height: 4)
.frame(maxWidth: .infinity)
} else {
HStack(spacing: 12) {
TimerProgressRing(progress: timerManager.progress, tint: timerAccentColor)
.frame(height: 56)
Spacer()
}
}
}
private func togglePauseAction() {
guard timerManager.allowsManualInteraction else { return }
timerManager.isPaused ? timerManager.resumeTimer() : timerManager.pauseTimer()
}
private func stopTimerAction() {
if timerManager.allowsManualInteraction {
timerManager.stopTimer()
} else {
timerManager.endExternalTimer(triggerSmoothClose: false)
}
}
private var customTimerComposer: some View {
VStack(alignment: .leading, spacing: 12) {
DurationInputRow(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds)
HStack(spacing: 10) {
Button {
withAnimation(.smooth) {
timerManager.startTimer(duration: customDurationInSeconds, name: "Custom Timer")
coordinator.currentView = .timer
}
} label: {
Label("Start", systemImage: "play.fill")
.font(.system(size: 15, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.white)
}
.buttonStyle(.plain)
.background(startButtonColor.opacity(isStartDisabled ? 0.5 : 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 1)
)
.opacity(isStartDisabled ? 0.7 : 1)
.disabled(isStartDisabled)
Button {
resetCustomTimerInputs()
} label: {
Label("Reset", systemImage: "arrow.counterclockwise")
.font(.system(size: 15, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(.white.opacity(0.9))
}
.buttonStyle(.plain)
.background(Color.white.opacity(0.16))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
}
}
.padding(12)
.background(Color.white.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
private var externalTimerNotice: some View {
VStack(alignment: .leading, spacing: 6) {
Label("Managed in Clock app", systemImage: "arrow.triangle.2.circlepath")
.font(.caption)
.foregroundStyle(.secondary)
Button("Hide", role: .destructive) {
timerManager.endExternalTimer(triggerSmoothClose: false)
}
.buttonStyle(.borderless)
.font(.caption)
}
}
private var disabledState: some View {
VStack(spacing: 16) {
Image(systemName: "timer.slash")
.font(.system(size: 48, weight: .light))
.foregroundStyle(.secondary)
Text("Timer Disabled")
.font(.title2)
.fontWeight(.medium)
Text("Enable the timer feature in Settings to access this tab.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var shouldShowPresetColumn: Bool {
!timerManager.isTimerActive
}
private var resolvedNotchHeight: CGFloat {
let height = vm.notchSize.height
return height > 0 ? height : openNotchSize.height
}
private var headerHeight: CGFloat {
max(24, vm.effectiveClosedNotchHeight)
}
private var maxTabContentHeight: CGFloat {
let available = resolvedNotchHeight - headerHeight - 36
return max(130, available)
}
private func lockAccentColorIfNeeded() {
if timerManager.isTimerActive {
lockedAccentColor = resolvedAccentColor
}
}
private var timerAccentColor: Color {
lockedAccentColor ?? resolvedAccentColor
}
private var resolvedAccentColor: Color {
switch colorMode {
case .adaptive:
return timerManager.activePreset?.color ?? timerManager.timerColor
case .solid:
return solidColor
}
}
private var timerDisplayName: String {
timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName
}
private var timerStatusText: String? {
if timerManager.isOvertime {
return "Overtime"
} else if timerManager.isPaused {
return "Paused"
} else if timerManager.isFinished {
return "Completed"
}
return nil
}
private var timerStatusColor: Color {
timerManager.isOvertime ? .red : timerAccentColor
}
private func statusBadge(_ text: String) -> some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(timerStatusColor)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(timerStatusColor.opacity(0.18))
.clipShape(Capsule())
}
private func statusBadgeWidth(for text: String) -> CGFloat {
#if canImport(AppKit)
let font = NSFont.systemFont(ofSize: 12, weight: .semibold)
#elseif canImport(UIKit)
let font = UIFont.systemFont(ofSize: 12, weight: .semibold)
#else
return 80
#endif
let width = (text as NSString).size(withAttributes: [.font: font]).width
return width + 24
}
private var pauseIconName: String {
timerManager.isPaused ? "play.fill" : "pause.fill"
}
private var pauseAccessibilityLabel: String {
timerManager.isPaused ? "Resume" : "Pause"
}
private var startButtonColor: Color {
Color(red: 0.142, green: 0.633, blue: 0.265)
}
private var isStartDisabled: Bool {
customDurationInSeconds == 0
}
private var customDurationInSeconds: TimeInterval {
TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
}
private func resetCustomTimerInputs() {
withAnimation(.smooth(duration: 0.2)) {
customHours = 0
customMinutes = 0
customSeconds = 0
}
customTimerDuration = 0
}
private func syncCustomDuration(with value: Double) {
isSyncingCustomDuration = true
let components = TimerPreset.components(for: value)
customHours = components.hours
customMinutes = components.minutes
customSeconds = components.seconds
isSyncingCustomDuration = false
}
private func updateStoredCustomDuration() {
guard !isSyncingCustomDuration else { return }
customTimerDuration = customDurationInSeconds
}
}
private struct TimerControlButton: View {
let icon: String
let foreground: Color
let background: Color
let accessibilityLabel: String
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(foreground)
.frame(width: 46, height: 46)
.background(background)
.clipShape(Circle())
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.help(accessibilityLabel)
}
}
private struct TimerProgressRing: View {
let progress: Double
let tint: Color
var body: some View {
ZStack {
Circle()
.stroke(Color.white.opacity(0.12), lineWidth: 6)
Circle()
.trim(from: 0, to: min(progress, 1.0))
.stroke(tint, style: StrokeStyle(lineWidth: 6, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.3), value: progress)
}
.frame(width: 60, height: 60)
}
}
private struct DurationInputRow: View {
@Binding var hours: Int
@Binding var minutes: Int
@Binding var seconds: Int
var body: some View {
HStack(alignment: .center, spacing: 8) {
DurationField(label: "HH", value: $hours, range: 0...23)
colon
DurationField(label: "MM", value: $minutes, range: 0...59)
colon
DurationField(label: "SS", value: $seconds, range: 0...59)
}
}
private var colon: some View {
Text(":")
.font(.system(size: 26, weight: .black, design: .monospaced))
.foregroundStyle(.secondary)
}
}
private struct DurationField: View {
let label: String
@Binding var value: Int
let range: ClosedRange<Int>
var body: some View {
VStack(spacing: 6) {
TextField("00", text: binding)
.font(.system(size: 28, weight: .semibold, design: .monospaced))
.multilineTextAlignment(.center)
.textFieldStyle(.plain)
.frame(width: 64, height: 46)
.background(Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var binding: Binding<String> {
Binding<String>(
get: { String(format: "%02d", value) },
set: { newValue in
let digits = newValue.filter { $0.isNumber }
let number = min(max(range.lowerBound, Int(digits) ?? 0), range.upperBound)
value = number
}
)
}
}
private struct TimerPresetCard: 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: 30, height: 30)
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
VStack(alignment: .leading, spacing: 2) {
Text(preset.name)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
Text(preset.formattedDuration)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isActive ? "checkmark" : "play.fill")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(isActive ? preset.color : Color.secondary)
.padding(6)
.background(isActive ? preset.color.opacity(0.2) : Color.white.opacity(0.08))
.clipShape(Circle())
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isActive ? preset.color.opacity(0.12) : Color.white.opacity(0.04))
)
}
.buttonStyle(.plain)
}
}
#Preview {
NotchTimerView()
.environmentObject(DynamicIslandViewModel())
.frame(width: 600, height: 320)
.background(.black)
}