Skip to content

Commit 968cc41

Browse files
Merge pull request #95 from AkshayAshokCode/2.2.0
2.2.0
2 parents b11bab7 + fc0a05c commit 968cc41

15 files changed

Lines changed: 486 additions & 180 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@
44

55
## [Unreleased]
66

7+
## [2.2.0]
8+
### Added
9+
- **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
10+
- **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
11+
- **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
12+
13+
### Changed
14+
- 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
15+
- Reset button updated to use an icon instead of text
16+
- 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
17+
- Switching to Custom mode in the dropdown now pre-populates the spinners with the currently active session settings
18+
719
## [2.1.0]
820
### Changed
921
- Cleaned up boilerplate scaffold files (MyProjectActivity, MyProjectService)

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Most Pomodoro timers don't survive an IDE restart, skip the long break entirely,
1919

2020
**🔁 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.
2121

22+
**🕐 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.
23+
2224
**⏭ 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.
2325

2426
**⌨️ 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`).
@@ -29,8 +31,10 @@ Most Pomodoro timers don't survive an IDE restart, skip the long break entirely,
2931

3032
## Everything else
3133

32-
- **Three modes** — Classic Pomodoro (25/5), Deep Work (50/10), or fully custom durations
34+
- **Three built-in modes** — Classic Pomodoro (25/5), Deep Work (50/10), or fully Custom durations
35+
- **Saved custom presets** — name and save your own timing configurations; switch to them in one click from the mode dropdown
3336
- **Visual circular timer** — arc depletes clockwise, colour-coded by phase
37+
- **Customizable ring colors** — pick any Focus and Break accent color from a color picker in Settings
3438
- **Session indicator** — dot row showing completed, active, and upcoming sessions
3539
- **🍅 Daily session counter** — resets at midnight, shown in tool window and status bar
3640
- **Auto-start toggle** — choose whether work sessions start automatically after a break or wait for you

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ pluginGroup = com.github.akshayashokcode.devfocus
44
pluginName = DevFocus
55
pluginRepositoryUrl = https://github.com/AkshayAshokCode/DevFocus
66
# SemVer format -> https://semver.org
7-
pluginVersion = 2.1.0
7+
pluginVersion = 2.2.0
88

99
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
1010
pluginSinceBuild = 233
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.github.akshayashokcode.devfocus.actions
2+
3+
import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService
4+
import com.intellij.openapi.actionSystem.ActionUpdateThread
5+
import com.intellij.openapi.actionSystem.AnAction
6+
import com.intellij.openapi.actionSystem.AnActionEvent
7+
8+
/** Reset the timer and session back to the initial state. */
9+
class DevFocusResetAction : AnAction() {
10+
11+
override fun actionPerformed(e: AnActionEvent) {
12+
e.project?.getService(PomodoroTimerService::class.java)?.reset()
13+
}
14+
15+
override fun update(e: AnActionEvent) {
16+
val service = e.project?.getService(PomodoroTimerService::class.java)
17+
e.presentation.isEnabledAndVisible =
18+
service != null && service.state.value != PomodoroTimerService.TimerState.IDLE
19+
}
20+
21+
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.github.akshayashokcode.devfocus.actions
2+
3+
import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService
4+
import com.intellij.openapi.actionSystem.ActionUpdateThread
5+
import com.intellij.openapi.actionSystem.AnAction
6+
import com.intellij.openapi.actionSystem.AnActionEvent
7+
8+
/** Skip the current break and move straight to the next work session. */
9+
class DevFocusSkipBreakAction : AnAction() {
10+
11+
override fun actionPerformed(e: AnActionEvent) {
12+
e.project?.getService(PomodoroTimerService::class.java)?.skipBreak()
13+
}
14+
15+
override fun update(e: AnActionEvent) {
16+
val service = e.project?.getService(PomodoroTimerService::class.java)
17+
e.presentation.isEnabledAndVisible =
18+
service != null && service.currentPhase.value.isBreak
19+
}
20+
21+
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
22+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.github.akshayashokcode.devfocus.actions
2+
3+
import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService
4+
import com.intellij.openapi.actionSystem.ActionUpdateThread
5+
import com.intellij.openapi.actionSystem.AnAction
6+
import com.intellij.openapi.actionSystem.AnActionEvent
7+
8+
/** Start the timer if idle/paused; pause it if running. */
9+
class DevFocusToggleAction : AnAction() {
10+
11+
override fun actionPerformed(e: AnActionEvent) {
12+
val service = e.project?.getService(PomodoroTimerService::class.java) ?: return
13+
if (service.state.value == PomodoroTimerService.TimerState.RUNNING) {
14+
service.pause()
15+
} else {
16+
service.start()
17+
}
18+
}
19+
20+
override fun update(e: AnActionEvent) {
21+
val service = e.project?.getService(PomodoroTimerService::class.java)
22+
e.presentation.isEnabledAndVisible = service != null
23+
if (service != null) {
24+
e.presentation.text =
25+
if (service.state.value == PomodoroTimerService.TimerState.RUNNING) "Pause Timer"
26+
else "Start / Resume Timer"
27+
}
28+
}
29+
30+
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
31+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.github.akshayashokcode.devfocus.model
2+
3+
data class SavedPreset(
4+
val name: String,
5+
val sessionMinutes: Int,
6+
val breakMinutes: Int,
7+
val sessionsPerRound: Int
8+
)

src/main/kotlin/com/github/akshayashokcode/devfocus/services/pomodoro/PomodoroTimerService.kt

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ class PomodoroTimerService(private val project: Project) {
3131
companion object {
3232
private const val ONE_SECOND = 1000L
3333
private const val NOTIFICATION_GROUP_ID = "DevFocus Notifications"
34-
private const val SAVE_INTERVAL_TICKS = 30
3534
}
3635

3736
enum class TimerState { IDLE, RUNNING, PAUSED }
@@ -88,12 +87,13 @@ class PomodoroTimerService(private val project: Project) {
8887
persistState()
8988

9089
job = coroutineScope.launch {
91-
var ticks = 0
9290
while (remainingTimeMs > 0 && isActive) {
9391
delay(ONE_SECOND)
9492
remainingTimeMs -= ONE_SECOND
9593
_timeLeft.value = formatTime(remainingTimeMs)
96-
if (++ticks % SAVE_INTERVAL_TICKS == 0) persistState()
94+
// Guard: never save at remainingMs=0 — phase hasn't transitioned yet,
95+
// so a crash here would restore a stale WORK state and replay the session.
96+
if (remainingTimeMs > 0) persistState()
9797
}
9898
if (remainingTimeMs <= 0) {
9999
// Do NOT set IDLE here — onSessionComplete updates phase/session first,
@@ -148,53 +148,53 @@ class PomodoroTimerService(private val project: Project) {
148148

149149
if (sessionNum >= totalSessions) {
150150
// Full round done — start long break
151-
playWorkEndSound()
152151
val longMin = settings.longBreakMinutes
153-
notifyWithAction(
154-
title = "🎉 Round Complete!",
155-
body = "Outstanding! $totalSessions sessions done. Enjoy a $longMin-min long break.",
156-
actionText = "Skip Long Break",
157-
action = { skipBreak() }
158-
)
159152
_currentSession.value = 1
160153
internalPhase = TimerPhase.LONG_BREAK
161154
_currentPhase.value = TimerPhase.LONG_BREAK
162155
remainingTimeMs = TimeUnit.MINUTES.toMillis(longMin.toLong())
163156
_timeLeft.value = formatTime(remainingTimeMs)
164157
_state.value = TimerState.IDLE
165-
persistState()
158+
persistState() // save new phase before any side effects
159+
playWorkEndSound()
160+
notifyWithAction(
161+
title = "🎉 Round Complete!",
162+
body = "Outstanding! $totalSessions sessions done. Enjoy a $longMin-min long break.",
163+
actionText = "Skip Long Break",
164+
action = { skipBreak() }
165+
)
166166
start() // long break always auto-starts
167167

168168
} else {
169169
// Short break between sessions
170-
playWorkEndSound()
171170
_currentSession.value = sessionNum + 1
171+
internalPhase = TimerPhase.BREAK
172+
_currentPhase.value = TimerPhase.BREAK
173+
remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong())
174+
_timeLeft.value = formatTime(remainingTimeMs)
175+
_state.value = TimerState.IDLE
176+
persistState() // save new phase before any side effects
177+
playWorkEndSound()
172178
notifyWithAction(
173179
title = "✅ Session $sessionNum Complete!",
174180
body = "Great work! Starting ${settings.breakMinutes}-min break ☕.",
175181
actionText = "Skip Break",
176182
action = { skipBreak() }
177183
)
178-
internalPhase = TimerPhase.BREAK
179-
_currentPhase.value = TimerPhase.BREAK
180-
remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong())
181-
_timeLeft.value = formatTime(remainingTimeMs)
182-
_state.value = TimerState.IDLE
183-
persistState()
184184
start() // short breaks always auto-start
185185
}
186186

187187
} else {
188188
// BREAK or LONG_BREAK complete
189-
playBreakEndSound()
190189
val nextSession = _currentSession.value
191190
val autoStart = appSettings.autoStartNextSession
192191
internalPhase = TimerPhase.WORK
193192
_currentPhase.value = TimerPhase.WORK
194193
remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
195194
_timeLeft.value = formatTime(remainingTimeMs)
196195
_state.value = TimerState.IDLE
197-
persistState()
196+
persistState() // save new phase before any side effects
197+
playBreakEndSound()
198198

199199
if (autoStart) {
200200
notify(
@@ -235,6 +235,21 @@ class PomodoroTimerService(private val project: Project) {
235235

236236
fun getSettings(): PomodoroSettings = settings
237237

238+
fun getRemainingSessionMs(): Long = remainingTimeMs
239+
240+
fun getRemainingRoundMs(): Long {
241+
val sessionMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
242+
val breakMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong())
243+
val longBreakMs = TimeUnit.MINUTES.toMillis(settings.longBreakMinutes.toLong())
244+
val T = settings.sessionsPerRound
245+
val N = _currentSession.value
246+
return when (internalPhase) {
247+
TimerPhase.LONG_BREAK -> remainingTimeMs
248+
TimerPhase.WORK -> remainingTimeMs + (T - N) * (sessionMs + breakMs) + longBreakMs
249+
TimerPhase.BREAK -> remainingTimeMs + (T - N) * (sessionMs + breakMs) + sessionMs + longBreakMs
250+
}
251+
}
252+
238253
fun getProgress(): Float {
239254
val totalMs = when (internalPhase) {
240255
TimerPhase.WORK -> TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
@@ -253,6 +268,7 @@ class PomodoroTimerService(private val project: Project) {
253268
savedRemainingTimeMs = remainingTimeMs
254269
savedCurrentSession = _currentSession.value
255270
savedPhase = internalPhase.name
271+
savedTimerState = _state.value.name
256272
savedTimerWasRunning = _state.value == TimerState.RUNNING
257273
savedSessionMinutes = settings.sessionMinutes
258274
savedBreakMinutes = settings.breakMinutes
@@ -292,7 +308,11 @@ class PomodoroTimerService(private val project: Project) {
292308
else TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
293309
_timeLeft.value = formatTime(remainingTimeMs)
294310

295-
if (saved.savedTimerWasRunning && savedMs > 0) {
311+
// Restore as PAUSED for both RUNNING and PAUSED — fall back to old boolean for existing saves
312+
val priorState = runCatching { TimerState.valueOf(saved.savedTimerState) }.getOrElse {
313+
if (saved.savedTimerWasRunning) TimerState.RUNNING else TimerState.IDLE
314+
}
315+
if ((priorState == TimerState.RUNNING || priorState == TimerState.PAUSED) && savedMs > 0) {
296316
_state.value = TimerState.PAUSED
297317
}
298318
}

src/main/kotlin/com/github/akshayashokcode/devfocus/services/settings/DevFocusSettingsState.kt

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.github.akshayashokcode.devfocus.services.settings
22

3+
import com.github.akshayashokcode.devfocus.model.SavedPreset
34
import com.intellij.openapi.components.PersistentStateComponent
45
import com.intellij.openapi.components.Service
56
import com.intellij.openapi.components.State
@@ -18,15 +19,24 @@ class DevFocusSettingsState : PersistentStateComponent<DevFocusSettingsState.Set
1819
var savedCurrentSession: Int = 1,
1920
var savedPhase: String = "WORK",
2021
var savedTimerWasRunning: Boolean = false,
22+
var savedTimerState: String = "IDLE",
2123
var savedSessionMinutes: Int = 25,
2224
var savedBreakMinutes: Int = 5,
2325
var savedSessionsPerRound: Int = 4,
2426
var savedLongBreakMinutes: Int = 15,
2527
var savedLongBreakAfter: Int = 4,
2628
var savedMode: String = "CLASSIC",
29+
// Ring accent colors (hex strings, e.g. "#4a90e2")
30+
var focusColorHex: String = "#4a90e2",
31+
var breakColorHex: String = "#f39c12",
2732
// Daily session counter
2833
var completedSessionsToday: Int = 0,
29-
var lastSessionDate: String = ""
34+
var lastSessionDate: String = "",
35+
// Saved custom presets — parallel lists (IntelliJ XML serializer handles List<String> reliably)
36+
var presetNames: MutableList<String> = mutableListOf(),
37+
var presetSessions: MutableList<String> = mutableListOf(),
38+
var presetBreaks: MutableList<String> = mutableListOf(),
39+
var presetCounts: MutableList<String> = mutableListOf()
3040
)
3141

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

73+
var savedTimerState: String
74+
get() = state.savedTimerState
75+
set(value) { state.savedTimerState = value }
76+
6377
var savedSessionMinutes: Int
6478
get() = state.savedSessionMinutes
6579
set(value) { state.savedSessionMinutes = value }
@@ -84,6 +98,15 @@ class DevFocusSettingsState : PersistentStateComponent<DevFocusSettingsState.Set
8498
get() = state.savedMode
8599
set(value) { state.savedMode = value }
86100

101+
// Ring colors
102+
var focusColorHex: String
103+
get() = state.focusColorHex
104+
set(value) { state.focusColorHex = value }
105+
106+
var breakColorHex: String
107+
get() = state.breakColorHex
108+
set(value) { state.breakColorHex = value }
109+
87110
// Daily counter
88111
var completedSessionsToday: Int
89112
get() = state.completedSessionsToday
@@ -92,4 +115,22 @@ class DevFocusSettingsState : PersistentStateComponent<DevFocusSettingsState.Set
92115
var lastSessionDate: String
93116
get() = state.lastSessionDate
94117
set(value) { state.lastSessionDate = value }
118+
119+
// Saved presets
120+
fun getSavedPresets(): List<SavedPreset> =
121+
state.presetNames.indices.mapNotNull { i ->
122+
SavedPreset(
123+
name = state.presetNames[i],
124+
sessionMinutes = state.presetSessions.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null,
125+
breakMinutes = state.presetBreaks.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null,
126+
sessionsPerRound = state.presetCounts.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null
127+
)
128+
}
129+
130+
fun addSavedPreset(preset: SavedPreset) {
131+
state.presetNames.add(preset.name)
132+
state.presetSessions.add(preset.sessionMinutes.toString())
133+
state.presetBreaks.add(preset.breakMinutes.toString())
134+
state.presetCounts.add(preset.sessionsPerRound.toString())
135+
}
95136
}

0 commit comments

Comments
 (0)