Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions boringNotch/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions boringNotch/components/PomodoroCompactView.swift
Original file line number Diff line number Diff line change
@@ -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() }
97 changes: 97 additions & 0 deletions boringNotch/components/PomodoroView.swift
Original file line number Diff line number Diff line change
@@ -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<Int>, range: ClosedRange<Int>) -> 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() }
11 changes: 11 additions & 0 deletions boringNotch/extensions/PomodoroDefaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation
import Defaults

// Claves de configuración para el temporizador Pomodoro
extension Defaults.Keys {
static let pomodoroWorkDuration = Key<Double>("pomodoroWorkDuration", default: 25 * 60)
static let pomodoroBreakDuration = Key<Double>("pomodoroBreakDuration", default: 5 * 60)
static let pomodoroLongBreakDuration = Key<Double>("pomodoroLongBreakDuration", default: 15 * 60)
static let pomodoroUseLongBreak = Key<Bool>("pomodoroUseLongBreak", default: true)
static let pomodoroLongBreakAfter = Key<Int>("pomodoroLongBreakAfter", default: 4)
}
134 changes: 134 additions & 0 deletions boringNotch/managers/PomodoroManager.swift
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable> = []

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)
}
}
54 changes: 46 additions & 8 deletions boringNotch/menu/StatusBarMenu.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}