From 75d604460278126728c23c5ba8bd5c185c1ed28d Mon Sep 17 00:00:00 2001 From: akshay ashok Date: Tue, 23 Dec 2025 07:35:40 +0530 Subject: [PATCH] #37 Replace Current Timer UI with CircularTimerPanel --- .../devfocus/model/PomodoroMode.kt | 24 ++-- .../devfocus/model/PomodoroSettings.kt | 4 +- .../services/pomodoro/PomodoroTimerService.kt | 46 +++++-- .../toolWindow/PomodoroToolWindowPanel.kt | 118 +++++++++++++++--- .../ui/components/CircularTimerPanel.kt | 76 +++++++++++ .../ui/components/SessionIndicatorPanel.kt | 76 +++++++++++ .../devfocus/util/SettingsValidator.kt | 5 +- 7 files changed, 317 insertions(+), 32 deletions(-) create mode 100644 src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/CircularTimerPanel.kt create mode 100644 src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/SessionIndicatorPanel.kt diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/model/PomodoroMode.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/model/PomodoroMode.kt index d230a39..374ea81 100644 --- a/src/main/kotlin/com/github/akshayashokcode/devfocus/model/PomodoroMode.kt +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/model/PomodoroMode.kt @@ -5,28 +5,36 @@ enum class PomodoroMode( val emoji: String, val sessionMinutes: Int, val breakMinutes: Int, - val sessionsPerRound: Int + val sessionsPerRound: Int, + val longBreakMinutes: Int, + val longBreakAfter: Int ) { CLASSIC( displayName = "Classic Pomodoro", emoji = "🍅", sessionMinutes = 25, breakMinutes = 5, - sessionsPerRound = 4 + sessionsPerRound = 4, + longBreakMinutes = 15, + longBreakAfter = 4 ), DEEP_WORK( displayName = "Deep Work", emoji = "⚡", - sessionMinutes = 52, - breakMinutes = 17, - sessionsPerRound = 3 + sessionMinutes = 50, + breakMinutes = 10, + sessionsPerRound = 2, + longBreakMinutes = 30, + longBreakAfter = 2 ), CUSTOM( displayName = "Custom", emoji = "⚙️", sessionMinutes = 25, breakMinutes = 5, - sessionsPerRound = 4 + sessionsPerRound = 4, + longBreakMinutes = 15, + longBreakAfter = 4 ); fun toSettings(): PomodoroSettings { @@ -34,7 +42,9 @@ enum class PomodoroMode( mode = this, sessionMinutes = sessionMinutes, breakMinutes = breakMinutes, - sessionsPerRound = sessionsPerRound + sessionsPerRound = sessionsPerRound, + longBreakMinutes = longBreakMinutes, + longBreakAfter = longBreakAfter ) } diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/model/PomodoroSettings.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/model/PomodoroSettings.kt index 62e83df..77e08db 100644 --- a/src/main/kotlin/com/github/akshayashokcode/devfocus/model/PomodoroSettings.kt +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/model/PomodoroSettings.kt @@ -4,5 +4,7 @@ data class PomodoroSettings( val mode: PomodoroMode = PomodoroMode.CLASSIC, val sessionMinutes: Int, val breakMinutes: Int, - val sessionsPerRound: Int + val sessionsPerRound: Int, + val longBreakMinutes: Int = breakMinutes, + val longBreakAfter: Int = sessionsPerRound ) diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/services/pomodoro/PomodoroTimerService.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/services/pomodoro/PomodoroTimerService.kt index 48206b6..ba2fb4c 100644 --- a/src/main/kotlin/com/github/akshayashokcode/devfocus/services/pomodoro/PomodoroTimerService.kt +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/services/pomodoro/PomodoroTimerService.kt @@ -1,5 +1,6 @@ package com.github.akshayashokcode.devfocus.services.pomodoro +import com.github.akshayashokcode.devfocus.model.PomodoroMode import com.github.akshayashokcode.devfocus.model.PomodoroSettings import com.intellij.openapi.components.Service import kotlinx.coroutines.CoroutineScope @@ -16,28 +17,35 @@ import java.util.concurrent.TimeUnit @Service(Service.Level.PROJECT) class PomodoroTimerService { companion object { - private const val DEFAULT_MINUTES = 25 private const val ONE_SECOND = 1000L } enum class TimerState { IDLE, RUNNING, PAUSED } private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private var remainingTimeMs: Long = TimeUnit.MINUTES.toMillis(DEFAULT_MINUTES.toLong()) private var job: Job? = null + private var settings = PomodoroMode.CLASSIC.toSettings() + private var remainingTimeMs: Long = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong()) + private val _timeLeft = MutableStateFlow(formatTime(remainingTimeMs)) val timeLeft: StateFlow = _timeLeft private val _state = MutableStateFlow(TimerState.IDLE) val state: StateFlow = _state - private var settings = PomodoroSettings(25, 5, 4) // default + private val _currentSession = MutableStateFlow(1) + val currentSession: StateFlow = _currentSession + + private val _settings = MutableStateFlow(settings) + val settingsFlow: StateFlow = _settings fun start() { if (_state.value == TimerState.RUNNING) return + // Cancel any existing job to ensure only one timer is running + job?.cancel() + _state.value = TimerState.RUNNING job = coroutineScope.launch { while (remainingTimeMs > 0 && isActive) { @@ -59,9 +67,14 @@ class PomodoroTimerService { } fun reset() { + // Cancel any running job job?.cancel() - remainingTimeMs = TimeUnit.MINUTES.toMillis(DEFAULT_MINUTES.toLong()) + job = null + + // Reset to initial state + remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong()) _timeLeft.value = formatTime(remainingTimeMs) + _currentSession.value = 1 _state.value = TimerState.IDLE } @@ -72,10 +85,27 @@ class PomodoroTimerService { return String.format("%02d:%02d", minutes, seconds) } - fun applySettings(settings: PomodoroSettings) { - this.settings = settings - remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong()) + fun applySettings(newSettings: PomodoroSettings) { + // Cancel any running job when settings change + job?.cancel() + job = null + + settings = newSettings + _settings.value = newSettings + remainingTimeMs = TimeUnit.MINUTES.toMillis(newSettings.sessionMinutes.toLong()) _timeLeft.value = formatTime(remainingTimeMs) + _currentSession.value = 1 _state.value = TimerState.IDLE } + + fun applyMode(mode: PomodoroMode) { + applySettings(mode.toSettings()) + } + + fun getSettings(): PomodoroSettings = settings + + fun getProgress(): Float { + val totalMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong()) + return if (totalMs > 0) remainingTimeMs.toFloat() / totalMs.toFloat() else 0f + } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/toolWindow/PomodoroToolWindowPanel.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/toolWindow/PomodoroToolWindowPanel.kt index 82ec8a7..29c734c 100644 --- a/src/main/kotlin/com/github/akshayashokcode/devfocus/toolWindow/PomodoroToolWindowPanel.kt +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/toolWindow/PomodoroToolWindowPanel.kt @@ -1,67 +1,145 @@ package com.github.akshayashokcode.devfocus.toolWindow +import com.github.akshayashokcode.devfocus.model.PomodoroMode import com.github.akshayashokcode.devfocus.model.PomodoroSettings import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService +import com.github.akshayashokcode.devfocus.ui.components.CircularTimerPanel +import com.github.akshayashokcode.devfocus.ui.components.SessionIndicatorPanel import com.github.akshayashokcode.devfocus.ui.settings.PomodoroSettingsPanel import com.intellij.openapi.project.Project import com.intellij.ui.components.JBPanel import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest -import java.awt.BorderLayout -import java.awt.FlowLayout +import java.awt.* import javax.swing.* class PomodoroToolWindowPanel(private val project: Project) : JBPanel>(BorderLayout()) { private val timerService = project.getService(PomodoroTimerService::class.java) ?: error("PomodoroTimerService not available") - private val timeLabel = JLabel("25:00").apply { + // Mode selector + private val modeComboBox = JComboBox(PomodoroMode.entries.toTypedArray()).apply { + selectedItem = PomodoroMode.CLASSIC + } + + // Info label showing current mode settings + private val infoLabel = JLabel("📊 25 min work • 5 min break").apply { horizontalAlignment = SwingConstants.CENTER - font = font.deriveFont(32f) + font = font.deriveFont(Font.PLAIN, 12f) } + // Circular timer display + private val circularTimer = CircularTimerPanel() + + // Session indicator with tomato icons + private val sessionIndicator = SessionIndicatorPanel() + + // Control buttons private val startButton = JButton("Start") private val pauseButton = JButton("Pause") private val resetButton = JButton("Reset") + // Custom settings panel (only visible when Custom mode selected) private val settingsPanel = PomodoroSettingsPanel { session, breakTime, sessions -> - timerService.applySettings(PomodoroSettings(session, breakTime, sessions)) + timerService.applySettings(PomodoroSettings(PomodoroMode.CUSTOM, session, breakTime, sessions)) + updateInfoLabel(session, breakTime) + updateProgressBar(sessions) } private val scope = CoroutineScope(Dispatchers.Default) private var stateJob: Job? = null private var timeJob: Job? = null + private var sessionJob: Job? = null init { - val buttonPanel = JPanel(FlowLayout()).apply { + buildUI() + setupListeners() + observeTimer() + updateSettingsPanelVisibility() + } + + private fun buildUI() { + // Top panel with mode selector + val topPanel = JPanel(BorderLayout(5, 5)).apply { + border = BorderFactory.createEmptyBorder(10, 10, 5, 10) + add(modeComboBox, BorderLayout.CENTER) + } + + // Info panel + val infoPanel = JPanel(FlowLayout(FlowLayout.CENTER)).apply { + add(infoLabel) + } + + // Timer panel + val timerPanel = JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(20, 10, 20, 10) + add(circularTimer, BorderLayout.CENTER) + } + + // Progress panel + val progressPanel = JPanel(BorderLayout(5, 5)).apply { + border = BorderFactory.createEmptyBorder(0, 20, 10, 20) + add(sessionIndicator, BorderLayout.CENTER) + } + + // Button panel + val buttonPanel = JPanel(FlowLayout(FlowLayout.CENTER, 10, 5)).apply { add(startButton) add(pauseButton) add(resetButton) } - val centerPanel = JPanel(BorderLayout()).apply { - add(timeLabel, BorderLayout.CENTER) - add(buttonPanel, BorderLayout.SOUTH) + // Center content + val centerPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(infoPanel) + add(timerPanel) + add(progressPanel) + add(buttonPanel) } + add(topPanel, BorderLayout.NORTH) add(centerPanel, BorderLayout.CENTER) add(settingsPanel, BorderLayout.SOUTH) - - setupListeners() - observeTimer() } private fun setupListeners() { startButton.addActionListener { timerService.start() } pauseButton.addActionListener { timerService.pause() } resetButton.addActionListener { timerService.reset() } + + modeComboBox.addActionListener { + val selectedMode = modeComboBox.selectedItem as PomodoroMode + if (selectedMode != PomodoroMode.CUSTOM) { + timerService.applyMode(selectedMode) + updateInfoLabel(selectedMode.sessionMinutes, selectedMode.breakMinutes) + updateProgressBar(selectedMode.sessionsPerRound) + } + updateSettingsPanelVisibility() + } + } + + private fun updateSettingsPanelVisibility() { + val isCustom = modeComboBox.selectedItem == PomodoroMode.CUSTOM + settingsPanel.isVisible = isCustom + revalidate() + repaint() + } + + private fun updateInfoLabel(sessionMin: Int, breakMin: Int) { + infoLabel.text = "📊 $sessionMin min work • $breakMin min break" + } + + private fun updateProgressBar(totalSessions: Int) { + sessionIndicator.updateSessions(timerService.currentSession.value, totalSessions) } private fun observeTimer() { timeJob = scope.launch { - timerService.timeLeft.collectLatest { + timerService.timeLeft.collectLatest { time -> SwingUtilities.invokeLater { - timeLabel.text = it + val progress = timerService.getProgress() + circularTimer.updateTimer(time, progress, false) } } } @@ -72,6 +150,17 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel startButton.isEnabled = it != PomodoroTimerService.TimerState.RUNNING pauseButton.isEnabled = it == PomodoroTimerService.TimerState.RUNNING resetButton.isEnabled = it != PomodoroTimerService.TimerState.IDLE + // Disable mode selector when timer is active (running or paused) + modeComboBox.isEnabled = it == PomodoroTimerService.TimerState.IDLE + } + } + } + + sessionJob = scope.launch { + timerService.currentSession.collectLatest { session -> + SwingUtilities.invokeLater { + val settings = timerService.getSettings() + sessionIndicator.updateSessions(session, settings.sessionsPerRound) } } } @@ -80,6 +169,7 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel fun dispose() { stateJob?.cancel() timeJob?.cancel() + sessionJob?.cancel() scope.cancel() } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/CircularTimerPanel.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/CircularTimerPanel.kt new file mode 100644 index 0000000..1a6a0e4 --- /dev/null +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/CircularTimerPanel.kt @@ -0,0 +1,76 @@ +package com.github.akshayashokcode.devfocus.ui.components + +import java.awt.* +import javax.swing.JPanel + +class CircularTimerPanel : JPanel() { + + private var timeText: String = "25:00" + private var progress: Float = 1.0f // 0.0 to 1.0 (1.0 = full, 0.0 = empty) + private var isBreakTime: Boolean = false + + // Colors following UX best practices + private val workColor = Color(74, 144, 226) // Blue for focus/work + private val breakColor = Color(80, 200, 120) // Green for rest + private val backgroundColor = Color(224, 224, 224) // Light gray + private val textColor = Color(60, 60, 60) // Dark gray for text + + private val diameter = 180 + private val strokeWidth = 10f + + init { + preferredSize = Dimension(diameter + 40, diameter + 40) + isOpaque = false + } + + fun updateTimer(timeText: String, progress: Float, isBreak: Boolean = false) { + this.timeText = timeText + this.progress = progress.coerceIn(0f, 1f) + this.isBreakTime = isBreak + repaint() + } + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + val g2d = g as Graphics2D + + // Enable anti-aliasing for smooth circles + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) + + val centerX = width / 2 + val centerY = height / 2 + val radius = diameter / 2 + + // Calculate bounds for the arc + val arcX = centerX - radius + val arcY = centerY - radius + val arcSize = diameter + + // Draw background circle (full circle in light gray) + g2d.stroke = BasicStroke(strokeWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) + g2d.color = backgroundColor + g2d.drawArc(arcX, arcY, arcSize, arcSize, 0, 360) + + // Draw progress arc (clockwise from top, depleting) + // Start at 90 degrees (top of circle) and go clockwise + val arcAngle = (360 * progress).toInt() + g2d.color = if (isBreakTime) breakColor else workColor + g2d.stroke = BasicStroke(strokeWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) + g2d.drawArc(arcX, arcY, arcSize, arcSize, 90, -arcAngle) // Negative for clockwise + + // Draw time text in center + g2d.color = textColor + val font = Font("SansSerif", Font.BOLD, 36) + g2d.font = font + + val metrics = g2d.fontMetrics + val textWidth = metrics.stringWidth(timeText) + val textHeight = metrics.height + + val textX = centerX - textWidth / 2 + val textY = centerY + textHeight / 4 + + g2d.drawString(timeText, textX, textY) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/SessionIndicatorPanel.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/SessionIndicatorPanel.kt new file mode 100644 index 0000000..a08dfb3 --- /dev/null +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/SessionIndicatorPanel.kt @@ -0,0 +1,76 @@ +package com.github.akshayashokcode.devfocus.ui.components + +import java.awt.* +import javax.swing.JPanel + +class SessionIndicatorPanel : JPanel() { + + private var currentSession: Int = 1 + private var totalSessions: Int = 4 + + private val tomatoSize = 24 + private val spacing = 8 + + init { + isOpaque = false + preferredSize = Dimension(200, 40) + } + + fun updateSessions(current: Int, total: Int) { + this.currentSession = current + this.totalSessions = total + repaint() + } + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + val g2d = g as Graphics2D + + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + val totalWidth = (tomatoSize * totalSessions) + (spacing * (totalSessions - 1)) + val startX = (width - totalWidth) / 2 + val startY = (height - tomatoSize) / 2 + + for (i in 1..totalSessions) { + val x = startX + (i - 1) * (tomatoSize + spacing) + + if (i < currentSession) { + // Completed session - filled tomato + drawFilledTomato(g2d, x, startY) + } else if (i == currentSession) { + // Current session - outlined tomato with pulse effect + drawCurrentTomato(g2d, x, startY) + } else { + // Future session - empty circle + drawEmptyCircle(g2d, x, startY) + } + } + } + + private fun drawFilledTomato(g2d: Graphics2D, x: Int, y: Int) { + g2d.color = Color(231, 76, 60) // Tomato red + g2d.fillOval(x, y, tomatoSize, tomatoSize) + + // Add small leaf/stem on top + g2d.color = Color(46, 204, 113) // Green + val leafSize = 6 + g2d.fillOval(x + tomatoSize / 2 - leafSize / 2, y - 2, leafSize, leafSize) + } + + private fun drawCurrentTomato(g2d: Graphics2D, x: Int, y: Int) { + // Outlined tomato for current session + g2d.color = Color(231, 76, 60) + g2d.stroke = BasicStroke(2.5f) + g2d.drawOval(x + 2, y + 2, tomatoSize - 4, tomatoSize - 4) + + // Small filled center + g2d.fillOval(x + tomatoSize / 2 - 3, y + tomatoSize / 2 - 3, 6, 6) + } + + private fun drawEmptyCircle(g2d: Graphics2D, x: Int, y: Int) { + g2d.color = Color(189, 195, 199) // Light gray + g2d.stroke = BasicStroke(2f) + g2d.drawOval(x + 2, y + 2, tomatoSize - 4, tomatoSize - 4) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/util/SettingsValidator.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/util/SettingsValidator.kt index deb7aed..6688ce9 100644 --- a/src/main/kotlin/com/github/akshayashokcode/devfocus/util/SettingsValidator.kt +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/util/SettingsValidator.kt @@ -1,5 +1,6 @@ package com.github.akshayashokcode.devfocus.util +import com.github.akshayashokcode.devfocus.model.PomodoroMode import com.github.akshayashokcode.devfocus.model.PomodoroSettings sealed class SettingsValidationResult { @@ -13,7 +14,7 @@ fun validateSettings( sessions: Int? ): SettingsValidationResult { if (session == null) return SettingsValidationResult.Invalid("session", "Session duration must be a number.") - if (session !in 5..120) return SettingsValidationResult.Invalid("session", "Session duration must be between 5 and 120 minutes.") + if (session !in 1..120) return SettingsValidationResult.Invalid("session", "Session duration must be between 1 and 120 minutes.") if (breakTime == null) return SettingsValidationResult.Invalid("break", "Break duration must be a number.") if (breakTime !in 1..60) return SettingsValidationResult.Invalid("break", "Break duration must be between 1 and 60 minutes.") @@ -21,5 +22,5 @@ fun validateSettings( if (sessions == null) return SettingsValidationResult.Invalid("sessions", "Sessions per round must be a number.") if (sessions !in 1..10) return SettingsValidationResult.Invalid("sessions", "Sessions per round must be between 1 and 10.") - return SettingsValidationResult.Valid(PomodoroSettings(session, breakTime, sessions)) + return SettingsValidationResult.Valid(PomodoroSettings(PomodoroMode.CUSTOM, session, breakTime, sessions)) } \ No newline at end of file