Merge pull request #108 from Ebullioscopic/feature/timer

Working with inbuilt macOS timer
This commit is contained in:
Hariharan Mudaliar 2025-11-16 00:40:28 +05:30 • committed by GitHub
commit 53055e1f27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1258 additions and 65 deletions

View file

@ -100,6 +100,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations
let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel
let systemTimerBridge = SystemTimerBridge.shared
var closeNotchWorkItem: DispatchWorkItem? var closeNotchWorkItem: DispatchWorkItem?
private var previousScreens: [NSScreen]? private var previousScreens: [NSScreen]?
private var onboardingWindowController: NSWindowController? private var onboardingWindowController: NSWindowController?

View file

@ -5603,9 +5603,8 @@
} }
} }
}, },
"Control timer availability and whether progress appears as a live activity while the notch is closed." : { "Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app." : {
"comment" : "A footer description for the timer feature settings.",
"isCommentAutoGenerated" : true
}, },
"Controls" : { "Controls" : {
"localizations" : { "localizations" : {
@ -9319,6 +9318,10 @@
} }
} }
}, },
"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" : {
@ -11001,6 +11004,9 @@
} }
} }
} }
},
"Manage the Clock timer from the Clock app." : {
}, },
"Match menubar height" : { "Match menubar height" : {
"localizations" : { "localizations" : {
@ -14887,6 +14893,10 @@
}, },
"Quick Presets" : { "Quick Presets" : {
},
"Quick Start" : {
"comment" : "A label displayed above the quick start options in the timer tab.",
"isCommentAutoGenerated" : true
}, },
"Quit" : { "Quit" : {
"localizations" : { "localizations" : {
@ -17103,6 +17113,9 @@
} }
} }
} }
},
"Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item." : {
}, },
"Size & Scale" : { "Size & Scale" : {
"localizations" : { "localizations" : {

View file

@ -96,21 +96,20 @@ struct NotchTimerView: View {
} }
private var controlsSection: some View { private var controlsSection: some View {
VStack(spacing: 20) { VStack(alignment: .leading, spacing: 20) {
if timerManager.isTimerActive { if timerManager.allowsManualInteraction {
// Active timer controls manualControlButtons
activeTimerControls
} else {
// Quick timer setup
quickTimerControls quickTimerControls
} else {
externalTimerNotice
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
private var activeTimerControls: some View { private var manualControlButtons: some View {
VStack(spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if timerManager.isOvertime { if timerManager.isOvertime {
// Overtime controls - only show stop button
Button(action: { Button(action: {
timerManager.forceStopTimer() timerManager.forceStopTimer()
}) { }) {
@ -128,17 +127,9 @@ struct NotchTimerView: View {
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.onHover { isHovering in .onHover(perform: cursorHover)
if isHovering {
NSCursor.pointingHand.push()
} else { } else {
NSCursor.pop()
}
}
} else {
// Normal timer controls
HStack(spacing: 16) { HStack(spacing: 16) {
// Pause/Resume button
Button(action: { Button(action: {
if timerManager.isPaused { if timerManager.isPaused {
timerManager.resumeTimer() timerManager.resumeTimer()
@ -159,15 +150,8 @@ struct NotchTimerView: View {
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.onHover { isHovering in .onHover(perform: cursorHover)
if isHovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
// Stop button
Button(action: { Button(action: {
timerManager.stopTimer() timerManager.stopTimer()
}) { }) {
@ -180,34 +164,30 @@ struct NotchTimerView: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(height: 40) .frame(height: 40)
.frame(minWidth: 100) .frame(minWidth: 100)
.background(.white.opacity(0.15)) .background(.white.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.onHover { isHovering in .onHover(perform: cursorHover)
if isHovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
} }
} }
} }
} }
private var quickTimerControls: some View { private var quickTimerControls: some View {
VStack(spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Quick timer grid Text("Quick Start")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
VStack(spacing: 12) { VStack(spacing: 12) {
HStack(spacing: 12) { HStack(spacing: 12) {
quickTimerButton(minutes: 1, title: "1 min") quickTimerButton(minutes: 1)
quickTimerButton(minutes: 5, title: "5 min") quickTimerButton(minutes: 5)
quickTimerButton(minutes: 15, title: "15 min") quickTimerButton(minutes: 15)
} }
HStack(spacing: 12) { HStack(spacing: 12) {
// Custom timer button
Button(action: { Button(action: {
timerManager.startTimer(duration: customTimerDuration, name: "Custom Timer") timerManager.startTimer(duration: customTimerDuration, name: "Custom Timer")
}) { }) {
@ -229,15 +209,35 @@ struct NotchTimerView: View {
) )
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.onHover { isHovering in .onHover(perform: cursorHover)
if isHovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
} }
} }
} }
} }
private var externalTimerNotice: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Manage the Clock timer from the Clock app.")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Button(action: {
timerManager.endExternalTimer(triggerSmoothClose: false)
}) {
HStack(spacing: 8) {
Image(systemName: "eye.slash")
.font(.system(size: 16, weight: .medium))
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)
} }
} }
@ -256,7 +256,7 @@ struct NotchTimerView: View {
} }
} }
private func quickTimerButton(minutes: Int, title: String) -> some View { private func quickTimerButton(minutes: Int) -> some View {
Button(action: { Button(action: {
timerManager.startTimer(duration: TimeInterval(minutes * 60), name: "\(minutes) Min Timer") timerManager.startTimer(duration: TimeInterval(minutes * 60), name: "\(minutes) Min Timer")
}) { }) {
@ -277,13 +277,7 @@ struct NotchTimerView: View {
) )
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.onHover { isHovering in .onHover(perform: cursorHover)
if isHovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
} }
} }
@ -292,6 +286,14 @@ private extension NotchTimerView {
guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) } guard timerManager.isTimerActive && timerManager.isRunning else { return .white.opacity(0.9) }
return timerManager.currentColor return timerManager.currentColor
} }
private func cursorHover(_ isHovering: Bool) {
if isHovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
} }
#Preview { #Preview {

View file

@ -1843,6 +1843,7 @@ struct TimerSettings: View {
@Default(.timerShowsProgress) private var showsProgress @Default(.timerShowsProgress) private var showsProgress
@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
@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
@State private var customMinutes: Int = 10 @State private var customMinutes: Int = 10
@ -1857,11 +1858,13 @@ struct TimerSettings: View {
if enableTimerFeature { if enableTimerFeature {
Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled) Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled)
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled) .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
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.")
} }
} header: { } header: {
Text("Timer Feature") Text("Timer Feature")
} footer: { } footer: {
Text("Control timer availability and whether progress appears as a live activity while the notch is closed.") Text("Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app.")
} }
if enableTimerFeature { if enableTimerFeature {

View file

@ -41,6 +41,7 @@ struct TimerControlOverlay: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.help(helpText) .help(helpText)
.disabled(!timerManager.allowsManualInteraction)
Button(action: stopTimer) { Button(action: stopTimer) {
Image(systemName: "stop.fill") Image(systemName: "stop.fill")
@ -51,6 +52,7 @@ struct TimerControlOverlay: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.help("Stop") .help("Stop")
.disabled(!timerManager.allowsManualInteraction)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(height: notchHeight) .frame(height: notchHeight)
@ -66,6 +68,7 @@ struct TimerControlOverlay: View {
} }
private func togglePause() { private func togglePause() {
guard timerManager.allowsManualInteraction else { return }
if timerManager.isPaused { if timerManager.isPaused {
timerManager.resumeTimer() timerManager.resumeTimer()
} else { } else {
@ -74,6 +77,10 @@ struct TimerControlOverlay: View {
} }
private func stopTimer() { private func stopTimer() {
guard timerManager.allowsManualInteraction else {
timerManager.endExternalTimer(triggerSmoothClose: false)
return
}
#if os(macOS) #if os(macOS)
TimerControlWindowManager.shared.hide(animated: true) TimerControlWindowManager.shared.hide(animated: true)
#endif #endif

View file

@ -141,7 +141,7 @@ struct TimerLiveActivity: View {
} }
private var shouldShowControlWindow: Bool { private var shouldShowControlWindow: Bool {
controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive controlWindowEnabled && !shouldDisplayLabel && timerManager.isTimerActive && !timerManager.isExternalTimerActive
} }
private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat { private func measureTextWidth(_ text: String, font: PlatformFont) -> CGFloat {
@ -286,6 +286,9 @@ struct TimerLiveActivity: View {
.onChange(of: progressStyle) { _, _ in .onChange(of: progressStyle) { _, _ in
syncControlWindow(forceRefresh: true) syncControlWindow(forceRefresh: true)
} }
.onChange(of: timerManager.activeSource) { _, _ in
syncControlWindow(forceRefresh: true)
}
} }
private var iconSection: some View { private var iconSection: some View {

View file

@ -158,6 +158,7 @@ private struct ActiveTimerSection: View {
.labelStyle(.titleAndIcon) .labelStyle(.titleAndIcon)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(!timerManager.allowsManualInteraction)
Button(role: .destructive, action: stopTimer) { Button(role: .destructive, action: stopTimer) {
Label("Stop", systemImage: "stop.fill") Label("Stop", systemImage: "stop.fill")
@ -165,6 +166,7 @@ private struct ActiveTimerSection: View {
.labelStyle(.titleAndIcon) .labelStyle(.titleAndIcon)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(!timerManager.allowsManualInteraction)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -175,6 +177,7 @@ private struct ActiveTimerSection: View {
} }
private func togglePause() { private func togglePause() {
guard timerManager.allowsManualInteraction else { return }
withAnimation(.smooth) { withAnimation(.smooth) {
if timerManager.isPaused { if timerManager.isPaused {
timerManager.resumeTimer() timerManager.resumeTimer()
@ -185,6 +188,10 @@ private struct ActiveTimerSection: View {
} }
private func stopTimer() { private func stopTimer() {
guard timerManager.allowsManualInteraction else {
timerManager.endExternalTimer(triggerSmoothClose: false)
return
}
withAnimation(.smooth) { withAnimation(.smooth) {
timerManager.stopTimer() timerManager.stopTimer()
} }

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,7 @@ class TimerManager: ObservableObject {
@Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative @Published var isOvertime: Bool = false // Timer has gone past 0 and is counting negative
@Published var lastUpdated: Date = .distantPast @Published var lastUpdated: Date = .distantPast
@Published var activePresetId: UUID? @Published var activePresetId: UUID?
@Published private(set) var activeSource: TimerSource = .none
// Timer progress (0.0 to 1.0, or >1.0 for overtime) // Timer progress (0.0 to 1.0, or >1.0 for overtime)
var progress: Double { var progress: Double {
@ -113,7 +114,6 @@ class TimerManager: ObservableObject {
private var timerInstance: Timer? private var timerInstance: Timer?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var soundPlayer: AVAudioPlayer? private var soundPlayer: AVAudioPlayer?
// MARK: - Initialization // MARK: - Initialization
private init() { private init() {
// Simple initialization // Simple initialization
@ -127,6 +127,10 @@ class TimerManager: ObservableObject {
// MARK: - Timer Methods // MARK: - Timer Methods
func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) { func startTimer(duration: TimeInterval, name: String = "Timer", preset: TimerPreset? = nil) {
if activeSource == .external {
endExternalTimer(triggerSmoothClose: false)
}
// Stop any existing timer // Stop any existing timer
timerInstance?.invalidate() timerInstance?.invalidate()
@ -134,6 +138,7 @@ class TimerManager: ObservableObject {
withAnimation(.smooth) { withAnimation(.smooth) {
isTimerActive = true isTimerActive = true
} }
activeSource = .manual
isFinished = false isFinished = false
isOvertime = false isOvertime = false
timerName = name timerName = name
@ -178,6 +183,11 @@ class TimerManager: ObservableObject {
} }
func stopTimer() { func stopTimer() {
if activeSource == .external {
endExternalTimer(triggerSmoothClose: true)
return
}
timerInstance?.invalidate() timerInstance?.invalidate()
timerInstance = nil timerInstance = nil
soundPlayer?.stop() soundPlayer?.stop()
@ -191,6 +201,11 @@ class TimerManager: ObservableObject {
} }
func forceStopTimer() { func forceStopTimer() {
if activeSource == .external {
endExternalTimer(triggerSmoothClose: false)
return
}
// Immediate stop for user action (stop button) // Immediate stop for user action (stop button)
timerInstance?.invalidate() timerInstance?.invalidate()
timerInstance = nil timerInstance = nil
@ -202,6 +217,7 @@ class TimerManager: ObservableObject {
} }
func pauseTimer() { func pauseTimer() {
guard activeSource == .manual else { return }
guard isTimerActive && !isPaused else { return } guard isTimerActive && !isPaused else { return }
isPaused = true isPaused = true
timerInstance?.invalidate() timerInstance?.invalidate()
@ -209,6 +225,7 @@ class TimerManager: ObservableObject {
} }
func resumeTimer() { func resumeTimer() {
guard activeSource == .manual else { return }
guard isTimerActive && isPaused else { return } guard isTimerActive && isPaused else { return }
isPaused = false isPaused = false
lastUpdated = Date() lastUpdated = Date()
@ -241,6 +258,78 @@ class TimerManager: ObservableObject {
} }
} }
func adoptExternalTimer(name: String, totalDuration: TimeInterval, remaining: TimeInterval, isPaused: Bool) {
guard activeSource != .manual else { return }
timerInstance?.invalidate()
timerInstance = nil
soundPlayer?.stop()
activeSource = .external
let clampedTotal = totalDuration > 0 ? totalDuration : max(totalDuration, remaining)
withAnimation(.smooth) {
isTimerActive = true
}
timerName = name.isEmpty ? "Clock Timer" : name
self.totalDuration = clampedTotal
remainingTime = remaining
elapsedTime = max(0, clampedTotal - remaining)
self.isPaused = isPaused
isFinished = false
isOvertime = remaining < 0
activePresetId = nil
lastUpdated = Date()
}
func updateExternalTimer(remaining: TimeInterval, totalDuration: TimeInterval?, isPaused paused: Bool, name: String? = nil) {
guard activeSource == .external else { return }
if let name, !name.isEmpty, name != timerName {
timerName = name
}
if let totalDuration, totalDuration > 0 {
self.totalDuration = max(totalDuration, self.totalDuration)
} else if self.totalDuration <= 0 {
self.totalDuration = max(self.totalDuration, remaining)
}
remainingTime = remaining
elapsedTime = max(0, self.totalDuration - remaining)
isOvertime = remaining < 0
isPaused = paused
isFinished = remaining <= 0 && !isOvertime
lastUpdated = Date()
}
func completeExternalTimer() {
guard activeSource == .external else { return }
remainingTime = 0
elapsedTime = totalDuration
isOvertime = false
isFinished = true
lastUpdated = Date()
scheduleSmoothClose()
}
func endExternalTimer(triggerSmoothClose: Bool) {
guard activeSource == .external else { return }
if triggerSmoothClose && isTimerActive {
scheduleSmoothClose()
} else {
withAnimation(.smooth) {
isTimerActive = false
}
}
resetTimer()
}
private func resetTimer() { private func resetTimer() {
withAnimation(.smooth) { withAnimation(.smooth) {
isTimerActive = false isTimerActive = false
@ -253,6 +342,7 @@ class TimerManager: ObservableObject {
isFinished = false isFinished = false
isOvertime = false isOvertime = false
activePresetId = nil activePresetId = nil
activeSource = .none
} }
// MARK: - Derived State // MARK: - Derived State
@ -261,6 +351,24 @@ class TimerManager: ObservableObject {
return Defaults[.timerPresets].first { $0.id == presetId } return Defaults[.timerPresets].first { $0.id == presetId }
} }
var isExternalTimerActive: Bool {
activeSource == .external && isTimerActive
}
var hasManualTimerRunning: Bool {
activeSource == .manual && isTimerActive
}
var allowsManualInteraction: Bool {
activeSource != .external
}
enum TimerSource: String {
case none
case manual
case external
}
private func scheduleSmoothClose() { private func scheduleSmoothClose() {
// Wait 3 seconds then smoothly close the live activity // Wait 3 seconds then smoothly close the live activity
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {

View file

@ -463,6 +463,7 @@ extension Defaults.Keys {
static let timerShowsLabel = Key<Bool>("timerShowsLabel", default: true) static let timerShowsLabel = Key<Bool>("timerShowsLabel", default: true)
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true) static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar) static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
static let mirrorSystemTimer = Key<Bool>("mirrorSystemTimer", default: true)
// MARK: Reminder Live Activity // MARK: Reminder Live Activity
static let enableReminderLiveActivity = Key<Bool>("enableReminderLiveActivity", default: true) static let enableReminderLiveActivity = Key<Bool>("enableReminderLiveActivity", default: true)