Working stuff with revamped UI

This commit is contained in:
Hariharan Mudaliar 2025-12-09 17:03:17 +05:30
commit 5a8c592157
No known key found for this signature in database
GPG key ID: D04F1C2B9194DDFC
11 changed files with 646 additions and 279 deletions

View file

@ -37,6 +37,8 @@ struct ContentView: View {
@Default(.showNetworkGraph) var showNetworkGraph @Default(.showNetworkGraph) var showNetworkGraph
@Default(.showDiskGraph) var showDiskGraph @Default(.showDiskGraph) var showDiskGraph
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity @Default(.enableReminderLiveActivity) var enableReminderLiveActivity
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.timerDisplayMode) var timerDisplayMode
// Dynamic sizing based on view type and graph count with smooth transitions // Dynamic sizing based on view type and graph count with smooth transitions
var dynamicNotchSize: CGSize { var dynamicNotchSize: CGSize {
@ -765,9 +767,11 @@ struct ContentView: View {
triggerHapticIfAllowed() triggerHapticIfAllowed()
} }
let shouldFocusTimerTab = enableTimerFeature && timerDisplayMode == .tab && timerManager.isTimerActive
guard vm.notchState == .closed, guard vm.notchState == .closed,
!coordinator.sneakPeek.show, !coordinator.sneakPeek.show,
Defaults[.openNotchOnHover] else { return } (Defaults[.openNotchOnHover] || shouldFocusTimerTab) else { return }
hoverTask = Task { hoverTask = Task {
try? await Task.sleep(for: .seconds(Defaults[.minimumHoverDuration])) try? await Task.sleep(for: .seconds(Defaults[.minimumHoverDuration]))
@ -778,6 +782,11 @@ struct ContentView: View {
self.isHovering, self.isHovering,
!self.coordinator.sneakPeek.show else { return } !self.coordinator.sneakPeek.show else { return }
if shouldFocusTimerTab {
withAnimation(.smooth) {
self.coordinator.currentView = .timer
}
}
self.openNotch() self.openNotch()
} }
} }

View file

