Skip to content

Commit 0afdd76

Browse files
committed
feat: redesign pause as a dedicated window with live timers
- pause menu item always present, grayed out when no active session - pause opens a persistent window instead of inline menu/banner state - countdown and break timers update live via timelineview (1s cadence) - state machine: idle → countdown → confirm → break, all in one view - preferences banner pause button opens the pause window - remove notification-based confirmation flow, use pendingPauseConfirmation in appstate - rebuild menu on all pause-related state changes
1 parent 354e1ad commit 0afdd76

5 files changed

Lines changed: 155 additions & 137 deletions

File tree

Tome/AppDelegate.swift

Lines changed: 49 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
2020
// window delegates must be held strongly (NSWindow.delegate is weak)
2121
private var prefsWindowDelegate: WindowCloseDelegate?
2222
private var aboutWindowDelegate: WindowCloseDelegate?
23+
private var pauseWindowDelegate: WindowCloseDelegate?
2324

2425
func applicationDidFinishLaunching(_ notification: Notification) {
2526
NSApp.setActivationPolicy(.accessory)
2627

27-
// force initialization so schedule evaluation starts immediately
2828
_ = ScheduleManager.shared
2929
_ = BlocklistManager.shared
3030

@@ -33,8 +33,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
3333

3434
NotificationCenter.default.addObserver(
3535
self,
36-
selector: #selector(showPauseConfirmation),
37-
name: .tomeShowPauseConfirmation,
36+
selector: #selector(openPause),
37+
name: .tomeOpenPauseWindow,
3838
object: nil
3939
)
4040
}
@@ -81,41 +81,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
8181
let m = NSMenu()
8282

8383
// Status line
84-
let statusItem = NSMenuItem(title: statusTitle(), action: nil, keyEquivalent: "")
85-
statusItem.isEnabled = false
86-
m.addItem(statusItem)
84+
let statusMenuItem = NSMenuItem(title: statusTitle(), action: nil, keyEquivalent: "")
85+
statusMenuItem.isEnabled = false
86+
m.addItem(statusMenuItem)
8787

8888
m.addItem(.separator())
8989

