303 lines
10 KiB
Swift
303 lines
10 KiB
Swift
import AppKit
|
|
|
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
// Configuration
|
|
var isDebugMode: Bool = false
|
|
var workTimerDuration: Int {
|
|
return isDebugMode ? 60 : 1500 // Debug: 1 min, Normal: 25 min
|
|
}
|
|
var countdownDuration: Int = 300 // 5 minutes of escalating flashes
|
|
|
|
// Status bar
|
|
var statusItem: NSStatusItem!
|
|
var statusMenuItem: NSMenuItem!
|
|
var debugMenuItem: NSMenuItem!
|
|
var launchAtLoginMenuItem: NSMenuItem!
|
|
var pauseMenuItem: NSMenuItem!
|
|
var settingsMenuBuilder: SettingsMenuBuilder!
|
|
|
|
// Overlay windows
|
|
var overlayWindows: [NSWindow] = []
|
|
var countdownWindows: [NSWindow] = []
|
|
var countdownLabels: [NSTextField] = []
|
|
|
|
// Timers
|
|
var countdownTimer: Timer?
|
|
var breakTimer: Timer?
|
|
|
|
// State
|
|
var remainingSeconds: Int = 0
|
|
var isSolid = false
|
|
var isPaused = false
|
|
var currentSuggestion = ""
|
|
var breakRemainingSeconds: Int = 300
|
|
var breakCountdownLabels: [NSTextField] = []
|
|
|
|
// Work log tracking
|
|
var currentIntervalStartDate: Date?
|
|
var currentIntent: String?
|
|
var intervalCompleted: Bool = false
|
|
var currentSuggestionFeedback: SuggestionFeedback?
|
|
var currentWellnessReflection: WellnessReflection?
|
|
|
|
// Text fields on ready screen (held for reading values)
|
|
var reflectionField: NSTextField?
|
|
var intentField: NSTextField?
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
WorkLogStore.shared.clearOldEntries()
|
|
if let saved = TimerPersistence.restore() {
|
|
remainingSeconds = min(saved.remainingSeconds, workTimerDuration)
|
|
isPaused = saved.isPaused
|
|
currentIntervalStartDate = saved.intervalStartDate
|
|
} else {
|
|
remainingSeconds = workTimerDuration
|
|
currentIntervalStartDate = Date()
|
|
}
|
|
setupStatusBar()
|
|
setupWindows()
|
|
startCountdown()
|
|
updateStatusBar()
|
|
NotificationManager.shared.requestPermission()
|
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self, selector: #selector(settingsDidChange),
|
|
name: Settings.didChangeNotification, object: nil
|
|
)
|
|
}
|
|
|
|
func applicationWillTerminate(_ notification: Notification) {
|
|
TimerPersistence.save(remainingSeconds: remainingSeconds, isPaused: isPaused, intervalStartDate: currentIntervalStartDate)
|
|
}
|
|
|
|
@objc private func settingsDidChange() {
|
|
let color = Settings.shared.gradientColor
|
|
for label in countdownLabels {
|
|
label.textColor = color
|
|
}
|
|
for window in overlayWindows {
|
|
guard let view = window.contentView as? EdgeGradientView else { continue }
|
|
view.color = color
|
|
view.needsDisplay = true
|
|
}
|
|
}
|
|
|
|
func startCountdown() {
|
|
updateStatusBar()
|
|
|
|
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
|
|
guard let self = self else { timer.invalidate(); return }
|
|
if self.isPaused { return }
|
|
self.remainingSeconds -= 1
|
|
self.updateStatusBar()
|
|
|
|
if CountdownPulseSchedule.isInCountdownPhase(remainingSeconds: self.remainingSeconds, countdownDuration: self.countdownDuration, workTimerDuration: self.workTimerDuration) {
|
|
let effective = min(self.countdownDuration, self.workTimerDuration)
|
|
self.applyPulseSchedule(remainingSeconds: self.remainingSeconds, totalSeconds: effective)
|
|
if self.remainingSeconds <= 10 {
|
|
self.showCountdownPills()
|
|
self.updateCountdownLabels()
|
|
}
|
|
}
|
|
|
|
if self.remainingSeconds <= 0 {
|
|
self.intervalCompleted = true
|
|
self.showFullOverlay()
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func togglePause() {
|
|
isPaused.toggle()
|
|
updateStatusBar()
|
|
}
|
|
|
|
@objc func restartTimer() {
|
|
countdownTimer?.invalidate()
|
|
remainingSeconds = workTimerDuration
|
|
currentIntervalStartDate = Date()
|
|
intervalCompleted = false
|
|
isPaused = false
|
|
isSolid = false
|
|
resetOverlayToGradient()
|
|
hideCountdownPills()
|
|
for window in countdownWindows { window.orderFrontRegardless() }
|
|
startCountdown()
|
|
updateStatusBar()
|
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
|
}
|
|
|
|
@objc func pauseFromOverlay() {
|
|
saveCurrentInterval()
|
|
isPaused = true
|
|
breakTimer?.invalidate()
|
|
breakCountdownLabels.removeAll()
|
|
// Only reset to full duration if the interval actually completed;
|
|
// otherwise preserve remaining time so the user can resume mid-countdown.
|
|
if remainingSeconds <= 0 {
|
|
remainingSeconds = workTimerDuration
|
|
currentIntervalStartDate = Date()
|
|
currentIntent = nil
|
|
intervalCompleted = false
|
|
}
|
|
isSolid = false
|
|
resetOverlayToGradient()
|
|
hideCountdownPills()
|
|
for window in countdownWindows { window.orderFrontRegardless() }
|
|
startCountdown()
|
|
updateStatusBar()
|
|
}
|
|
|
|
@objc func toggleDebugMode() {
|
|
isDebugMode.toggle()
|
|
debugMenuItem.state = isDebugMode ? .on : .off
|
|
|
|
countdownTimer?.invalidate()
|
|
breakTimer?.invalidate()
|
|
breakCountdownLabels.removeAll()
|
|
remainingSeconds = workTimerDuration
|
|
isSolid = false
|
|
isPaused = false
|
|
|
|
currentIntervalStartDate = Date()
|
|
currentIntent = nil
|
|
intervalCompleted = false
|
|
|
|
resetOverlayToGradient()
|
|
hideCountdownPills()
|
|
for window in countdownWindows { window.orderFrontRegardless() }
|
|
|
|
startCountdown()
|
|
}
|
|
|
|
@objc func toggleLaunchAtLogin() {
|
|
if LaunchAgent.isInstalled() {
|
|
LaunchAgent.uninstall()
|
|
launchAtLoginMenuItem.state = .off
|
|
} else {
|
|
LaunchAgent.install()
|
|
launchAtLoginMenuItem.state = .on
|
|
}
|
|
}
|
|
|
|
@objc func closePommedoro() {
|
|
saveCurrentInterval()
|
|
countdownTimer?.invalidate()
|
|
breakTimer?.invalidate()
|
|
for window in overlayWindows { window.orderOut(nil) }
|
|
for window in countdownWindows { window.orderOut(nil) }
|
|
NSApp.terminate(nil)
|
|
}
|
|
|
|
@objc func resumePommedoro() {
|
|
saveCurrentInterval()
|
|
|
|
isPaused = false
|
|
isSolid = false
|
|
breakTimer?.invalidate()
|
|
breakCountdownLabels.removeAll()
|
|
|
|
// Only reset to full duration if the interval completed;
|
|
// otherwise preserve remaining time so the user resumes mid-countdown.
|
|
if remainingSeconds <= 0 || intervalCompleted {
|
|
remainingSeconds = workTimerDuration
|
|
intervalCompleted = false
|
|
currentIntervalStartDate = Date()
|
|
// Carry forward the next intent
|
|
let nextIntent = intentField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
currentIntent = (nextIntent?.isEmpty == false) ? nextIntent : nil
|
|
}
|
|
reflectionField = nil
|
|
intentField = nil
|
|
|
|
resetOverlayToGradient()
|
|
hideCountdownPills()
|
|
for window in countdownWindows { window.orderFrontRegardless() }
|
|
|
|
startCountdown()
|
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
|
}
|
|
|
|
@objc func dismissSuggestion() {
|
|
currentSuggestionFeedback = .dismissed
|
|
SuggestionManager.shared.dismiss(suggestion: currentSuggestion)
|
|
resumePommedoro()
|
|
}
|
|
|
|
// MARK: - Work Log Helpers
|
|
|
|
func saveCurrentInterval() {
|
|
guard let startDate = currentIntervalStartDate else { return }
|
|
let elapsed = Int(Date().timeIntervalSince(startDate))
|
|
let reflection = reflectionField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let entry = WorkLogEntry(
|
|
date: startDate,
|
|
durationSeconds: elapsed,
|
|
intent: currentIntent,
|
|
completed: intervalCompleted,
|
|
reflection: (reflection?.isEmpty == false) ? reflection : nil,
|
|
additionalNotes: nil,
|
|
suggestion: currentSuggestion.isEmpty ? nil : currentSuggestion,
|
|
suggestionFeedback: currentSuggestionFeedback,
|
|
wellnessReflection: currentWellnessReflection
|
|
)
|
|
WorkLogStore.shared.addEntry(entry)
|
|
currentIntervalStartDate = nil
|
|
currentSuggestionFeedback = nil
|
|
currentWellnessReflection = nil
|
|
}
|
|
|
|
@objc func showWorkLog() {
|
|
// Only save if meaningful work was done; skip trivial 0-second partials
|
|
// so that viewing the log doesn't create throwaway entries.
|
|
if let start = currentIntervalStartDate, Int(Date().timeIntervalSince(start)) >= 5 {
|
|
saveCurrentInterval()
|
|
}
|
|
countdownTimer?.invalidate()
|
|
breakTimer?.invalidate()
|
|
breakCountdownLabels.removeAll()
|
|
|
|
for (i, window) in overlayWindows.enumerated() {
|
|
window.ignoresMouseEvents = false
|
|
WorkLogViewBuilder.build(in: window, isPrimary: i == 0, delegate: self)
|
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
ctx.duration = 0.3
|
|
window.animator().alphaValue = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func dismissWorkLogAndResume() {
|
|
isPaused = false
|
|
remainingSeconds = workTimerDuration
|
|
isSolid = false
|
|
intervalCompleted = false
|
|
breakTimer?.invalidate()
|
|
breakCountdownLabels.removeAll()
|
|
|
|
// Preserve currentIntent so it carries into the next interval
|
|
currentIntervalStartDate = Date()
|
|
reflectionField = nil
|
|
intentField = nil
|
|
|
|
resetOverlayToGradient()
|
|
hideCountdownPills()
|
|
for window in countdownWindows { window.orderFrontRegardless() }
|
|
|
|
startCountdown()
|
|
NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60)
|
|
}
|
|
|
|
@objc func copyWorkLog() {
|
|
let text = WorkLogStore.shared.todayLogAsText()
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
}
|
|
|
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
|
return false
|
|
}
|
|
}
|