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 @@ [![Version](https://img.shields.io/jetbrains/plugin/v/30114.svg)](https://plugins.jetbrains.com/plugin/30114) [![Downloads](https://img.shields.io/jetbrains/plugin/d/30114.svg)](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 - - + + + + + + + + + + + + +