pommedoro/Sources/Pommedoro/AppDelegate.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
}
}