90-
// Pause / Cancel Pause / Resume — always visible, context-sensitive
91-
if appState.isPaused {
92-
let breakRemaining = pauseManager.breakSecondsRemaining
93-
let mins = breakRemaining / 60
94-
let secs = breakRemaining % 60
95-
let resumeItem = NSMenuItem(
96-
title: String(format: "End break early (%d:%02d left)", mins, secs),
97-
action: #selector(endBreakEarly),
98-
keyEquivalent: ""
99-
)
100-
resumeItem.target = self
101-
m.addItem(resumeItem)
102-
} else if appState.pauseRequestActive {
103-
let remaining = pauseManager.countdownSecondsRemaining
104-
let mins = remaining / 60
105-
let secs = remaining % 60
106-
let cancelItem = NSMenuItem(
107-
title: String(format: "Cancel pause request (%d:%02d)", mins, secs),
108-
action: #selector(cancelPauseRequest),
109-
keyEquivalent: ""
110-
)
111-
cancelItem.target = self
112-
m.addItem(cancelItem)
113-
} else {
114-
let pauseItem = NSMenuItem(title: "Request pause...", action: #selector(requestPause), keyEquivalent: "")
115-
pauseItem.target = self
116-
pauseItem.isEnabled = appState.isActivelyBlocking
117-
m.addItem(pauseItem)
118-
}
90+
// Pause — always present, grayed out when not applicable
91+
let pauseItem = NSMenuItem(title: "Pause...", action: #selector(openPause), keyEquivalent: "")
92+
pauseItem.target = self
93+
pauseItem.isEnabled = appState.pauseWindowEnabled
94+
m.addItem(pauseItem)
11995

12096
m.addItem(.separator())
12197

@@ -132,12 +108,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
132108
m.addItem(.separator())
133109

134110
// Quit — disabled in locked mode during active blocking
135-
let quitItem = NSMenuItem(title: "Quit Tome", action: #selector(quit), keyEquivalent: "q")
111+
let quitItem = NSMenuItem(
112+
title: appState.lockedMode && appState.isActivelyBlocking ? "Quit Tome (locked)" : "Quit Tome",
113+
action: #selector(quit),
114+
keyEquivalent: "q"
115+
)
136116
quitItem.target = self
137-
if appState.lockedMode && appState.isActivelyBlocking {
138-
quitItem.isEnabled = false
139-
quitItem.title = "Quit Tome (locked)"
140-
}
117+
quitItem.isEnabled = !(appState.lockedMode && appState.isActivelyBlocking)
141118
m.addItem(quitItem)
142119

143120
self.menu = m
@@ -146,9 +123,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
146123

147124
private func statusTitle() -> String {
148125
if appState.isPaused {
149-
let mins = pauseManager.breakSecondsRemaining / 60
150-
let secs = pauseManager.breakSecondsRemaining % 60
151-
return String(format: "Paused — %d:%02d remaining", mins, secs)
126+
let remaining = pauseManager.breakSecondsRemaining
127+
return String(format: "Paused — %d:%02d remaining", remaining / 60, remaining % 60)
152128
} else if appState.isBlocking {
153129
if let first = appState.activeSchedules.first {
154130
return "Blocking · \(first.name)"
@@ -162,38 +138,34 @@ class AppDelegate: NSObject, NSApplicationDelegate {
162138
// MARK: - Observers
163139

164140
private func setupObservers() {
165-
appState.$isBlocking
166-
.receive(on: RunLoop.main)
167-
.sink { [weak self] _ in self?.updateStatusIcon() }
168-
.store(in: &cancellables)
169-
170-
appState.$isPaused
171-
.receive(on: RunLoop.main)
172-
.sink { [weak self] _ in self?.updateStatusIcon() }
173-
.store(in: &cancellables)
174-
}
175-
176-
// MARK: - Actions
177-
178-
@objc private func requestPause() {
179-
pauseManager.requestPause()
180-
rebuildMenu()
181-
}
141+
// update icon + rebuild menu on any relevant state change
142+
let rebuild: (Any) -> Void = { [weak self] _ in
143+
DispatchQueue.main.async {
144+
self?.updateStatusIcon()
145+
self?.rebuildMenu()
146+
}
147+
}
182148

183-
@objc private func cancelPauseRequest() {
184-
pauseManager.cancelPauseRequest()
185-
rebuildMenu()
149+
appState.$isBlocking.receive(on: RunLoop.main).sink(receiveValue: rebuild).store(in: &cancellables)
150+
appState.$isPaused.receive(on: RunLoop.main).sink(receiveValue: rebuild).store(in: &cancellables)
151+
appState.$pauseRequestActive.receive(on: RunLoop.main).sink(receiveValue: rebuild).store(in: &cancellables)
152+
appState.$pendingPauseConfirmation.receive(on: RunLoop.main).sink(receiveValue: rebuild).store(in: &cancellables)
186153
}
187154

188-
@objc private func endBreakEarly() {
189-
pauseManager.endPause()
190-
rebuildMenu()
191-
}
155+
// MARK: - Actions
192156

193-
@objc func showPauseConfirmation() {
194-
let view = PauseConfirmView()
195-
.environmentObject(appState)
196-
showFloatingWindow(title: "Confirm Pause", content: view, identifier: "pause", size: NSSize(width: 320, height: 240))
157+
@objc func openPause() {
158+
if pauseWindow == nil {
159+
let view = PauseView().environmentObject(appState)
160+
let window = makeWindow(title: "Pause", content: view, size: NSSize(width: 320, height: 300))
161+
window.styleMask = [.titled, .closable]
162+
pauseWindow = window
163+
let delegate = WindowCloseDelegate { [weak self] in self?.pauseWindow = nil }
164+
pauseWindowDelegate = delegate
165+
window.delegate = delegate
166+
}
167+
pauseWindow?.makeKeyAndOrderFront(nil)
168+
NSApp.activate(ignoringOtherApps: true)
197169
}
198170

199171
@objc private func openPreferences() {
@@ -204,9 +176,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
204176
.environmentObject(ScheduleManager.shared)
205177
let window = makeWindow(title: "Tome Preferences", content: view, size: NSSize(width: 680, height: 520))
206178
prefsWindow = window
207-
let prefsDelegate = WindowCloseDelegate { [weak self] in self?.prefsWindow = nil }
208-
prefsWindowDelegate = prefsDelegate
209-
window.delegate = prefsDelegate
179+
let delegate = WindowCloseDelegate { [weak self] in self?.prefsWindow = nil }
180+
prefsWindowDelegate = delegate
181+
window.delegate = delegate
210182
}
211183
prefsWindow?.makeKeyAndOrderFront(nil)
212184
NSApp.activate(ignoringOtherApps: true)
@@ -218,9 +190,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
218190
let window = makeWindow(title: "About Tome", content: view, size: NSSize(width: 340, height: 300))
219191
window.styleMask = [.titled, .closable]
220192
aboutWindow = window
221-
let aboutDelegate = WindowCloseDelegate { [weak self] in self?.aboutWindow = nil }
222-
aboutWindowDelegate = aboutDelegate
223-
window.delegate = aboutDelegate
193+
let delegate = WindowCloseDelegate { [weak self] in self?.aboutWindow = nil }
194+
aboutWindowDelegate = delegate
195+
window.delegate = delegate
224196
}
225197
aboutWindow?.makeKeyAndOrderFront(nil)
226198
NSApp.activate(ignoringOtherApps: true)
@@ -245,23 +217,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
245217
window.isReleasedWhenClosed = false
246218
return window
247219
}
248-
249-
private func showFloatingWindow<V: View>(title: String, content: V, identifier: String, size: NSSize) {
250-
let window = NSPanel(
251-
contentRect: NSRect(origin: .zero, size: size),
252-
styleMask: [.titled, .closable, .nonactivatingPanel, .hudWindow],
253-
backing: .buffered,
254-
defer: false
255-
)
256-
window.title = title
257-
window.center()
258-
window.contentView = NSHostingView(rootView: content)
259-
window.isReleasedWhenClosed = true
260-
window.level = .floating
261-
window.makeKeyAndOrderFront(nil)
262-
NSApp.activate(ignoringOtherApps: true)
263-
pauseWindow = window
264-
}
265220
}
266221

267222
class WindowCloseDelegate: NSObject, NSWindowDelegate {

Tome/Managers/PauseManager.swift

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class PauseManager: ObservableObject {
3434
DispatchQueue.main.async {
3535
self.appState.pauseRequestActive = false
3636
self.appState.pauseRequestEndsAt = nil
37+
self.appState.pendingPauseConfirmation = false
3738
}
3839
}
3940

@@ -45,8 +46,8 @@ class PauseManager: ObservableObject {
4546
DispatchQueue.main.async {
4647
self.appState.pauseRequestActive = false
4748
self.appState.pauseRequestEndsAt = nil
49+
self.appState.pendingPauseConfirmation = true
4850
}
49-
NotificationCenter.default.post(name: .tomeShowPauseConfirmation, object: nil)
5051
}
5152
}
5253

@@ -58,18 +59,14 @@ class PauseManager: ObservableObject {
5859
// MARK: - Confirmed Pause (1-15 min break)
5960

6061
func confirmPause(minutes: Int) {
61-
let duration = TimeInterval(minutes * 60)
62-
let endsAt = Date().addingTimeInterval(duration)
63-
64-
// remove blocks while paused
62+
let endsAt = Date().addingTimeInterval(TimeInterval(minutes * 60))
6563
hostsManager.removeAllBlocks()
66-
6764
DispatchQueue.main.async {
65+
self.appState.pendingPauseConfirmation = false
6866
self.appState.isPaused = true
6967
self.appState.pauseEndsAt = endsAt
7068
self.appState.isBlocking = false
7169
}
72-
7370
breakTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
7471
self?.tickBreak()
7572
}
@@ -100,5 +97,5 @@ class PauseManager: ObservableObject {
10097
}
10198

10299
extension Notification.Name {
103-
static let tomeShowPauseConfirmation = Notification.Name("tomeShowPauseConfirmation")
100+
static let tomeOpenPauseWindow = Notification.Name("tomeOpenPauseWindow")
104101
}

Tome/Models/AppState.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,24 @@ class AppState: ObservableObject {
1414
@Published var pauseRequestActive: Bool = false
1515
@Published var pauseRequestEndsAt: Date? = nil
1616

17+
// countdown expired — waiting for user to confirm duration
18+
@Published var pendingPauseConfirmation: Bool = false
19+
1720
var isActivelyBlocking: Bool {
18-
return isBlocking && !isPaused
21+
isBlocking && !isPaused
1922
}
2023

2124
var canEditPreferences: Bool {
22-
return !isActivelyBlocking
25+
!isActivelyBlocking
2326
}
2427

2528
var canToggleLockedMode: Bool {
26-
return !isActivelyBlocking
29+
!isActivelyBlocking
30+
}
31+
32+
// pause window should be accessible whenever something is happening
33+
var pauseWindowEnabled: Bool {
34+
isBlocking || isPaused || pauseRequestActive || pendingPauseConfirmation
2735
}
2836

2937
private init() {

0 commit comments

Comments
 (0)