Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@

## [Unreleased]

## [2.2.0]
### Added
- **End time display** — tool window shows "Ends at X:XX PM" below the phase label and "Free by X:XX PM" below the session counter while the timer is running; both labels hide automatically when paused or idle and recalculate on resume
- **Saved custom presets** — name and save any custom timing configuration from the Custom mode panel; saved presets appear directly in the mode dropdown alongside the built-in modes and are persisted across IDE restarts
- **Customizable ring colors** — Focus and Break accent colors are now configurable via Settings; pick any color from a visual color picker (supports HSB wheel, RGB sliders, and hex input); defaults are restored if settings are cleared

### Changed
- Play and Pause replaced by a single icon toggle button that switches between ▶ and ⏸ based on timer state, with a tooltip of "Start", "Resume", or "Pause" as appropriate
- Reset button updated to use an icon instead of text
- Custom mode duration inputs replaced with step spinners (Session: 5–120 min step 5, Break: 1–60 min step 1, Sessions: 1–10 step 1); spinner model enforces bounds natively, removing the need for validation error dialogs
- Switching to Custom mode in the dropdown now pre-populates the spinners with the currently active session settings

## [2.1.0]
### Changed
- Cleaned up boilerplate scaffold files (MyProjectActivity, MyProjectService)
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Most Pomodoro timers don't survive an IDE restart, skip the long break entirely,

**🔁 Proper long breaks.** After a full round of sessions, DevFocus fires a long break automatically — the defining feature of the Pomodoro technique that most timer plugins quietly omit.

**🕐 Tells you when you'll be done.** Once running, the tool window shows two live timestamps: when the current session ends and when the full round finishes. No mental math — just "Ends at 3:45 PM" and "Free by 5:30 PM" so you can plan your day around your focus blocks.

**⏭ Skip break from the notification.** When you're in flow, click **Skip Break** directly in the IDE notification balloon. No need to open the tool window or break your focus.

**⌨️ Full keyboard control.** Start/Pause, Reset, and Skip Break are registered as IDE actions — assign your own shortcuts via **Settings → Keymap → DevFocus** to avoid conflicts with your existing bindings. All three are also reachable via **Tools → DevFocus** and Find Action (`Ctrl+Shift+A` / `⌘⇧A`).
Expand All @@ -29,8 +31,10 @@ Most Pomodoro timers don't survive an IDE restart, skip the long break entirely,

## Everything else

- **Three modes** — Classic Pomodoro (25/5), Deep Work (50/10), or fully custom durations
- **Three built-in modes** — Classic Pomodoro (25/5), Deep Work (50/10), or fully Custom durations
- **Saved custom presets** — name and save your own timing configurations; switch to them in one click from the mode dropdown
- **Visual circular timer** — arc depletes clockwise, colour-coded by phase
- **Customizable ring colors** — pick any Focus and Break accent color from a color picker in Settings
- **Session indicator** — dot row showing completed, active, and upcoming sessions
- **🍅 Daily session counter** — resets at midnight, shown in tool window and status bar
- **Auto-start toggle** — choose whether work sessions start automatically after a break or wait for you
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pluginGroup = com.github.akshayashokcode.devfocus
pluginName = DevFocus
pluginRepositoryUrl = https://github.com/AkshayAshokCode/DevFocus
# SemVer format -> https://semver.org
pluginVersion = 2.1.0
pluginVersion = 2.2.0

# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 233
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.github.akshayashokcode.devfocus.actions

import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent

