From 7dc982a89a39ab6c1c6ec9a0afd0853fe82560d6 Mon Sep 17 00:00:00 2001 From: Abdel Mizraim Cervantes Garcia <113736749+AMCGedu@users.noreply.github.com> Date: Tue, 18 Nov 2025 02:44:36 +0000 Subject: [PATCH] Initial commit --- boringNotch/ContentView.swift | 5 + .../components/PomodoroCompactView.swift | 27 ++++ boringNotch/components/PomodoroView.swift | 97 +++++++++++++ boringNotch/extensions/PomodoroDefaults.swift | 11 ++ boringNotch/managers/PomodoroManager.swift | 134 ++++++++++++++++++ boringNotch/menu/StatusBarMenu.swift | 54 +++++-- 6 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 boringNotch/components/PomodoroCompactView.swift create mode 100644 boringNotch/components/PomodoroView.swift create mode 100644 boringNotch/extensions/PomodoroDefaults.swift create mode 100644 boringNotch/managers/PomodoroManager.swift diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index a2e4676d..1bca48c3 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -20,6 +20,7 @@ struct ContentView: View { @ObservedObject var coordinator = BoringViewCoordinator.shared @ObservedObject var musicManager = MusicManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared + @ObservedObject var pomodoroManager = PomodoroManager.shared @State private var isHovering: Bool = false @State private var hoverWorkItem: DispatchWorkItem? @@ -234,6 +235,10 @@ struct ContentView: View { .transition(.opacity) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { MusicLiveActivity() + } else if !coordinator.expandingView.show && vm.notchState == .closed && pomodoroManager.isRunning && !vm.hideOnClosed { + PomodoroCompactView() + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .transition(.opacity) } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { BoringFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { diff --git a/boringNotch/components/PomodoroCompactView.swift b/boringNotch/components/PomodoroCompactView.swift new file mode 100644 index 00000000..6b823a16 --- /dev/null +++ b/boringNotch/components/PomodoroCompactView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct PomodoroCompactView: View { + @ObservedObject var manager = PomodoroManager.shared + + var body: some View { + HStack(spacing: 6) { + Image(systemName: manager.isWorkPhase ? "hourglass" : "cup.and.saucer") + .font(.system(size: 10, weight: .semibold)) + Text(manager.formattedRemaining()) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .monospacedDigit() + .foregroundStyle(manager.isWorkPhase ? .white : .green) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.black.opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(Rectangle()) + .help("Pomodoro activo: clic en menú para abrir") + .accessibilityLabel("Pomodoro restante") + .accessibilityValue(manager.formattedRemaining()) + .animation(.smooth, value: manager.isWorkPhase) + } +} + +#Preview { PomodoroCompactView() } diff --git a/boringNotch/components/PomodoroView.swift b/boringNotch/components/PomodoroView.swift new file mode 100644 index 00000000..74b743e1 --- /dev/null +++ b/boringNotch/components/PomodoroView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct PomodoroView: View { + @ObservedObject var manager = PomodoroManager.shared + // Estados locales en minutos para edición + @State private var workMinutes: Int = 25 + @State private var breakMinutes: Int = 5 + @State private var longBreakMinutes: Int = 15 + @State private var useLongBreak: Bool = true + @State private var longBreakAfter: Int = 4 + @State private var showSettings: Bool = false + + var body: some View { + VStack(spacing: 20) { + // Temporizador principal + VStack(spacing: 8) { + Text(manager.isWorkPhase ? "Trabajo" : "Descanso") + .font(.title3).bold() + Text(manager.formattedRemaining()) + .font(.system(size: 42, weight: .bold, design: .rounded)) + .monospacedDigit() + ProgressView(value: progressValue) + .progressViewStyle(.linear) + } + + HStack(spacing: 12) { + Button(manager.isRunning ? "Pausar" : "Iniciar") { + manager.isRunning ? manager.pause() : manager.start() + } + Button("Reset") { manager.reset() } + Button(showSettings ? "Ocultar" : "Config") { + toggleSettings() + } + } + .buttonStyle(.borderedProminent) + + Text("Sesiones completadas: \(manager.completedWorkSessions)") + .font(.caption) + .foregroundStyle(.secondary) + + if showSettings { settingsSection } + } + .onAppear(perform: syncFromManager) + .padding(20) + .frame(width: 320) + } + + private var settingsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Configuración") + .font(.headline) + durationRow(title: "Trabajo", minutes: $workMinutes, range: 5...120) + durationRow(title: "Descanso corto", minutes: $breakMinutes, range: 1...30) + Toggle("Usar descanso largo", isOn: $useLongBreak) + if useLongBreak { + durationRow(title: "Descanso largo", minutes: $longBreakMinutes, range: 5...60) + Stepper(value: $longBreakAfter, in: 2...10) { + Text("Cada \(longBreakAfter) sesiones") + } + } + Button("Guardar cambios") { persistSettings() } + .buttonStyle(.bordered) + } + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + private func durationRow(title: String, minutes: Binding, range: ClosedRange) -> some View { + HStack { + Text(title).frame(width: 110, alignment: .leading) + Stepper(value: minutes, in: range) { + Text("\(minutes.wrappedValue) min") + } + } + } + + private func syncFromManager() { + workMinutes = Int(manager.workDuration / 60) + breakMinutes = Int(manager.breakDuration / 60) + longBreakMinutes = Int(manager.longBreakDuration / 60) + useLongBreak = manager.useLongBreak + longBreakAfter = manager.longBreakAfter + } + + private func persistSettings() { + manager.updateDurations(work: workMinutes, shortBreak: breakMinutes, longBreak: longBreakMinutes, useLong: useLongBreak, longAfter: longBreakAfter) + } + + private func toggleSettings() { withAnimation { showSettings.toggle() } } + + var progressValue: Double { + let total = manager.isWorkPhase ? manager.workDuration : (manager.useLongBreak && manager.completedWorkSessions % manager.longBreakAfter == 0 && !manager.isWorkPhase ? manager.longBreakDuration : manager.breakDuration) + guard total > 0 else { return 0 } + return 1 - (manager.remaining / total) + } +} + +#Preview { PomodoroView() } diff --git a/boringNotch/extensions/PomodoroDefaults.swift b/boringNotch/extensions/PomodoroDefaults.swift new file mode 100644 index 00000000..580f4734 --- /dev/null +++ b/boringNotch/extensions/PomodoroDefaults.swift @@ -0,0 +1,11 @@ +import Foundation +import Defaults + +// Claves de configuración para el temporizador Pomodoro +extension Defaults.Keys { + static let pomodoroWorkDuration = Key("pomodoroWorkDuration", default: 25 * 60) + static let pomodoroBreakDuration = Key("pomodoroBreakDuration", default: 5 * 60) + static let pomodoroLongBreakDuration = Key("pomodoroLongBreakDuration", default: 15 * 60) + static let pomodoroUseLongBreak = Key("pomodoroUseLongBreak", default: true) + static let pomodoroLongBreakAfter = Key("pomodoroLongBreakAfter", default: 4) +} diff --git a/boringNotch/managers/PomodoroManager.swift b/boringNotch/managers/PomodoroManager.swift new file mode 100644 index 00000000..0b06fa09 --- /dev/null +++ b/boringNotch/managers/PomodoroManager.swift @@ -0,0 +1,134 @@ +import Foundation +import Combine +import SwiftUI +import UserNotifications +import Defaults + +// Manager Pomodoro con soporte de descanso largo y notificaciones locales. +class PomodoroManager: ObservableObject { + static let shared = PomodoroManager() + + // Duraciones (segundos) cargadas desde Defaults + @Published var workDuration: TimeInterval = Defaults[.pomodoroWorkDuration] + @Published var breakDuration: TimeInterval = Defaults[.pomodoroBreakDuration] + @Published var longBreakDuration: TimeInterval = Defaults[.pomodoroLongBreakDuration] + @Published var useLongBreak: Bool = Defaults[.pomodoroUseLongBreak] + @Published var longBreakAfter: Int = Defaults[.pomodoroLongBreakAfter] + + // Estado dinámico + @Published var remaining: TimeInterval = Defaults[.pomodoroWorkDuration] + @Published var isRunning: Bool = false + @Published var isWorkPhase: Bool = true + @Published var completedWorkSessions: Int = 0 + + private var timer: Timer? + private var cancellables: Set = [] + + init() { + observeDefaults() + requestNotificationPermission() + } + + // Observa cambios en Defaults y aplica nuevas duraciones sin romper el estado actual. + private func observeDefaults() { + Defaults.publisher(.pomodoroWorkDuration).map(\. + newValue).sink { [weak self] new in self?.workDuration = new }.store(in: &cancellables) + Defaults.publisher(.pomodoroBreakDuration).map(\. + newValue).sink { [weak self] new in self?.breakDuration = new }.store(in: &cancellables) + Defaults.publisher(.pomodoroLongBreakDuration).map(\. + newValue).sink { [weak self] new in self?.longBreakDuration = new }.store(in: &cancellables) + Defaults.publisher(.pomodoroUseLongBreak).map(\. + newValue).sink { [weak self] new in self?.useLongBreak = new }.store(in: &cancellables) + Defaults.publisher(.pomodoroLongBreakAfter).map(\. + newValue).sink { [weak self] new in self?.longBreakAfter = new }.store(in: &cancellables) + } + + // Solicita permiso para notificaciones locales una vez. + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + private func sendNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = UNNotificationSound.default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request, withCompletionHandler: nil) + } + + // Inicia o continúa + func start() { + guard !isRunning else { return } + isRunning = true + scheduleTimer() + } + + // Pausa sin perder el tiempo restante + func pause() { + isRunning = false + timer?.invalidate() + } + + // Reinicia el ciclo actual (mantiene fase) + func reset() { + timer?.invalidate() + isRunning = false + remaining = isWorkPhase ? workDuration : currentBreakDuration() + } + + // Determina duración del descanso según si toca descanso largo. + private func currentBreakDuration() -> TimeInterval { + if useLongBreak && completedWorkSessions > 0 && completedWorkSessions % longBreakAfter == 0 { + return longBreakDuration + } + return breakDuration + } + + // Avanza de fase y dispara notificación. + private func advancePhase() { + if isWorkPhase { + completedWorkSessions += 1 + sendNotification(title: "Bloque completado", body: "Tiempo de descanso") + } else { + sendNotification(title: "Descanso terminado", body: "Vuelve al enfoque") + } + isWorkPhase.toggle() + remaining = isWorkPhase ? workDuration : currentBreakDuration() + start() // auto start siguiente fase + } + + private func scheduleTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self = self else { return } + guard self.isRunning else { return } + if self.remaining > 0 { + self.remaining -= 1 + } else { + self.isRunning = false + self.timer?.invalidate() + self.advancePhase() + } + } + if let t = timer { RunLoop.current.add(t, forMode: .common) } + } + + // Actualiza duraciones manualmente y persiste en Defaults. + func updateDurations(work: Int, shortBreak: Int, longBreak: Int, useLong: Bool, longAfter: Int) { + Defaults[.pomodoroWorkDuration] = Double(work * 60) + Defaults[.pomodoroBreakDuration] = Double(shortBreak * 60) + Defaults[.pomodoroLongBreakDuration] = Double(longBreak * 60) + Defaults[.pomodoroUseLongBreak] = useLong + Defaults[.pomodoroLongBreakAfter] = longAfter + // Si no está corriendo, refleja inmediatamente en remaining. + if !isRunning { remaining = isWorkPhase ? workDuration : currentBreakDuration() } + } + + // Formatea mm:ss para la vista + func formattedRemaining() -> String { + let minutes = Int(remaining) / 60 + let seconds = Int(remaining) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } +} diff --git a/boringNotch/menu/StatusBarMenu.swift b/boringNotch/menu/StatusBarMenu.swift index 2bcbae2e..91aa5c04 100644 --- a/boringNotch/menu/StatusBarMenu.swift +++ b/boringNotch/menu/StatusBarMenu.swift @@ -1,24 +1,62 @@ import Cocoa +import SwiftUI class BoringStatusMenu: NSMenu { - var statusItem: NSStatusItem! + private var pomodoroWindow: NSWindow? override init() { super.init() - - // Initialize the status item + setupStatusItem() + buildMenu() + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupStatusItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - if let button = statusItem.button { button.image = NSImage(systemSymbolName: "music.note", accessibilityDescription: "BoringNotch") button.action = #selector(showMenu) } - - // Set up the menu + } + + private func buildMenu() { let menu = NSMenu() - menu.addItem(NSMenuItem(title: "Quit", action: #selector(quitAction), keyEquivalent: "q")) + let pomodoroItem = NSMenuItem(title: "Pomodoro Timer", action: #selector(openPomodoro), keyEquivalent: "p") + pomodoroItem.target = self + menu.addItem(pomodoroItem) + menu.addItem(.separator()) + let quitItem = NSMenuItem(title: "Quit", action: #selector(quitAction), keyEquivalent: "q") + quitItem.target = self + menu.addItem(quitItem) statusItem.menu = menu } - + + @objc private func showMenu() { + statusItem.button?.performClick(nil) + } + + @objc private func quitAction() { + NSApp.terminate(nil) + } + + @objc private func openPomodoro() { + if let window = pomodoroWindow, window.isVisible { + window.close() + pomodoroWindow = nil + return + } + let hosting = NSHostingController(rootView: PomodoroView()) + let window = NSWindow(contentViewController: hosting) + window.title = "Pomodoro" + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + pomodoroWindow = window + NSApp.activate(ignoringOtherApps: true) + } }