diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f446cc..ef36219 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,5 +3,54 @@
# DevFocus Changelog
## [Unreleased]
+
+## [2.1.0]
+### Changed
+- Cleaned up boilerplate scaffold files (MyProjectActivity, MyProjectService)
+- Removed default keyboard shortcuts to avoid conflicts with existing IDE bindings — assign via Settings → Keymap → DevFocus
+- Removed unnecessary optional Android plugin dependency declaration
+
+### Fixed
+- Removed stale "Don't forget to remove sample code" warning logged on every project open
+
+## [2.0.1]
+### Fixed
+- Removed unnecessary optional Android dependency declaration that required a config-file attribute
+- Coroutine exception handler added to suppress debug metadata version mismatch crash on pause
+
+## [2.0.0]
+### Added
+- **Long break support** — Classic Pomodoro now fires a long break (15 min) after completing a full round of 4 sessions; Deep Work fires a 30-min long break after 2 sessions
+- **Skip Break** — button in the tool window and notification action to skip any break and jump straight to the next work session
+- **Notification action buttons** — "Skip Break", "Skip Long Break", and "Start Session" (when auto-start is off) actions are now clickable directly from the balloon notification
+- **IDE actions** — Start/Pause, Reset, and Skip Break registered as IDE actions under Tools → DevFocus and Find Action; assign your own shortcuts via Settings → Keymap → DevFocus
+- **Daily session counter** — tracks how many work sessions you've completed today; shown in the tool window and status bar
+- **Auto-start toggle** — new setting to control whether the next work session starts automatically after a break, or waits for a manual start
+- **Timer state persistence** — timer state (remaining time, session, phase) is saved and restored across IDE restarts; a running timer resumes as paused
+- **Status bar always visible** — shows "🍅 X today" when idle instead of disappearing; work/break/long-break each have a distinct emoji and label
+- **Phase label** — explicit "Focus / Break / Long Break" text label with colour coding beneath the circular timer
+- **Responsive layout** — tool window adapts to three modes: compact (<160 px), vertical, and horizontal (side-by-side timer and controls)
+
+### Fixed
+- Session counter no longer double-increments when a break ends (sessions were previously skipping every other number)
+- Circular timer background arc now uses theme-aware colour instead of hardcoded light grey (was invisible in dark themes)
+- Layout rebuild on panel resize no longer accumulates duplicate action listeners on buttons
+- Skip Break from a notification now correctly starts the new work timer (the state guard in `start()` was blocking it)
+- Custom settings panel no longer flashes visible for a frame when a session ends and a break starts
+
+### Changed
+- Circular timer scales dynamically with the panel size (capped at 180 px diameter) instead of using a fixed 180 px
+- Session indicator dots resize dynamically when sessions-per-round changes (was clipping at >6 sessions with a fixed 200 px width)
+
+## [1.2.3]
+### Added
+- Notification sound support with enable/disable setting
+- Sound plays on work session complete and break complete
+
+## [1.2.2]
### Added
-- Initial scaffold created from [IntelliJ Platform Plugin Template](https://github.com/JetBrains/intellij-platform-plugin-template)
+- Classic Pomodoro and Deep Work preset modes
+- Custom mode with configurable session, break, and sessions-per-round
+- Circular timer panel with progress arc
+- Session indicator dots showing completed, current, and upcoming sessions
+- Status bar widget showing live timer when active
diff --git a/README.md b/README.md
index 9932031..8c0ef9f 100644
--- a/README.md
+++ b/README.md
@@ -4,144 +4,56 @@
[](https://plugins.jetbrains.com/plugin/30114)
[](https://plugins.jetbrains.com/plugin/30114)
-DevFocus is a simple, elegant Pomodoro timer plugin for Android Studio and IntelliJ IDEA.
-Designed to help developers stay focused, take breaks, and manage their productivity — right inside the IDE.
+DevFocus is a Pomodoro timer plugin for Android Studio and IntelliJ IDEA that helps developers stay focused, take structured breaks, and track their productivity — without leaving the IDE.
# DevFocus — Pomodoro Timer for JetBrains IDEs
-DevFocus is a lightweight Pomodoro timer designed specifically for developers using JetBrains IDEs.
-Stay focused on your coding sessions without leaving your editor, and maintain a healthy balance between deep work and breaks.
-
-Instead of switching to external productivity apps, DevFocus integrates directly into your IDE so you can manage focus sessions while coding.
+Most Pomodoro timers don't survive an IDE restart, skip the long break entirely, and require you to leave the editor just to pause. DevFocus fixes all three — and stays out of your way while doing it.
---
-## Features
-
-### 🎯 Classic Pomodoro
-
-Follow the traditional Pomodoro technique:
+## What makes it different
-- **25 minutes** focused work
-- **5 minutes** break
-
-A simple and proven way to stay productive while avoiding burnout.
-
----
+**💾 Survives restarts and crashes.** Timer state is saved to disk continuously. Reopen the IDE — even the next day — and your session is exactly where you left it, paused and ready to resume.
-### 🧠 Deep Work Mode
+**🔁 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.
-For longer, distraction-free coding sessions.
+**⏭ 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.
-- **50 minutes** focused work
-- **10 minutes** break
+**⌨️ 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`).
-Perfect for tasks that require deeper concentration like debugging, architecture work, or learning new systems.
+**📍 Status bar always present.** Live countdown visible while you code, even with the tool window closed. When idle, shows your daily session count — a quiet reminder of what you've already accomplished.
---
-### ⚙️ Fully Custom Sessions
+## Everything else
-Create your own focus routine by customizing:
-
-- Work session duration
-- Break duration
-- Number of rounds per session
-
-Adapt the timer to your personal workflow.
-
----
-
-### ⏱ Visual Circular Timer
-
-DevFocus includes a clean **clockwise circular timer** that visually represents your session progress, making it easy to track time without distractions.
-
----
-
-### 📍 Status Bar Timer
-
-Your current session time is always visible in the **IDE status bar**, even if the DevFocus tool window is minimized.
-
-This ensures you can stay aware of your focus session while continuing to work normally in the editor.
-
----
-
-### 🔔 Smart Session Notifications
-
-DevFocus notifies you when:
-
-- A **focus session is completed**
-- A **break session is completed**
-
-These notifications help maintain the Pomodoro rhythm without constantly checking the timer.
-
----
-
-## Why DevFocus?
-
-Many Pomodoro apps run outside the IDE, forcing developers to constantly switch context.
-
-DevFocus keeps everything inside your development environment so you can:
-
-- Stay focused while coding
-- Maintain productive work/break cycles
-- Avoid workflow interruptions
+- **Three modes** — Classic Pomodoro (25/5), Deep Work (50/10), or fully custom durations
+- **Visual circular timer** — arc depletes clockwise, colour-coded by phase
+- **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
+- **Actionable notifications** — Skip Break, Skip Long Break, Start Session — inline in the balloon
+- **Responsive tool window** — adapts between compact, vertical, and horizontal layouts as you resize
---
## Supported IDEs
-DevFocus works with JetBrains IDEs including:
-
-- IntelliJ IDEA
-- Android Studio
-- PyCharm
-- WebStorm
-- CLion
-- Rider
-- Other IntelliJ-based IDEs
-
----
-
-## Boost Your Coding Focus
-
-Whether you're debugging a complex issue, implementing a new feature, or learning a new technology, DevFocus helps you stay in the zone while maintaining a healthy development rhythm.
+IntelliJ IDEA, Android Studio, PyCharm, WebStorm, CLion, Rider, and all other JetBrains IDEs.
-## Features
-
-- 🕒 Pomodoro countdown in a dedicated tool window
-- ⏸️ Auto-break reminders
-- 🔔 Session notifications
-- 💡 Simple, unobtrusive design
-- 🧠 Works in Android Studio and IntelliJ IDEA
-
## Installation
-- Using the IDE built-in plugin system:
-
- Settings/Preferences > Plugins > Marketplace > Search for "DevFocus" >
- Install
-
-- Using JetBrains Marketplace:
+- **IDE Plugin System:** Settings > Plugins > Marketplace > search **DevFocus** > Install
- Go to [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/30114) and install it by clicking the Install to ... button in case your IDE is running.
-
- You can also download the [latest release](https://plugins.jetbrains.com/plugin/30114/versions) from JetBrains Marketplace and install it manually using
- Settings/Preferences > Plugins > ⚙️ > Install plugin from disk...
-
-- Manually:
-
- Download the [latest release](https://github.com/AkshayAshokCode/DevFocus/releases/latest) and install it manually using
- Settings/Preferences > Plugins > ⚙️ > Install plugin from disk...
+- **JetBrains Marketplace:** [plugins.jetbrains.com/plugin/30114](https://plugins.jetbrains.com/plugin/30114)
+- **Manually:** Download the [latest release](https://github.com/AkshayAshokCode/DevFocus/releases/latest) and install via Settings > Plugins > ⚙️ > Install plugin from disk...
## License
-DevFocus is licensed under the Apache License 2.0.
-
-Copyright (c) 2026 Akshay Ashok
-
-See the LICENSE file for details.
+DevFocus is licensed under the Apache License 2.0.
+Copyright (c) 2026 Akshay Ashok — see the LICENSE file for details.
diff --git a/build.gradle.kts b/build.gradle.kts
index f52aeab..6f695ee 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,6 @@
import org.jetbrains.changelog.Changelog
import org.jetbrains.changelog.markdownToHTML
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
-import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
plugins {
id("java") // Java support
@@ -83,7 +82,7 @@ intellijPlatform {
ideaVersion {
sinceBuild = providers.gradleProperty("pluginSinceBuild")
- untilBuild = providers.gradleProperty("pluginUntilBuild")
+ // untilBuild omitted — no upper cap, plugin installs on all IDE versions from sinceBuild onwards
}
}
@@ -153,12 +152,5 @@ intellijPlatformTesting {
robotServerPlugin()
}
}
-
- runIde {
- register("runAndroidStudio") {
- type.set(IntelliJPlatformType.AndroidStudio)
- version.set("2024.3.2.14")
- }
- }
}
}
diff --git a/gradle.properties b/gradle.properties
index bb7b885..863f65c 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -4,11 +4,10 @@ pluginGroup = com.github.akshayashokcode.devfocus
pluginName = DevFocus
pluginRepositoryUrl = https://github.com/AkshayAshokCode/DevFocus
# SemVer format -> https://semver.org
-pluginVersion = 1.2.4
+pluginVersion = 2.1.0
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 233
-pluginUntilBuild = 251.*
# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension
platformType = IC
@@ -26,8 +25,10 @@ gradleVersion = 8.13
# Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib
kotlin.stdlib.default.dependency = false
-# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html
-org.gradle.configuration-cache = true
+# Configuration cache disabled — IntelliJ Platform Gradle Plugin's RunIdeTask has a
+# non-serializable DefaultProperty field (__runtimeArchitecture__) that causes cache
+# write failures. Build cache below is unaffected and still active.
+org.gradle.configuration-cache = false
# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html
org.gradle.caching = true
diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/services/MyProjectService.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/services/MyProjectService.kt
index 9425155..abfcb77 100644
--- a/src/main/kotlin/com/github/akshayashokcode/devfocus/services/MyProjectService.kt
+++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/services/MyProjectService.kt
@@ -1,17 +1,7 @@
package com.github.akshayashokcode.devfocus.services
import com.intellij.openapi.components.Service
-import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
-import com.github.akshayashokcode.devfocus.MyBundle
@Service(Service.Level.PROJECT)
-class MyProjectService(project: Project) {
-
- init {
- thisLogger().info(MyBundle.message("projectService", project.name))
- thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.")
- }
-
- fun getRandomNumber() = (1..100).random()
-}
+class MyProjectService(@Suppress("UNUSED_PARAMETER") project: Project)
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 8745879..05eb086 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
@@ -2,11 +2,17 @@ package com.github.akshayashokcode.devfocus.services.pomodoro
import com.github.akshayashokcode.devfocus.model.PomodoroMode
import com.github.akshayashokcode.devfocus.model.PomodoroSettings
+import com.github.akshayashokcode.devfocus.services.settings.DevFocusSettingsState
import com.github.akshayashokcode.devfocus.util.SoundPlayer
+import com.intellij.notification.Notification
+import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.project.Project
+import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -16,19 +22,30 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import java.time.LocalDate
import java.util.concurrent.TimeUnit
@Service(Service.Level.PROJECT)
class PomodoroTimerService(private val project: Project) {
+
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 }
- enum class TimerPhase { WORK, BREAK }
- private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ enum class TimerPhase {
+ WORK, BREAK, LONG_BREAK;
+ val isBreak: Boolean get() = this != WORK
+ }
+
+ // CoroutineExceptionHandler suppresses the debug-metadata version mismatch crash that
+ // kotlinx-coroutines triggers during stack trace recovery when cancelling a coroutine
+ // compiled with Kotlin 2.3.x against an older bundled coroutines runtime.
+ private val exceptionHandler = CoroutineExceptionHandler { _, _ -> }
+ private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + exceptionHandler)
private var job: Job? = null
private var settings = PomodoroMode.CLASSIC.toSettings()
@@ -50,128 +67,159 @@ class PomodoroTimerService(private val project: Project) {
private val _settings = MutableStateFlow(settings)
val settingsFlow: StateFlow = _settings
+ private val _dailySessionCount = MutableStateFlow(0)
+ val dailySessionCount: StateFlow = _dailySessionCount
+
+ private val appSettings: DevFocusSettingsState
+ get() = ApplicationManager.getApplication().getService(DevFocusSettingsState::class.java)
+
+ init {
+ restoreState()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Timer control
+ // ---------------------------------------------------------------------------
+
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
+ 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()
}
if (remainingTimeMs <= 0) {
- _state.value = TimerState.IDLE
+ // Do NOT set IDLE here — onSessionComplete updates phase/session first,
+ // then sets IDLE so the stateJob collector always sees a consistent snapshot.
onSessionComplete()
}
}
}
+ fun pause() {
+ if (_state.value != TimerState.RUNNING) return
+ job?.cancel()
+ _state.value = TimerState.PAUSED
+ persistState()
+ }
+
+ fun reset() {
+ job?.cancel()
+ job = null
+ internalPhase = TimerPhase.WORK
+ _currentPhase.value = TimerPhase.WORK
+ remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
+ _timeLeft.value = formatTime(remainingTimeMs)
+ _currentSession.value = 1
+ _state.value = TimerState.IDLE
+ persistState()
+ }
+
+ fun skipBreak() {
+ if (!internalPhase.isBreak) return
+ job?.cancel()
+ job = null
+ _state.value = TimerState.IDLE // must reset before start() — its guard rejects RUNNING state
+ internalPhase = TimerPhase.WORK
+ _currentPhase.value = TimerPhase.WORK
+ remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
+ _timeLeft.value = formatTime(remainingTimeMs)
+ persistState()
+ if (appSettings.autoStartNextSession) start()
+ }
+
+ // ---------------------------------------------------------------------------
+ // Session completion & transitions
+ // ---------------------------------------------------------------------------
+
private fun onSessionComplete() {
- val currentSessionNum = _currentSession.value
+ val sessionNum = _currentSession.value
val totalSessions = settings.sessionsPerRound
if (internalPhase == TimerPhase.WORK) {
- // Work session complete
- if (currentSessionNum >= totalSessions) {
- // Last session complete - all done!
- playCompleteSound()
- NotificationGroupManager.getInstance()
- .getNotificationGroup(NOTIFICATION_GROUP_ID)
- .createNotification(
- "\uD83C\uDF89 All Sessions Complete!",
- "You've completed all $totalSessions sessions. Take a well-deserved break!",
- NotificationType.INFORMATION
- )
- .notify(project)
-
- // Reset to initial state
- internalPhase = TimerPhase.WORK
- _currentPhase.value = TimerPhase.WORK
+ incrementDailyCounter()
+
+ 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
- remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
+ internalPhase = TimerPhase.LONG_BREAK
+ _currentPhase.value = TimerPhase.LONG_BREAK
+ remainingTimeMs = TimeUnit.MINUTES.toMillis(longMin.toLong())
_timeLeft.value = formatTime(remainingTimeMs)
+ _state.value = TimerState.IDLE
+ persistState()
+ start() // long break always auto-starts
+
} else {
- // Work session complete - start break
+ // Short break between sessions
playWorkEndSound()
- _currentSession.value = currentSessionNum + 1
-
- NotificationGroupManager.getInstance()
- .getNotificationGroup(NOTIFICATION_GROUP_ID)
- .createNotification(
- "✅ Session $currentSessionNum Complete!",
- "Great work! Starting ${settings.breakMinutes}-minute break ☕.",
- NotificationType.INFORMATION
- )
- .notify(project)
-
- // Start break timer
+ _currentSession.value = sessionNum + 1
+ 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)
- start()
+ _state.value = TimerState.IDLE
+ persistState()
+ start() // short breaks always auto-start
}
} else {
- // Break complete
- // More sessions remaining - start next session
+ // BREAK or LONG_BREAK complete
playBreakEndSound()
- NotificationGroupManager.getInstance()
- .getNotificationGroup(NOTIFICATION_GROUP_ID)
- .createNotification(
- "☕ Break Complete!",
- "Starting session $currentSessionNum of $totalSessions.",
- NotificationType.INFORMATION
- )
- .notify(project)
-
- // Start next work session (session was already incremented when work ended)
+ 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)
- start()
-
- }
- }
+ _state.value = TimerState.IDLE
+ persistState()
- fun pause() {
- if (_state.value == TimerState.RUNNING) {
- job?.cancel()
- _state.value = TimerState.PAUSED
+ if (autoStart) {
+ notify(
+ title = "☕ Break Over!",
+ body = "Starting session $nextSession of $totalSessions."
+ )
+ start()
+ } else {
+ notifyWithAction(
+ title = "☕ Break Over!",
+ body = "Session $nextSession of $totalSessions is ready when you are.",
+ actionText = "Start Session",
+ action = { start() }
+ )
+ }
}
}
- fun reset() {
- // Cancel any running job
- job?.cancel()
- job = null
-
- // Reset to initial state
- internalPhase = TimerPhase.WORK
- _currentPhase.value = TimerPhase.WORK
- remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
- _timeLeft.value = formatTime(remainingTimeMs)
- _currentSession.value = 1
- _state.value = TimerState.IDLE
- }
-
- private fun formatTime(ms: Long): String {
- val totalSeconds = ms / 1000
- val minutes = totalSeconds / 60
- val seconds = totalSeconds % 60
- return String.format("%02d:%02d", minutes, seconds)
- }
+ // ---------------------------------------------------------------------------
+ // Settings
+ // ---------------------------------------------------------------------------
fun applySettings(newSettings: PomodoroSettings) {
- // Cancel any running job when settings change
job?.cancel()
job = null
-
settings = newSettings
_settings.value = newSettings
internalPhase = TimerPhase.WORK
@@ -180,32 +228,128 @@ class PomodoroTimerService(private val project: Project) {
_timeLeft.value = formatTime(remainingTimeMs)
_currentSession.value = 1
_state.value = TimerState.IDLE
+ persistState()
}
- fun applyMode(mode: PomodoroMode) {
- applySettings(mode.toSettings())
- }
+ fun applyMode(mode: PomodoroMode) = applySettings(mode.toSettings())
fun getSettings(): PomodoroSettings = settings
fun getProgress(): Float {
- val totalMs = if (internalPhase == TimerPhase.WORK) {
- TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
- } else {
- TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong())
+ val totalMs = when (internalPhase) {
+ TimerPhase.WORK -> TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
+ TimerPhase.BREAK -> TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong())
+ TimerPhase.LONG_BREAK -> TimeUnit.MINUTES.toMillis(settings.longBreakMinutes.toLong())
}
return if (totalMs > 0) remainingTimeMs.toFloat() / totalMs.toFloat() else 0f
}
- private fun playBreakEndSound() {
- SoundPlayer.play("break.wav")
+ // ---------------------------------------------------------------------------
+ // State persistence
+ // ---------------------------------------------------------------------------
+
+ private fun persistState() {
+ appSettings.apply {
+ savedRemainingTimeMs = remainingTimeMs
+ savedCurrentSession = _currentSession.value
+ savedPhase = internalPhase.name
+ savedTimerWasRunning = _state.value == TimerState.RUNNING
+ savedSessionMinutes = settings.sessionMinutes
+ savedBreakMinutes = settings.breakMinutes
+ savedSessionsPerRound = settings.sessionsPerRound
+ savedLongBreakMinutes = settings.longBreakMinutes
+ savedLongBreakAfter = settings.longBreakAfter
+ savedMode = settings.mode.name
+ }
}
- private fun playWorkEndSound() {
- SoundPlayer.play("work.wav")
+ private fun restoreState() {
+ val saved = appSettings
+ refreshDailyCount()
+
+ // Restore settings
+ val mode = runCatching { PomodoroMode.valueOf(saved.savedMode) }.getOrDefault(PomodoroMode.CLASSIC)
+ settings = PomodoroSettings(
+ mode = mode,
+ sessionMinutes = saved.savedSessionMinutes,
+ breakMinutes = saved.savedBreakMinutes,
+ sessionsPerRound = saved.savedSessionsPerRound,
+ longBreakMinutes = saved.savedLongBreakMinutes,
+ longBreakAfter = saved.savedLongBreakAfter
+ )
+ _settings.value = settings
+
+ // Restore phase & session
+ internalPhase = runCatching { TimerPhase.valueOf(saved.savedPhase) }.getOrDefault(TimerPhase.WORK)
+ _currentPhase.value = internalPhase
+ _currentSession.value = saved.savedCurrentSession.coerceAtLeast(1)
+
+ // Restore the exact saved position — wall-clock time while the IDE was closed is
+ // intentionally ignored. The timer only counts down while actively running in the IDE.
+ // A crash or close freezes the session at the exact second it stopped.
+ val savedMs = saved.savedRemainingTimeMs
+ remainingTimeMs = if (savedMs > 0) savedMs
+ else TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong())
+ _timeLeft.value = formatTime(remainingTimeMs)
+
+ if (saved.savedTimerWasRunning && savedMs > 0) {
+ _state.value = TimerState.PAUSED
+ }
}
- private fun playCompleteSound() {
- SoundPlayer.play("complete.wav")
+ // ---------------------------------------------------------------------------
+ // Daily counter
+ // ---------------------------------------------------------------------------
+
+ private fun refreshDailyCount() {
+ val today = LocalDate.now().toString()
+ if (appSettings.lastSessionDate != today) {
+ appSettings.completedSessionsToday = 0
+ appSettings.lastSessionDate = today
+ }
+ _dailySessionCount.value = appSettings.completedSessionsToday
}
-}
\ No newline at end of file
+
+ private fun incrementDailyCounter() {
+ val today = LocalDate.now().toString()
+ if (appSettings.lastSessionDate != today) {
+ appSettings.completedSessionsToday = 0
+ appSettings.lastSessionDate = today
+ }
+ appSettings.completedSessionsToday++
+ appSettings.lastSessionDate = today
+ _dailySessionCount.value = appSettings.completedSessionsToday
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private fun formatTime(ms: Long): String {
+ val totalSeconds = ms / 1000
+ return String.format("%02d:%02d", totalSeconds / 60, totalSeconds % 60)
+ }
+
+ private fun notify(title: String, body: String) {
+ NotificationGroupManager.getInstance()
+ .getNotificationGroup(NOTIFICATION_GROUP_ID)
+ .createNotification(title, body, NotificationType.INFORMATION)
+ .notify(project)
+ }
+
+ private fun notifyWithAction(title: String, body: String, actionText: String, action: () -> Unit) {
+ NotificationGroupManager.getInstance()
+ .getNotificationGroup(NOTIFICATION_GROUP_ID)
+ .createNotification(title, body, NotificationType.INFORMATION)
+ .addAction(object : NotificationAction(actionText) {
+ override fun actionPerformed(e: AnActionEvent, notification: Notification) {
+ action()
+ notification.expire()
+ }
+ })
+ .notify(project)
+ }
+
+ private fun playWorkEndSound() = SoundPlayer.play("work.wav")
+ private fun playBreakEndSound() = SoundPlayer.play("break.wav")
+}
diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/services/settings/DevFocusSettingsState.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/services/settings/DevFocusSettingsState.kt
index 3470bfa..ad9988e 100644
--- a/src/main/kotlin/com/github/akshayashokcode/devfocus/services/settings/DevFocusSettingsState.kt
+++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/services/settings/DevFocusSettingsState.kt
@@ -6,28 +6,90 @@ import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
@Service(Service.Level.APP)
-@State(
- name = "DevFocusSettings",
- storages = [Storage("DevFocusSettings.xml")]
-)
-class DevFocusSettingsState :
- PersistentStateComponent {
+@State(name = "DevFocusSettings", storages = [Storage("DevFocusSettings.xml")])
+class DevFocusSettingsState : PersistentStateComponent {
data class SettingsState(
- var soundEnabled: Boolean = true
+ // User preferences
+ var soundEnabled: Boolean = true,
+ var autoStartNextSession: Boolean = true,
+ // Timer state — persisted across IDE restarts
+ var savedRemainingTimeMs: Long = 0L,
+ var savedCurrentSession: Int = 1,
+ var savedPhase: String = "WORK",
+ var savedTimerWasRunning: Boolean = false,
+ var savedSessionMinutes: Int = 25,
+ var savedBreakMinutes: Int = 5,
+ var savedSessionsPerRound: Int = 4,
+ var savedLongBreakMinutes: Int = 15,
+ var savedLongBreakAfter: Int = 4,
+ var savedMode: String = "CLASSIC",
+ // Daily session counter
+ var completedSessionsToday: Int = 0,
+ var lastSessionDate: String = ""
)
private var state = SettingsState()
override fun getState(): SettingsState = state
+ override fun loadState(state: SettingsState) { this.state = state }
- override fun loadState(state: SettingsState) {
- this.state = state
- }
-
+ // User preferences
var soundEnabled: Boolean
get() = state.soundEnabled
- set(value) {
- state.soundEnabled = value
- }
-}
\ No newline at end of file
+ set(value) { state.soundEnabled = value }
+
+ var autoStartNextSession: Boolean
+ get() = state.autoStartNextSession
+ set(value) { state.autoStartNextSession = value }
+
+ // Timer persistence
+ var savedRemainingTimeMs: Long
+ get() = state.savedRemainingTimeMs
+ set(value) { state.savedRemainingTimeMs = value }
+
+ var savedCurrentSession: Int
+ get() = state.savedCurrentSession
+ set(value) { state.savedCurrentSession = value }
+
+ var savedPhase: String
+ get() = state.savedPhase
+ set(value) { state.savedPhase = value }
+
+ var savedTimerWasRunning: Boolean
+ get() = state.savedTimerWasRunning
+ set(value) { state.savedTimerWasRunning = value }
+
+ var savedSessionMinutes: Int
+ get() = state.savedSessionMinutes
+ set(value) { state.savedSessionMinutes = value }
+
+ var savedBreakMinutes: Int
+ get() = state.savedBreakMinutes
+ set(value) { state.savedBreakMinutes = value }
+
+ var savedSessionsPerRound: Int
+ get() = state.savedSessionsPerRound
+ set(value) { state.savedSessionsPerRound = value }
+
+ var savedLongBreakMinutes: Int
+ get() = state.savedLongBreakMinutes
+ set(value) { state.savedLongBreakMinutes = value }
+
+ var savedLongBreakAfter: Int
+ get() = state.savedLongBreakAfter
+ set(value) { state.savedLongBreakAfter = value }
+
+ var savedMode: String
+ get() = state.savedMode
+ set(value) { state.savedMode = value }
+
+ // Daily counter
+ var completedSessionsToday: Int
+ get() = state.completedSessionsToday
+ set(value) { state.completedSessionsToday = value }
+
+ var lastSessionDate: String
+ get() = state.lastSessionDate
+ set(value) { state.lastSessionDate = value }
+}
diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/startup/MyProjectActivity.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/startup/MyProjectActivity.kt
index f49f830..1b0fd24 100644
--- a/src/main/kotlin/com/github/akshayashokcode/devfocus/startup/MyProjectActivity.kt
+++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/startup/MyProjectActivity.kt
@@ -1,12 +1,8 @@
package com.github.akshayashokcode.devfocus.startup
-import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
class MyProjectActivity : ProjectActivity {
-
- override suspend fun execute(project: Project) {
- thisLogger().warn("Don't forget to remove all non-needed sample code files with their corresponding registration entries in `plugin.xml`.")
- }
-}
\ No newline at end of file
+ override suspend fun execute(project: Project) = Unit
+}
diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/statusbar/PomodoroStatusBarWidget.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/statusbar/PomodoroStatusBarWidget.kt
index 34e1ecc..10c0269 100644
--- a/src/main/kotlin/com/github/akshayashokcode/devfocus/statusbar/PomodoroStatusBarWidget.kt
+++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/statusbar/PomodoroStatusBarWidget.kt
@@ -28,28 +28,26 @@ class PomodoroStatusBarWidget(private val project: Project) : StatusBarWidget, S
private var stateJob: Job? = null
private var sessionJob: Job? = null
private var phaseJob: Job? = null
-
+ private var dailyJob: Job? = null
init {
observeTimer()
}
- override fun ID(): @NonNls String = ID
+ override fun ID(): @NonNls String = ID
override fun getPresentation(): StatusBarWidget.WidgetPresentation = this
-
- override fun install(statusBar: StatusBar) {
- this.statusBar = statusBar
- }
+ override fun install(statusBar: StatusBar) { this.statusBar = statusBar }
override fun dispose() {
timeJob?.cancel()
stateJob?.cancel()
sessionJob?.cancel()
phaseJob?.cancel()
+ dailyJob?.cancel()
scope.cancel()
}
- override fun getText(): String = currentText
+ override fun getText(): String = currentText
override fun getAlignment(): Float = 0.5f
override fun getTooltipText(): String {
@@ -57,63 +55,41 @@ class PomodoroStatusBarWidget(private val project: Project) : StatusBarWidget, S
val phase = timerService.currentPhase.value
val session = timerService.currentSession.value
val settings = timerService.getSettings()
+ val daily = timerService.dailySessionCount.value
return when (state) {
- PomodoroTimerService.TimerState.IDLE -> "DevFocus - Click to open"
- PomodoroTimerService.TimerState.RUNNING -> {
- if (phase == PomodoroTimerService.TimerPhase.WORK) {
- "Work Session $session/${settings.sessionsPerRound} - Running"
- } else {
- "Break Time - Running"
- }
+ PomodoroTimerService.TimerState.IDLE -> {
+ if (daily > 0) "DevFocus — $daily session${if (daily == 1) "" else "s"} completed today. Click to open."
+ else "DevFocus — Click to open"
}
- PomodoroTimerService.TimerState.PAUSED -> {
- if (phase == PomodoroTimerService.TimerPhase.WORK) {
- "Work Session $session/${settings.sessionsPerRound} - Paused"
- } else {
- "Break Time - Paused"
+ PomodoroTimerService.TimerState.RUNNING, PomodoroTimerService.TimerState.PAUSED -> {
+ val stateLabel = if (state == PomodoroTimerService.TimerState.RUNNING) "Running" else "Paused"
+ when (phase) {
+ PomodoroTimerService.TimerPhase.WORK ->
+ "Work Session $session/${settings.sessionsPerRound} — $stateLabel"
+ PomodoroTimerService.TimerPhase.BREAK ->
+ "Break — $stateLabel"
+ PomodoroTimerService.TimerPhase.LONG_BREAK ->
+ "Long Break — $stateLabel"
}
}
}
}
- override fun getClickConsumer(): Consumer? {
- return Consumer { event ->
- if (event.button == MouseEvent.BUTTON1) {
- // Left click - open focus tool window
- SwingUtilities.invokeLater {
- val toolWindowManager = ToolWindowManager.getInstance(project)
- val toolWindow = toolWindowManager.getToolWindow("DevFocus")
- toolWindow?.show()
- }
+ override fun getClickConsumer(): Consumer = Consumer { event ->
+ if (event.button == MouseEvent.BUTTON1) {
+ SwingUtilities.invokeLater {
+ ToolWindowManager.getInstance(project).getToolWindow("DevFocus")?.show()
}
}
}
private fun observeTimer() {
- timeJob = scope.launch {
- timerService.timeLeft.collectLatest {
- updateText()
- }
- }
-
- stateJob = scope.launch {
- timerService.state.collectLatest {
- updateText()
- }
- }
-
- sessionJob = scope.launch {
- timerService.currentSession.collectLatest {
- updateText()
- }
- }
-
- phaseJob = scope.launch {
- timerService.currentPhase.collectLatest {
- updateText()
- }
- }
+ timeJob = scope.launch { timerService.timeLeft.collectLatest { updateText() } }
+ stateJob = scope.launch { timerService.state.collectLatest { updateText() } }
+ sessionJob = scope.launch { timerService.currentSession.collectLatest { updateText() } }
+ phaseJob = scope.launch { timerService.currentPhase.collectLatest { updateText() } }
+ dailyJob = scope.launch { timerService.dailySessionCount.collectLatest { updateText() } }
}
private fun updateText() {
@@ -122,27 +98,26 @@ class PomodoroStatusBarWidget(private val project: Project) : StatusBarWidget, S
val time = timerService.timeLeft.value
val session = timerService.currentSession.value
val settings = timerService.getSettings()
+ val daily = timerService.dailySessionCount.value
- // Only show text when timer is active (running or paused)
val isActive = state == PomodoroTimerService.TimerState.RUNNING ||
state == PomodoroTimerService.TimerState.PAUSED
currentText = if (isActive) {
- // Use stopwatch for work, coffee for break
- val prefix = if (phase == PomodoroTimerService.TimerPhase.WORK) "⏱\uFE0F" else "☕"
- val sessionInfo = if (phase == PomodoroTimerService.TimerPhase.WORK) {
- " | Session $session/${settings.sessionsPerRound}"
- } else {
- " | Break"
+ val pauseMarker = if (state == PomodoroTimerService.TimerState.PAUSED) " ⏸" else ""
+ when (phase) {
+ PomodoroTimerService.TimerPhase.WORK ->
+ "⏱️ $time | Session $session/${settings.sessionsPerRound}$pauseMarker"
+ PomodoroTimerService.TimerPhase.BREAK ->
+ "☕ $time | Break$pauseMarker"
+ PomodoroTimerService.TimerPhase.LONG_BREAK ->
+ "🌟 $time | Long Break$pauseMarker"
}
- "$prefix $time$sessionInfo"
} else {
- "" // Empty string when idle - Widget still exists but shows nothing
- }
-
- SwingUtilities.invokeLater {
- statusBar?.updateWidget(ID)
+ // Idle: show daily count as ambient progress, or just the plugin name
+ if (daily > 0) "🍅 $daily today" else "🍅 DevFocus"
}
+ SwingUtilities.invokeLater { statusBar?.updateWidget(ID) }
}
-}
\ 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 da0b08d..725b595 100644
--- a/src/main/kotlin/com/github/akshayashokcode/devfocus/toolWindow/PomodoroToolWindowPanel.kt
+++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/toolWindow/PomodoroToolWindowPanel.kt
@@ -45,18 +45,25 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
isFocusPainted = false
}
- // Info label showing current mode settings
+ // Info label: current mode durations
private val infoLabel = JLabel("📊 25 min work • 5 min break").apply {
horizontalAlignment = SwingConstants.CENTER
font = font.deriveFont(Font.BOLD, 12f)
}
+ // Daily session count — shown below info label
+ private val dailyCountLabel = JLabel("").apply {
+ horizontalAlignment = SwingConstants.CENTER
+ font = font.deriveFont(Font.PLAIN, 11f)
+ isVisible = false
+ }
+
private val sessionTextLabel = JLabel("Session 1 of 4").apply {
horizontalAlignment = SwingConstants.CENTER
font = font.deriveFont(Font.BOLD, 14f)
}
- // Phase label: shows "Focus" or "Break" clearly beneath the timer
+ // Phase label: "Focus" or "Break" or "Long Break"
private val phaseLabel = JLabel("Focus").apply {
horizontalAlignment = SwingConstants.CENTER
font = font.deriveFont(Font.BOLD, 13f)
@@ -71,14 +78,16 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
preferredSize = Dimension(80, 32)
font = font.deriveFont(Font.BOLD)
}
- private val pauseButton = JButton("Pause").apply {
- preferredSize = Dimension(80, 32)
- }
- private val resetButton = JButton("Reset").apply {
- preferredSize = Dimension(80, 32)
+ private val pauseButton = JButton("Pause").apply { preferredSize = Dimension(80, 32) }
+ private val resetButton = JButton("Reset").apply { preferredSize = Dimension(80, 32) }
+
+ // Skip break — visible only during break/long-break phases
+ private val skipBreakButton = JButton("Skip Break").apply {
+ preferredSize = Dimension(110, 28)
+ isVisible = false
}
- // Custom settings panel (only visible when Custom mode selected)
+ // Custom settings panel (Custom mode only)
private val settingsPanel = PomodoroSettingsPanel { session, breakTime, sessions ->
timerService.applySettings(PomodoroSettings(PomodoroMode.CUSTOM, session, breakTime, sessions))
updateInfoLabel(session, breakTime)
@@ -90,6 +99,7 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
private var timeJob: Job? = null
private var sessionJob: Job? = null
private var phaseJob: Job? = null
+ private var dailyJob: Job? = null
init {
buildUI()
@@ -112,10 +122,13 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
}
/**
- * Compact: either dimension < 160px.
- * Just the circular timer + a row of buttons. Everything else hidden.
+ * Compact (<160px either dimension): timer + action buttons only.
*/
private fun buildCompactLayout() {
+ startButton.preferredSize = Dimension(60, 26)
+ pauseButton.preferredSize = Dimension(60, 26)
+ resetButton.preferredSize = Dimension(60, 26)
+
val timerPanel = JPanel(BorderLayout()).apply {
border = BorderFactory.createEmptyBorder(6, 6, 4, 6)
add(circularTimer, BorderLayout.CENTER)
@@ -125,64 +138,57 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
add(pauseButton)
add(resetButton)
}
+ val skipPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 0)).apply { add(skipBreakButton) }
+ val southPanel = JPanel().apply {
+ layout = BoxLayout(this, BoxLayout.Y_AXIS)
+ add(buttonPanel)
+ add(skipPanel)
+ }
add(timerPanel, BorderLayout.CENTER)
- add(buttonPanel, BorderLayout.SOUTH)
+ add(southPanel, BorderLayout.SOUTH)
}
/**
- * Vertical: height >= width.
- * Mode selector at top. Timer fills all remaining vertical space via
- * BorderLayout.CENTER so it grows/shrinks naturally. Controls pinned at bottom.
- *
- * Scenarios handled:
- * - Tall + narrow → small circle (min(width, timerHeight) drives diameter)
- * - Tall + wide → large circle (width becomes the constraint)
+ * Vertical (height >= width): mode selector top, timer fills center, controls pinned bottom.
+ * Handles both tall+narrow and tall+wide naturally since the circle scales with min(w,h).
*/
private fun buildVerticalLayout() {
+ startButton.preferredSize = Dimension(80, 32)
+ pauseButton.preferredSize = Dimension(80, 32)
+ resetButton.preferredSize = Dimension(80, 32)
+
val topPanel = JPanel(BorderLayout(5, 5)).apply {
border = BorderFactory.createEmptyBorder(10, 10, 4, 10)
add(modeComboBox, BorderLayout.CENTER)
add(settingsButton, BorderLayout.EAST)
}
- val infoPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 4)).apply {
- add(infoLabel)
+ val infoPanel = JPanel().apply {
+ layout = BoxLayout(this, BoxLayout.Y_AXIS)
+ border = BorderFactory.createEmptyBorder(4, 0, 0, 0)
+ add(centeredRow(infoLabel))
+ add(centeredRow(dailyCountLabel))
}
- // Timer lives in CENTER — it stretches to fill whatever height is left
val timerPanel = JPanel(BorderLayout()).apply {
border = BorderFactory.createEmptyBorder(8, 10, 8, 10)
add(circularTimer, BorderLayout.CENTER)
}
- val phaseLabelPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).apply {
- add(phaseLabel)
- }
- val sessionPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).apply {
- add(sessionTextLabel)
- }
- val progressPanel = JPanel(BorderLayout(5, 5)).apply {
- border = BorderFactory.createEmptyBorder(4, 20, 4, 20)
- add(sessionIndicator, BorderLayout.CENTER)
- }
- val buttonPanel = JPanel(FlowLayout(FlowLayout.CENTER, 8, 5)).apply {
- add(startButton)
- add(pauseButton)
- add(resetButton)
- }
+ val skipPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).apply { add(skipBreakButton) }
- // Fixed-height controls below the timer
val controlsPanel = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
- add(phaseLabelPanel)
- add(sessionPanel)
- add(progressPanel)
- add(buttonPanel)
+ add(centeredRow(phaseLabel))
+ add(centeredRow(sessionTextLabel))
+ add(progressRow())
+ add(buttonRow(8))
+ add(skipPanel)
}
val centerPanel = JPanel(BorderLayout()).apply {
add(infoPanel, BorderLayout.NORTH)
- add(timerPanel, BorderLayout.CENTER) // ← grows with panel
+ add(timerPanel, BorderLayout.CENTER)
add(controlsPanel, BorderLayout.SOUTH)
}
@@ -192,14 +198,14 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
}
/**
- * Horizontal: width > height.
- * Mode selector spans the top. Timer takes left 55%, controls take right 45%.
- *
- * Scenarios handled:
- * - Wide + tall → large circle (height drives diameter), ample control space
- * - Wide + short → smaller circle, controls stack compactly on the right
+ * Horizontal (width > height): mode selector top, timer 55% left, controls 45% right.
+ * Handles wide+tall (large circle) and wide+short (smaller circle, controls stay centered).
*/
private fun buildHorizontalLayout() {
+ startButton.preferredSize = Dimension(80, 32)
+ pauseButton.preferredSize = Dimension(80, 32)
+ resetButton.preferredSize = Dimension(80, 32)
+
val topPanel = JPanel(BorderLayout(5, 5)).apply {
border = BorderFactory.createEmptyBorder(8, 10, 4, 10)
add(modeComboBox, BorderLayout.CENTER)
@@ -211,36 +217,24 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
add(circularTimer, BorderLayout.CENTER)
}
- val phaseLabelPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).apply { add(phaseLabel) }
- val sessionPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).apply { add(sessionTextLabel) }
- val progressPanel = JPanel(BorderLayout(5, 5)).apply {
- border = BorderFactory.createEmptyBorder(4, 8, 4, 8)
- add(sessionIndicator, BorderLayout.CENTER)
- }
- val buttonPanel = JPanel(FlowLayout(FlowLayout.CENTER, 6, 4)).apply {
- add(startButton)
- add(pauseButton)
- add(resetButton)
- }
+ val skipPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).apply { add(skipBreakButton) }
- // Controls centered vertically on the right side
val rightPanel = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
border = BorderFactory.createEmptyBorder(4, 4, 4, 12)
add(Box.createVerticalGlue())
- add(phaseLabelPanel)
- add(sessionPanel)
- add(progressPanel)
- add(buttonPanel)
+ add(centeredRow(infoLabel))
+ add(centeredRow(dailyCountLabel))
+ add(centeredRow(phaseLabel))
+ add(centeredRow(sessionTextLabel))
+ add(progressRow())
+ add(buttonRow(6))
+ add(skipPanel)
add(Box.createVerticalGlue())
}
- // Split: timer 55% | controls 45%
val splitPanel = JPanel(GridBagLayout()).apply {
- val gbc = GridBagConstraints().apply {
- fill = GridBagConstraints.BOTH
- weighty = 1.0
- }
+ val gbc = GridBagConstraints().apply { fill = GridBagConstraints.BOTH; weighty = 1.0 }
gbc.weightx = 0.55; gbc.gridx = 0; add(timerPanel, gbc)
gbc.weightx = 0.45; gbc.gridx = 1; add(rightPanel, gbc)
}
@@ -250,15 +244,26 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
add(settingsPanel, BorderLayout.SOUTH)
}
+ // Small helpers to avoid repetitive panel construction
+ private fun centeredRow(component: JComponent) =
+ JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).also { it.add(component) }
+
+ private fun progressRow() = JPanel(BorderLayout(5, 5)).apply {
+ border = BorderFactory.createEmptyBorder(4, 20, 4, 20)
+ add(sessionIndicator, BorderLayout.CENTER)
+ }
+
+ private fun buttonRow(gap: Int) = JPanel(FlowLayout(FlowLayout.CENTER, gap, 5)).apply {
+ add(startButton); add(pauseButton); add(resetButton)
+ }
+
// ---------------------------------------------------------------------------
// Responsive layout detection
// ---------------------------------------------------------------------------
private fun setupLayoutListener() {
addComponentListener(object : ComponentAdapter() {
- override fun componentResized(e: ComponentEvent?) {
- checkAndUpdateLayout()
- }
+ override fun componentResized(e: ComponentEvent?) = checkAndUpdateLayout()
})
}
@@ -270,18 +275,14 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
}
if (newLayout != currentLayout) {
currentLayout = newLayout
- rebuildLayout()
+ removeAll()
+ buildUI()
+ updateSettingsPanelVisibility()
+ revalidate()
+ repaint()
}
}
- private fun rebuildLayout() {
- removeAll()
- buildUI()
- updateSettingsPanelVisibility()
- revalidate()
- repaint()
- }
-
// ---------------------------------------------------------------------------
// Listeners & helpers
// ---------------------------------------------------------------------------
@@ -290,6 +291,7 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
startButton.addActionListener { timerService.start() }
pauseButton.addActionListener { timerService.pause() }
resetButton.addActionListener { timerService.reset() }
+ skipBreakButton.addActionListener { timerService.skipBreak() }
modeComboBox.addActionListener {
val selectedMode = modeComboBox.selectedItem as PomodoroMode
@@ -301,14 +303,11 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
updateSettingsPanelVisibility()
}
- settingsButton.addActionListener {
- PomodoroSettingsDialog(project).show()
- }
+ settingsButton.addActionListener { PomodoroSettingsDialog(project).show() }
}
private fun updateSettingsPanelVisibility() {
val isCustom = modeComboBox.selectedItem == PomodoroMode.CUSTOM
- // Never show the custom settings panel in compact mode — no room for it
settingsPanel.isVisible = isCustom && currentLayout != LayoutMode.COMPACT
revalidate()
repaint()
@@ -333,30 +332,27 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
timerService.timeLeft.collectLatest { time ->
SwingUtilities.invokeLater {
val progress = timerService.getProgress()
- val isBreak = timerService.currentPhase.value == PomodoroTimerService.TimerPhase.BREAK
+ val isBreak = timerService.currentPhase.value.isBreak
circularTimer.updateTimer(time, progress, isBreak)
}
}
}
stateJob = scope.launch {
- timerService.state.collectLatest {
+ timerService.state.collectLatest { state ->
SwingUtilities.invokeLater {
- startButton.isEnabled = it != PomodoroTimerService.TimerState.RUNNING
- pauseButton.isEnabled = it == PomodoroTimerService.TimerState.RUNNING
- resetButton.isEnabled = it != PomodoroTimerService.TimerState.IDLE
+ startButton.isEnabled = state != PomodoroTimerService.TimerState.RUNNING
+ pauseButton.isEnabled = state == PomodoroTimerService.TimerState.RUNNING
+ resetButton.isEnabled = state != PomodoroTimerService.TimerState.IDLE
- startButton.text = when (it) {
- PomodoroTimerService.TimerState.IDLE -> "Start"
- else -> "Resume"
- }
+ startButton.text = if (state == PomodoroTimerService.TimerState.IDLE) "Start" else "Resume"
startButton.putClientProperty("JButton.buttonType", null)
pauseButton.putClientProperty("JButton.buttonType", null)
resetButton.putClientProperty("JButton.buttonType", null)
- when (it) {
- PomodoroTimerService.TimerState.IDLE -> {
+ when (state) {
+ PomodoroTimerService.TimerState.IDLE, PomodoroTimerService.TimerState.PAUSED -> {
startButton.putClientProperty("JButton.buttonType", "default")
startButton.requestFocusInWindow()
}
@@ -364,24 +360,18 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
pauseButton.putClientProperty("JButton.buttonType", "default")
pauseButton.requestFocusInWindow()
}
- PomodoroTimerService.TimerState.PAUSED -> {
- startButton.putClientProperty("JButton.buttonType", "default")
- startButton.requestFocusInWindow()
- }
}
val currentSession = timerService.currentSession.value
val currentPhase = timerService.currentPhase.value
- val isTrulyIdle = it == PomodoroTimerService.TimerState.IDLE &&
- currentSession == 1 &&
- currentPhase == PomodoroTimerService.TimerPhase.WORK
+ val isTrulyIdle = state == PomodoroTimerService.TimerState.IDLE &&
+ currentSession == 1 && !currentPhase.isBreak
modeComboBox.isEnabled = isTrulyIdle
if (!isTrulyIdle && modeComboBox.selectedItem == PomodoroMode.CUSTOM) {
settingsPanel.isVisible = false
- revalidate()
- repaint()
+ revalidate(); repaint()
} else if (isTrulyIdle) {
updateSettingsPanelVisibility()
}
@@ -393,7 +383,7 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
timerService.currentSession.collectLatest { session ->
SwingUtilities.invokeLater {
val settings = timerService.getSettings()
- val isBreak = timerService.currentPhase.value == PomodoroTimerService.TimerPhase.BREAK
+ val isBreak = timerService.currentPhase.value.isBreak
sessionIndicator.updateSessions(session, settings.sessionsPerRound, isBreak)
sessionTextLabel.text = "Session $session of ${settings.sessionsPerRound}"
}
@@ -405,14 +395,36 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
SwingUtilities.invokeLater {
val settings = timerService.getSettings()
val session = timerService.currentSession.value
- val isBreak = phase == PomodoroTimerService.TimerPhase.BREAK
- sessionIndicator.updateSessions(session, settings.sessionsPerRound, isBreak)
- if (isBreak) {
- phaseLabel.text = "Break"
- phaseLabel.foreground = Color(243, 156, 18)
+ sessionIndicator.updateSessions(session, settings.sessionsPerRound, phase.isBreak)
+
+ when (phase) {
+ PomodoroTimerService.TimerPhase.WORK -> {
+ phaseLabel.text = "Focus"
+ phaseLabel.foreground = Color(74, 144, 226)
+ }
+ PomodoroTimerService.TimerPhase.BREAK -> {
+ phaseLabel.text = "Break"
+ phaseLabel.foreground = Color(243, 156, 18)
+ }
+ PomodoroTimerService.TimerPhase.LONG_BREAK -> {
+ phaseLabel.text = "Long Break"
+ phaseLabel.foreground = Color(155, 89, 182) // Purple for long break
+ }
+ }
+
+ skipBreakButton.isVisible = phase.isBreak
+ }
+ }
+ }
+
+ dailyJob = scope.launch {
+ timerService.dailySessionCount.collectLatest { count ->
+ SwingUtilities.invokeLater {
+ if (count > 0) {
+ dailyCountLabel.text = "🍅 $count session${if (count == 1) "" else "s"} today"
+ dailyCountLabel.isVisible = true
} else {
- phaseLabel.text = "Focus"
- phaseLabel.foreground = Color(74, 144, 226)
+ dailyCountLabel.isVisible = false
}
}
}
@@ -424,6 +436,7 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel
timeJob?.cancel()
sessionJob?.cancel()
phaseJob?.cancel()
+ dailyJob?.cancel()
scope.cancel()
}
}
diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/settings/PomodoroSettingsDialog.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/settings/PomodoroSettingsDialog.kt
index 9988eb0..4a14fe2 100644
--- a/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/settings/PomodoroSettingsDialog.kt
+++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/settings/PomodoroSettingsDialog.kt
@@ -5,24 +5,19 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogWrapper
import java.awt.BorderLayout
-import javax.swing.BorderFactory
-import javax.swing.JCheckBox
-import javax.swing.JComponent
-import javax.swing.JPanel
+import javax.swing.*
-class PomodoroSettingsDialog(
- project: Project
-) : DialogWrapper(project) {
+class PomodoroSettingsDialog(project: Project) : DialogWrapper(project) {
- private val settings =
- ApplicationManager.getApplication()
- .getService(DevFocusSettingsState::class.java)
+ private val settings = ApplicationManager.getApplication()
+ .getService(DevFocusSettingsState::class.java)
- private val soundCheckbox =
- JCheckBox(
- "Notification sound",
- settings.soundEnabled
- )
+ private val soundCheckbox = JCheckBox("Enable notification sounds", settings.soundEnabled)
+
+ private val autoStartCheckbox = JCheckBox(
+ "Auto-start next work session after break",
+ settings.autoStartNextSession
+ )
init {
title = "DevFocus Settings"
@@ -30,18 +25,25 @@ class PomodoroSettingsDialog(
}
override fun createCenterPanel(): JComponent {
-
return JPanel(BorderLayout()).apply {
- border = BorderFactory.createEmptyBorder(16, 16, 16, 16)
-
- add(soundCheckbox, BorderLayout.NORTH)
+ border = BorderFactory.createEmptyBorder(16, 16, 8, 16)
+ val panel = JPanel().apply {
+ layout = BoxLayout(this, BoxLayout.Y_AXIS)
+ add(soundCheckbox)
+ add(Box.createVerticalStrut(10))
+ add(autoStartCheckbox)
+ add(Box.createVerticalStrut(4))
+ add(JLabel("When disabled, a notification with a Start button
appears after each break so you choose when to resume.").apply {
+ border = BorderFactory.createEmptyBorder(0, 22, 0, 0)
+ })
+ }
+ add(panel, BorderLayout.NORTH)
}
}
override fun doOKAction() {
-
settings.soundEnabled = soundCheckbox.isSelected
-
+ settings.autoStartNextSession = autoStartCheckbox.isSelected
super.doOKAction()
}
-}
\ No newline at end of file
+}
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index 038e3d8..cf50950 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -21,19 +21,37 @@
com.intellij.modules.platform
-
- org.jetbrains.android
-
messages.MyBundle
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+