/** Reset the timer and session back to the initial state. */
class DevFocusResetAction : AnAction() {

override fun actionPerformed(e: AnActionEvent) {
e.project?.getService(PomodoroTimerService::class.java)?.reset()
}

override fun update(e: AnActionEvent) {
val service = e.project?.getService(PomodoroTimerService::class.java)
e.presentation.isEnabledAndVisible =
service != null && service.state.value != PomodoroTimerService.TimerState.IDLE
}

override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.github.akshayashokcode.devfocus.actions

import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent

/** Skip the current break and move straight to the next work session. */
class DevFocusSkipBreakAction : AnAction() {

override fun actionPerformed(e: AnActionEvent) {
e.project?.getService(PomodoroTimerService::class.java)?.skipBreak()
}

override fun update(e: AnActionEvent) {
val service = e.project?.getService(PomodoroTimerService::class.java)
e.presentation.isEnabledAndVisible =
service != null && service.currentPhase.value.isBreak
}

override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.github.akshayashokcode.devfocus.actions

import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent

/** Start the timer if idle/paused; pause it if running. */
class DevFocusToggleAction : AnAction() {

override fun actionPerformed(e: AnActionEvent) {
val service = e.project?.getService(PomodoroTimerService::class.java) ?: return
if (service.state.value == PomodoroTimerService.TimerState.RUNNING) {
service.pause()
} else {
service.start()
}
}

override fun update(e: AnActionEvent) {
val service = e.project?.getService(PomodoroTimerService::class.java)
e.presentation.isEnabledAndVisible = service != null
if (service != null) {
e.presentation.text =
if (service.state.value == PomodoroTimerService.TimerState.RUNNING) "Pause Timer"
else "Start / Resume Timer"
}
}

override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.github.akshayashokcode.devfocus.model

data class SavedPreset(
val name: String,
val sessionMinutes: Int,
val breakMinutes: Int,
val sessionsPerRound: Int
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
companion object {
private const val ONE_SECOND = 1000L
private const val NOTIFICATION_GROUP_ID = "DevFocus Notifications"
private const val SAVE_INTERVAL_TICKS = 30
}

enum class TimerState { IDLE, RUNNING, PAUSED }
Expand Down Expand Up @@ -88,12 +87,13 @@
persistState()

job = coroutineScope.launch {
var ticks = 0
while (remainingTimeMs > 0 && isActive) {
delay(ONE_SECOND)
remainingTimeMs -= ONE_SECOND
_timeLeft.value = formatTime(remainingTimeMs)
if (++ticks % SAVE_INTERVAL_TICKS == 0) persistState()
// Guard: never save at remainingMs=0 — phase hasn't transitioned yet,
// so a crash here would restore a stale WORK state and replay the session.
if (remainingTimeMs > 0) persistState()
}
if (remainingTimeMs <= 0) {
// Do NOT set IDLE here — onSessionComplete updates phase/session first,
Expand Down Expand Up @@ -148,53 +148,53 @@

if (sessionNum >= totalSessions) {
// Full round done — start long break
playWorkEndSound()
val longMin = settings.longBreakMinutes
notifyWithAction(
title = "🎉 Round Complete!",
body = "Outstanding! $totalSessions sessions done. Enjoy a $longMin-min long break.",
actionText = "Skip Long Break",
action = { skipBreak() }
)
_currentSession.value = 1
internalPhase = TimerPhase.LONG_BREAK
_currentPhase.value = TimerPhase.LONG_BREAK
remainingTimeMs = TimeUnit.MINUTES.toMillis(longMin.toLong())
_timeLeft.value = formatTime(remainingTimeMs)
_state.value = TimerState.IDLE
persistState()
persistState() // save new phase before any side effects
playWorkEndSound()
notifyWithAction(
title = "🎉 Round Complete!",
body = "Outstanding! $totalSessions sessions done. Enjoy a $longMin-min long break.",
actionText = "Skip Long Break",
action = { skipBreak() }
)
start() // long break always auto-starts

} else {
// Short break between sessions
playWorkEndSound()
_currentSession.value = sessionNum + 1
internalPhase = TimerPhase.BREAK
_currentPhase.value = TimerPhase.BREAK
remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong())
_timeLeft.value = formatTime(remainingTimeMs)
_state.value = TimerState.IDLE
persistState() // save new phase before any side effects
playWorkEndSound()
notifyWithAction(
title = "✅ Session $sessionNum Complete!",
body = "Great work! Starting ${settings.breakMinutes}-min break ☕.",
actionText = "Skip Break",
action = { skipBreak() }
)
internalPhase = TimerPhase.BREAK
_currentPhase.value = TimerPhase.BREAK
remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong())
_timeLeft.value = formatTime(remainingTimeMs)
_state.value = TimerState.IDLE
persistState()
start() // short breaks always auto-start
}

} else {
// BREAK or LONG_BREAK complete
playBreakEndSound()
val nextSession = _currentSession.value
val autoStart = appSettings.autoStartNextSession
internalPhase = TimerPhase.WORK
_currentPhase.value = TimerPhase.WORK
remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
_timeLeft.value = formatTime(remainingTimeMs)
_state.value = TimerState.IDLE
persistState()
persistState() // save new phase before any side effects
playBreakEndSound()

if (autoStart) {
notify(
Expand Down Expand Up @@ -235,6 +235,21 @@

fun getSettings(): PomodoroSettings = settings

fun getRemainingSessionMs(): Long = remainingTimeMs

fun getRemainingRoundMs(): Long {
val sessionMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
val breakMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong())
val longBreakMs = TimeUnit.MINUTES.toMillis(settings.longBreakMinutes.toLong())
val T = settings.sessionsPerRound

Check notice on line 244 in src/main/kotlin/com/github/akshayashokcode/devfocus/services/pomodoro/PomodoroTimerService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Local variable naming convention

Local variable name `T` should start with a lowercase letter
val N = _currentSession.value

Check notice on line 245 in src/main/kotlin/com/github/akshayashokcode/devfocus/services/pomodoro/PomodoroTimerService.kt

View workflow job for this annotation

GitHub Actions / Qodana Community for JVM

Local variable naming convention

Local variable name `N` should start with a lowercase letter
return when (internalPhase) {
TimerPhase.LONG_BREAK -> remainingTimeMs
TimerPhase.WORK -> remainingTimeMs + (T - N) * (sessionMs + breakMs) + longBreakMs
TimerPhase.BREAK -> remainingTimeMs + (T - N) * (sessionMs + breakMs) + sessionMs + longBreakMs
}
}

fun getProgress(): Float {
val totalMs = when (internalPhase) {
TimerPhase.WORK -> TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
Expand All @@ -253,6 +268,7 @@
savedRemainingTimeMs = remainingTimeMs
savedCurrentSession = _currentSession.value
savedPhase = internalPhase.name
savedTimerState = _state.value.name
savedTimerWasRunning = _state.value == TimerState.RUNNING
savedSessionMinutes = settings.sessionMinutes
savedBreakMinutes = settings.breakMinutes
Expand Down Expand Up @@ -292,7 +308,11 @@
else TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
_timeLeft.value = formatTime(remainingTimeMs)

if (saved.savedTimerWasRunning && savedMs > 0) {
// Restore as PAUSED for both RUNNING and PAUSED — fall back to old boolean for existing saves
val priorState = runCatching { TimerState.valueOf(saved.savedTimerState) }.getOrElse {
if (saved.savedTimerWasRunning) TimerState.RUNNING else TimerState.IDLE
}
if ((priorState == TimerState.RUNNING || priorState == TimerState.PAUSED) && savedMs > 0) {
_state.value = TimerState.PAUSED
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.akshayashokcode.devfocus.services.settings

import com.github.akshayashokcode.devfocus.model.SavedPreset
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
Expand All @@ -18,15 +19,24 @@ class DevFocusSettingsState : PersistentStateComponent<DevFocusSettingsState.Set
var savedCurrentSession: Int = 1,
var savedPhase: String = "WORK",
var savedTimerWasRunning: Boolean = false,
var savedTimerState: String = "IDLE",
var savedSessionMinutes: Int = 25,
var savedBreakMinutes: Int = 5,
var savedSessionsPerRound: Int = 4,
var savedLongBreakMinutes: Int = 15,
var savedLongBreakAfter: Int = 4,
var savedMode: String = "CLASSIC",
// Ring accent colors (hex strings, e.g. "#4a90e2")
var focusColorHex: String = "#4a90e2",
var breakColorHex: String = "#f39c12",
// Daily session counter
var completedSessionsToday: Int = 0,
var lastSessionDate: String = ""
var lastSessionDate: String = "",
// Saved custom presets — parallel lists (IntelliJ XML serializer handles List<String> reliably)
var presetNames: MutableList<String> = mutableListOf(),
var presetSessions: MutableList<String> = mutableListOf(),
var presetBreaks: MutableList<String> = mutableListOf(),
var presetCounts: MutableList<String> = mutableListOf()
)

private var state = SettingsState()
Expand Down Expand Up @@ -60,6 +70,10 @@ class DevFocusSettingsState : PersistentStateComponent<DevFocusSettingsState.Set
get() = state.savedTimerWasRunning
set(value) { state.savedTimerWasRunning = value }

var savedTimerState: String
get() = state.savedTimerState
set(value) { state.savedTimerState = value }

var savedSessionMinutes: Int
get() = state.savedSessionMinutes
set(value) { state.savedSessionMinutes = value }
Expand All @@ -84,6 +98,15 @@ class DevFocusSettingsState : PersistentStateComponent<DevFocusSettingsState.Set
get() = state.savedMode
set(value) { state.savedMode = value }

// Ring colors
var focusColorHex: String
get() = state.focusColorHex
set(value) { state.focusColorHex = value }

var breakColorHex: String
get() = state.breakColorHex
set(value) { state.breakColorHex = value }

// Daily counter
var completedSessionsToday: Int
get() = state.completedSessionsToday
Expand All @@ -92,4 +115,22 @@ class DevFocusSettingsState : PersistentStateComponent<DevFocusSettingsState.Set
var lastSessionDate: String
get() = state.lastSessionDate
set(value) { state.lastSessionDate = value }

// Saved presets
fun getSavedPresets(): List<SavedPreset> =
state.presetNames.indices.mapNotNull { i ->
SavedPreset(
name = state.presetNames[i],
sessionMinutes = state.presetSessions.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null,
breakMinutes = state.presetBreaks.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null,
sessionsPerRound = state.presetCounts.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null
)
}

fun addSavedPreset(preset: SavedPreset) {
state.presetNames.add(preset.name)
state.presetSessions.add(preset.sessionMinutes.toString())
state.presetBreaks.add(preset.breakMinutes.toString())
state.presetCounts.add(preset.sessionsPerRound.toString())
}
}
Loading
Loading