@ -47,6 +47,7 @@ struct ExpandedItem {
class DynamicIslandViewCoordinator: ObservableObject { class DynamicIslandViewCoordinator: ObservableObject {
static let shared = DynamicIslandViewCoordinator() static let shared = DynamicIslandViewCoordinator()
private var cancellables = Set<AnyCancellable>()
@Published var currentView: NotchViews = .home { @Published var currentView: NotchViews = .home {
didSet { didSet {
@ -65,6 +66,9 @@ class DynamicIslandViewCoordinator: ObservableObject {
@AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true @AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true
@AppStorage("timerLiveActivityEnabled") var timerLiveActivityEnabled: Bool = true @AppStorage("timerLiveActivityEnabled") var timerLiveActivityEnabled: Bool = true
@Default(.enableTimerFeature) private var enableTimerFeature
@Default(.timerDisplayMode) private var timerDisplayMode
@AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true { @AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true {
didSet { didSet {
if !alwaysShowTabs { if !alwaysShowTabs {
@ -99,6 +103,19 @@ class DynamicIslandViewCoordinator: ObservableObject {
private init() { private init() {
selectedScreen = preferredScreen selectedScreen = preferredScreen
Defaults.publisher(.timerDisplayMode)
.receive(on: DispatchQueue.main)
.sink { [weak self] change in
self?.handleTimerDisplayModeChange(change.newValue)
}
.store(in: &cancellables)
Defaults.publisher(.enableTimerFeature)
.receive(on: DispatchQueue.main)
.sink { [weak self] change in
self?.handleTimerFeatureToggle(change.newValue)
}
.store(in: &cancellables)
} }
private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) {
@ -122,6 +139,20 @@ class DynamicIslandViewCoordinator: ObservableObject {
} }
} }
private func handleTimerDisplayModeChange(_ mode: TimerDisplayMode) {
guard mode == .popover, currentView == .timer else { return }
withAnimation(.smooth) {
currentView = .home
}
}
private func handleTimerFeatureToggle(_ isEnabled: Bool) {
guard !isEnabled, currentView == .timer else { return }
withAnimation(.smooth) {
currentView = .home
}
}
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
let resolvedDuration: TimeInterval let resolvedDuration: TimeInterval
switch type { switch type {

View file

@ -230,6 +230,10 @@
} }
} }
}, },
":" : {
"comment" : "A colon used in the timer interface.",
"isCommentAutoGenerated" : true
},
"'Now Playing' was the only option on previous versions and works with all media apps." : { "'Now Playing' was the only option on previous versions and works with all media apps." : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -1374,6 +1378,10 @@
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"00" : {
"comment" : "A placeholder text for the minutes in the timer.",
"isCommentAutoGenerated" : true
},
"00:05:00" : { "00:05:00" : {
"comment" : "A time displayed in the timer panel on a lock screen.", "comment" : "A time displayed in the timer panel on a lock screen.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -5713,6 +5721,10 @@
"comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.", "comment" : "A message displayed when there are no presets configured. It encourages the user to configure presets in the app settings.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Configure presets in Settings to see them here." : {
"comment" : "A message displayed when there are no timer presets configured. It instructs the user to configure presets in the app settings to see them here.",
"isCommentAutoGenerated" : true
},
"Connected" : { "Connected" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@ -5963,6 +5975,7 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Custom" : { "Custom" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -8143,6 +8156,10 @@
}, },
"Enable stats monitoring in Settings to view system performance data." : { "Enable stats monitoring in Settings to view system performance data." : {
},
"Enable the timer feature in Settings to access this tab." : {
"comment" : "A message explaining that the timer feature must be enabled in settings to access this tab.",
"isCommentAutoGenerated" : true
}, },
"Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators." : { "Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators." : {
"comment" : "A footer for the \"Weather Widget\" section in the Lock Screen Settings view, describing its purpose.", "comment" : "A footer for the \"Weather Widget\" section in the Lock Screen Settings view, describing its purpose.",
@ -8150,9 +8167,6 @@
}, },
"Enable Thinking Mode" : { "Enable Thinking Mode" : {
},
"Enable timer feature in Settings to use this tab" : {
}, },
"Enable timer live activity" : { "Enable timer live activity" : {
@ -9636,10 +9650,6 @@
} }
} }
}, },
"Hide from Notch" : {
"comment" : "A button label that hides the Clock timer from the Notch app.",
"isCommentAutoGenerated" : true
},
"Hide only when NowPlaying app is in fullscreen" : { "Hide only when NowPlaying app is in fullscreen" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -11268,8 +11278,9 @@
"comment" : "A description of how to manage the microphone privacy indicator preferences.", "comment" : "A description of how to manage the microphone privacy indicator preferences.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Manage the Clock timer from the Clock app." : { "Managed in Clock app" : {
"comment" : "A notice displayed below the custom timer section, explaining that the timer is managed by the Clock app.",
"isCommentAutoGenerated" : true
}, },
"Mark as complete" : { "Mark as complete" : {
"comment" : "An accessibility label for a button that marks an item as complete.", "comment" : "An accessibility label for a button that marks an item as complete.",
@ -12559,6 +12570,7 @@
} }
}, },
"min" : { "min" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
@ -14859,6 +14871,10 @@
"comment" : "Label describing the system volume control inside the media output selector.", "comment" : "Label describing the system volume control inside the media output selector.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Overtime" : {
"comment" : "A label indicating that the timer is running overtime.",
"isCommentAutoGenerated" : true
},
"Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : { "Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
}, },
@ -14870,9 +14886,6 @@
}, },
"Pause" : { "Pause" : {
},
"Paused" : {
}, },
"Percentage" : { "Percentage" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -15214,10 +15227,6 @@
"comment" : "A picker that lets the user select a quick share service to use when sharing files from the shelf.", "comment" : "A picker that lets the user select a quick share service to use when sharing files from the shelf.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Quick Start" : {
"comment" : "A label displayed above the quick start options in the timer tab.",
"isCommentAutoGenerated" : true
},
"Quit" : { "Quit" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -18164,6 +18173,10 @@
} }
} }
}, },
"Start Timer" : {
"comment" : "A button label that starts a custom timer.",
"isCommentAutoGenerated" : true
},
"Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : { "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : {
}, },
@ -18782,6 +18795,10 @@
} }
} }
}, },
"Timer controls appear as" : {
"comment" : "A picker that lets the user choose how the timer controls are displayed.",
"isCommentAutoGenerated" : true
},
"Timer Disabled" : { "Timer Disabled" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {

View file

@ -21,6 +21,7 @@ struct DynamicIslandHeader: View {
@State private var showColorPickerPopover = false @State private var showColorPickerPopover = false
@State private var showTimerPopover = false @State private var showTimerPopover = false
@Default(.enableTimerFeature) var enableTimerFeature @Default(.enableTimerFeature) var enableTimerFeature
@Default(.timerDisplayMode) var timerDisplayMode
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
@ -144,7 +145,7 @@ struct DynamicIslandHeader: View {
} }
} }
if Defaults[.enableTimerFeature] { if Defaults[.enableTimerFeature] && timerDisplayMode == .popover {
Button(action: { Button(action: {
withAnimation(.smooth) { withAnimation(.smooth) {
showTimerPopover.toggle() showTimerPopover.toggle()
@ -252,6 +253,12 @@ struct DynamicIslandHeader: View {
vm.isTimerPopoverActive = false vm.isTimerPopoverActive = false
} }
} }
.onChange(of: timerDisplayMode) { _, mode in
if mode == .tab {
showTimerPopover = false
vm.isTimerPopoverActive = false
}
}
} }
} }

View file

@ -7,27 +7,283 @@
import SwiftUI import SwiftUI
import Defaults import Defaults
#if canImport(AppKit)
import AppKit
#endif
struct NotchTimerView: View { struct NotchTimerView: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var timerManager = TimerManager.shared @ObservedObject var timerManager = TimerManager.shared
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature @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 // 10 minutes default @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 { var body: some View {
Group {
if enableTimerFeature { if enableTimerFeature {
HStack(alignment: .center, spacing: 32) { HStack(alignment: .top, spacing: timerManager.isTimerActive ? 0 : 20) {
// Timer Progress and Controls leftColumn
timerProgressSection if shouldShowPresetColumn {
Divider()
// Timer Control Panel .frame(height: max(0, maxTabContentHeight - 8))
controlsSection .opacity(0.2)
presetColumn
} }
.padding(.horizontal, 20) }
.frame(maxHeight: maxTabContentHeight, alignment: .top)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.transition(.opacity.combined(with: .blurReplace)) .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 { } 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: 10) {
DurationInputRow(hours: $customHours, minutes: $customMinutes, seconds: $customSeconds)
Button {
withAnimation(.smooth) {
timerManager.startTimer(duration: customDurationInSeconds, name: "Custom Timer")
coordinator.currentView = .timer
}
} label: {
Label("Start Timer", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(customDurationInSeconds == 0)
}
.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) { VStack(spacing: 16) {
Image(systemName: "timer.slash") Image(systemName: "timer.slash")
.font(.system(size: 48, weight: .light)) .font(.system(size: 48, weight: .light))
@ -36,269 +292,262 @@ struct NotchTimerView: View {
Text("Timer Disabled") Text("Timer Disabled")
.font(.title2) .font(.title2)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundStyle(.primary)
Text("Enable timer feature in Settings to use this tab") Text("Enable the timer feature in Settings to access this tab.")
.font(.body) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
private var shouldShowPresetColumn: Bool {
!timerManager.isTimerActive
} }
private var timerProgressSection: some View { private var resolvedNotchHeight: CGFloat {
VStack(spacing: 16) { let height = vm.notchSize.height
// Circular progress display return height > 0 ? height : openNotchSize.height
ZStack {
// Background ring
Circle()
.stroke(.white.opacity(0.1), lineWidth: 8)
.frame(width: 100, height: 100)
// Progress ring
Circle()
.trim(from: 0, to: timerManager.progress)
.stroke(
timerManager.currentColor,
style: StrokeStyle(lineWidth: 8, lineCap: .round)
)
.frame(width: 100, height: 100)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.5), value: timerManager.progress)
// Timer icon in center
Image(systemName: "timer")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(openIconColor)
.scaleEffect(timerManager.isRunning && timerManager.isTimerActive ? 1.1 : 1.0)
.animation(
timerManager.isRunning && timerManager.isTimerActive ?
.easeInOut(duration: 0.3).repeatForever(autoreverses: true) :
.easeInOut(duration: 0.3),
value: timerManager.isRunning && timerManager.isTimerActive
)
} }
// Time display private var headerHeight: CGFloat {
VStack(spacing: 4) { max(24, vm.effectiveClosedNotchHeight)
Text(timerManager.formattedTimeRemaining) }
.font(.system(size: 24, weight: .semibold, design: .monospaced))
.foregroundStyle(timerManager.isOvertime ? .red : .white)
if timerManager.isTimerActive && timerManager.isPaused { private var maxTabContentHeight: CGFloat {
Text("Paused") let available = resolvedNotchHeight - headerHeight - 36
.font(.system(size: 14, weight: .medium)) return max(130, available)
.foregroundStyle(.secondary)
}
} }
private func lockAccentColorIfNeeded() {
if timerManager.isTimerActive {
lockedAccentColor = resolvedAccentColor
} }
} }
private var controlsSection: some View { private var timerAccentColor: Color {
VStack(alignment: .leading, spacing: 20) { lockedAccentColor ?? resolvedAccentColor
if timerManager.allowsManualInteraction {
manualControlButtons
quickTimerControls
} else {
externalTimerNotice
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} }
private var manualControlButtons: some View { private var resolvedAccentColor: Color {
VStack(alignment: .leading, spacing: 16) { 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 { if timerManager.isOvertime {
Button(action: { return "Overtime"
timerManager.forceStopTimer() } else if timerManager.isPaused {
}) { return "Paused"
HStack(spacing: 8) { } else if timerManager.isFinished {
Image(systemName: "stop.fill") return "Completed"
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.red)
Text("Stop")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.red)
} }
.frame(height: 40) return nil
.frame(minWidth: 100)
.background(.white.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
} }
.buttonStyle(PlainButtonStyle())
.onHover(perform: cursorHover)
} else {
HStack(spacing: 16) {
Button(action: {
if timerManager.isPaused {
timerManager.resumeTimer()
} else {
timerManager.pauseTimer()
}
}) {
HStack(spacing: 8) {
Image(systemName: timerManager.isPaused ? "play.fill" : "pause.fill")
.font(.system(size: 16, weight: .medium))
Text(timerManager.isPaused ? "Resume" : "Pause")
.font(.system(size: 16, weight: .medium))
}
.foregroundStyle(.white)
.frame(height: 40)
.frame(minWidth: 100)
.background(.white.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(PlainButtonStyle())
.onHover(perform: cursorHover)
Button(action: { private var timerStatusColor: Color {
timerManager.stopTimer() timerManager.isOvertime ? .red : timerAccentColor
}) {
HStack(spacing: 8) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .medium))
Text("Stop")
.font(.system(size: 16, weight: .medium))
} }
.foregroundStyle(.white)
.frame(height: 40) private func statusBadge(_ text: String) -> some View {
.frame(minWidth: 100) Text(text)
.background(.white.opacity(0.1)) .font(.caption.weight(.semibold))
.clipShape(RoundedRectangle(cornerRadius: 12)) .foregroundStyle(timerStatusColor)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(timerStatusColor.opacity(0.18))
.clipShape(Capsule())
} }
.buttonStyle(PlainButtonStyle())
.onHover(perform: cursorHover) 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 customDurationInSeconds: TimeInterval {
TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
}
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 var quickTimerControls: some View { private struct TimerControlButton: View {
VStack(alignment: .leading, spacing: 16) { let icon: String
Text("Quick Start") let foreground: Color
.font(.system(size: 14, weight: .medium)) 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())
}
.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) .foregroundStyle(.secondary)
}
VStack(spacing: 12) {
HStack(spacing: 12) {
quickTimerButton(minutes: 1)
quickTimerButton(minutes: 5)
quickTimerButton(minutes: 15)
} }
HStack(spacing: 12) { private struct DurationField: View {
Button(action: { let label: String
timerManager.startTimer(duration: customTimerDuration, name: "Custom Timer") @Binding var value: Int
}) { let range: ClosedRange<Int>
VStack(spacing: 4) {
Text(customTimerDisplayText) var body: some View {
.font(.system(size: 16, weight: .medium)) VStack(spacing: 6) {
Text("Custom") TextField("00", text: binding)
.font(.system(size: 12, weight: .regular)) .font(.system(size: 28, weight: .semibold, design: .monospaced))
.opacity(0.7) .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
} }
.foregroundStyle(.white)
.frame(height: 60)
.frame(minWidth: 80)
.background(.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(.white.opacity(0.1), lineWidth: 1)
) )
} }
.buttonStyle(PlainButtonStyle())
.onHover(perform: cursorHover)
}
}
}
} }
private var externalTimerNotice: some View { private struct TimerPresetCard: View {
VStack(alignment: .leading, spacing: 12) { let preset: TimerPreset
Text("Manage the Clock timer from the Clock app.") let isActive: Bool
.font(.system(size: 14, weight: .medium)) let action: () -> Void
.foregroundStyle(.secondary)
Button(action: { var body: some View {
timerManager.endExternalTimer(triggerSmoothClose: false) Button(action: action) {
}) { HStack(spacing: 12) {
HStack(spacing: 8) { Circle()
Image(systemName: "eye.slash") .fill(preset.color.gradient)
.font(.system(size: 16, weight: .medium)) .frame(width: 30, height: 30)
Text("Hide from Notch")
.font(.system(size: 16, weight: .medium))
}
.foregroundStyle(.white)
.frame(height: 40)
.frame(minWidth: 140)
.background(.white.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(PlainButtonStyle())
.onHover(perform: cursorHover)
}
}
private var customTimerDisplayText: String {
let totalMinutes = Int(customTimerDuration) / 60
let seconds = Int(customTimerDuration) % 60
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
if hours > 0 {
return "\(hours):\(String(format: "%02d", minutes))"
} else if minutes > 0 {
return "\(minutes) min"
} else {
return "\(seconds)s"
}
}
private func quickTimerButton(minutes: Int) -> some View {
Button(action: {
timerManager.startTimer(duration: TimeInterval(minutes * 60), name: "\(minutes) Min Timer")
}) {
VStack(spacing: 4) {
Text("\(minutes)")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.white)
Text("min")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
}
.frame(width: 80, height: 60)
.background(.white.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) Circle()
.stroke(.white.opacity(0.1), lineWidth: 1) .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(PlainButtonStyle()) .buttonStyle(.plain)
.onHover(perform: cursorHover)
}
}
private extension NotchTimerView {
var openIconColor: Color {
guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) }
return timerManager.currentColor
}
private func cursorHover(_ isHovering: Bool) {
if isHovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
} }
} }
#Preview { #Preview {
NotchTimerView() NotchTimerView()
.environmentObject(DynamicIslandViewModel()) .environmentObject(DynamicIslandViewModel())
.frame(width: 500, height: 200) .frame(width: 600, height: 320)
.background(.black) .background(.black)
} }

View file

@ -3354,6 +3354,7 @@ struct TimerSettings: View {
@Default(.timerProgressStyle) private var progressStyle @Default(.timerProgressStyle) private var progressStyle
@Default(.timerControlWindowEnabled) private var controlWindowEnabled @Default(.timerControlWindowEnabled) private var controlWindowEnabled
@Default(.mirrorSystemTimer) private var mirrorSystemTimer @Default(.mirrorSystemTimer) private var mirrorSystemTimer
@Default(.timerDisplayMode) private var timerDisplayMode
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
@State private var customHours: Int = 0 @State private var customHours: Int = 0
@ -3390,6 +3391,15 @@ struct TimerSettings: View {
Defaults.Toggle("Mirror macOS Clock timers", key: .mirrorSystemTimer) Defaults.Toggle("Mirror macOS Clock timers", key: .mirrorSystemTimer)
.help("Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.") .help("Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.")
.settingsHighlight(id: highlightID("Mirror macOS Clock timers")) .settingsHighlight(id: highlightID("Mirror macOS Clock timers"))
Picker("Timer controls appear as", selection: $timerDisplayMode) {
ForEach(TimerDisplayMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.help(timerDisplayMode.description)
.settingsHighlight(id: highlightID("Timer controls appear as"))
} }
} header: { } header: {
Text("Timer Feature") Text("Timer Feature")

View file

@ -17,8 +17,10 @@ struct TabModel: Identifiable {
struct TabSelectionView: View { struct TabSelectionView: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableStatsFeature) var enableStatsFeature @Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature @Default(.enableColorPickerFeature) var enableColorPickerFeature
@Default(.timerDisplayMode) var timerDisplayMode
@Namespace var animation @Namespace var animation
private var tabs: [TabModel] { private var tabs: [TabModel] {
@ -30,6 +32,10 @@ struct TabSelectionView: View {
tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)) tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf))
} }
if enableTimerFeature && timerDisplayMode == .tab {
tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer))
}
// Stats tab only shown when stats feature is enabled // Stats tab only shown when stats feature is enabled
if Defaults[.enableStatsFeature] { if Defaults[.enableStatsFeature] {
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats)) tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))

