mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #108 from Ebullioscopic/feature/timer
Working with inbuilt macOS timer
This commit is contained in:
commit
53055e1f27
10 changed files with 1258 additions and 65 deletions
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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" : {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1048
DynamicIsland/managers/SystemTimerBridge.swift
Normal file
1048
DynamicIsland/managers/SystemTimerBridge.swift
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue