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 } }