View file

@ -0,0 +1,26 @@
import Defaults
public enum TimerDisplayMode: String, CaseIterable, Defaults.Serializable, Identifiable {
case tab
case popover
public var id: String { rawValue }
var displayName: String {
switch self {
case .tab:
return "Tab"
case .popover:
return "Popover"
}
}
var description: String {
switch self {
case .tab:
return "Shows timer controls as a dedicated tab inside the open notch."
case .popover:
return "Keeps the current popover button beside the notch instead of adding a tab."
}
}
}

View file

@ -1425,6 +1425,7 @@ class BluetoothAudioManager: ObservableObject {
HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5) HUDSuppressionCoordinator.shared.suppressVolumeHUD(for: 1.5)
Task { @MainActor in
coordinator.toggleSneakPeek( coordinator.toggleSneakPeek(
status: true, status: true,
type: .bluetoothAudio, type: .bluetoothAudio,
@ -1433,6 +1434,7 @@ class BluetoothAudioManager: ObservableObject {
icon: device.deviceType.sfSymbol icon: device.deviceType.sfSymbol
) )
} }
}
// MARK: - Cleanup // MARK: - Cleanup

View file

@ -155,7 +155,9 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
// Send to notch HUD if enabled and OSD is not enabled // Send to notch HUD if enabled and OSD is not enabled
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableVolumeHUD] { if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableVolumeHUD] {
coordinator?.toggleSneakPeek( Task { @MainActor in
guard let coordinator else { return }
coordinator.toggleSneakPeek(
status: true, status: true,
type: .volume, type: .volume,
value: CGFloat(value), value: CGFloat(value),
@ -163,6 +165,7 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
) )
} }
} }
}
private func sendBrightnessNotification(value: Float) { private func sendBrightnessNotification(value: Float) {
// Send to custom OSD if enabled // Send to custom OSD if enabled
@ -174,7 +177,9 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
// Send to notch HUD if enabled and OSD is not enabled // Send to notch HUD if enabled and OSD is not enabled
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableBrightnessHUD] { if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableBrightnessHUD] {
coordinator?.toggleSneakPeek( Task { @MainActor in
guard let coordinator else { return }
coordinator.toggleSneakPeek(
status: true, status: true,
type: .brightness, type: .brightness,
value: CGFloat(value), value: CGFloat(value),
@ -182,6 +187,7 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
) )
} }
} }
}
private func sendKeyboardBacklightNotification(value: Float) { private func sendKeyboardBacklightNotification(value: Float) {
// Send to custom OSD if enabled // Send to custom OSD if enabled
@ -193,7 +199,9 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
// Send to notch HUD if enabled and OSD is not enabled // Send to notch HUD if enabled and OSD is not enabled
if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableKeyboardBacklightHUD] { if Defaults[.enableSystemHUD] && !Defaults[.enableCustomOSD] && Defaults[.enableKeyboardBacklightHUD] {
coordinator?.toggleSneakPeek( Task { @MainActor in
guard let coordinator else { return }
coordinator.toggleSneakPeek(
status: true, status: true,
type: .backlight, type: .backlight,
value: CGFloat(value), value: CGFloat(value),
@ -201,6 +209,7 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
) )
} }
} }
}
private func configureKeyboardBacklightCallback() { private func configureKeyboardBacklightCallback() {
if keyboardBacklightEnabled { if keyboardBacklightEnabled {

View file

@ -545,6 +545,7 @@ extension Defaults.Keys {
// MARK: Timer Feature // MARK: Timer Feature
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true) static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
static let timerDisplayMode = Key<TimerDisplayMode>("timerDisplayMode", default: .tab)
static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets) static let timerPresets = Key<[TimerPreset]>("timerPresets", default: TimerPreset.defaultPresets)
static let timerIconColorMode = Key<TimerIconColorMode>("timerIconColorMode", default: .adaptive) static let timerIconColorMode = Key<TimerIconColorMode>("timerIconColorMode", default: .adaptive)
static let timerSolidColor = Key<Color>("timerSolidColor", default: .blue) static let timerSolidColor = Key<Color>("timerSolidColor", default: .blue)