diff --git a/CHANGELOG.md b/CHANGELOG.md index ef36219..a2080c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ## [Unreleased] +## [2.2.0] +### Added +- **End time display** — tool window shows "Ends at X:XX PM" below the phase label and "Free by X:XX PM" below the session counter while the timer is running; both labels hide automatically when paused or idle and recalculate on resume +- **Saved custom presets** — name and save any custom timing configuration from the Custom mode panel; saved presets appear directly in the mode dropdown alongside the built-in modes and are persisted across IDE restarts +- **Customizable ring colors** — Focus and Break accent colors are now configurable via Settings; pick any color from a visual color picker (supports HSB wheel, RGB sliders, and hex input); defaults are restored if settings are cleared + +### Changed +- Play and Pause replaced by a single icon toggle button that switches between ▶ and ⏸ based on timer state, with a tooltip of "Start", "Resume", or "Pause" as appropriate +- Reset button updated to use an icon instead of text +- Custom mode duration inputs replaced with step spinners (Session: 5–120 min step 5, Break: 1–60 min step 1, Sessions: 1–10 step 1); spinner model enforces bounds natively, removing the need for validation error dialogs +- Switching to Custom mode in the dropdown now pre-populates the spinners with the currently active session settings + ## [2.1.0] ### Changed - Cleaned up boilerplate scaffold files (MyProjectActivity, MyProjectService) diff --git a/README.md b/README.md index 53d3e39..046a78f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Most Pomodoro timers don't survive an IDE restart, skip the long break entirely, **🔁 Proper long breaks.** After a full round of sessions, DevFocus fires a long break automatically — the defining feature of the Pomodoro technique that most timer plugins quietly omit. +**🕐 Tells you when you'll be done.** Once running, the tool window shows two live timestamps: when the current session ends and when the full round finishes. No mental math — just "Ends at 3:45 PM" and "Free by 5:30 PM" so you can plan your day around your focus blocks. + **⏭ Skip break from the notification.** When you're in flow, click **Skip Break** directly in the IDE notification balloon. No need to open the tool window or break your focus. **⌨️ Full keyboard control.** Start/Pause, Reset, and Skip Break are registered as IDE actions — assign your own shortcuts via **Settings → Keymap → DevFocus** to avoid conflicts with your existing bindings. All three are also reachable via **Tools → DevFocus** and Find Action (`Ctrl+Shift+A` / `⌘⇧A`). @@ -29,8 +31,10 @@ Most Pomodoro timers don't survive an IDE restart, skip the long break entirely, ## Everything else -- **Three modes** — Classic Pomodoro (25/5), Deep Work (50/10), or fully custom durations +- **Three built-in modes** — Classic Pomodoro (25/5), Deep Work (50/10), or fully Custom durations +- **Saved custom presets** — name and save your own timing configurations; switch to them in one click from the mode dropdown - **Visual circular timer** — arc depletes clockwise, colour-coded by phase +- **Customizable ring colors** — pick any Focus and Break accent color from a color picker in Settings - **Session indicator** — dot row showing completed, active, and upcoming sessions - **🍅 Daily session counter** — resets at midnight, shown in tool window and status bar - **Auto-start toggle** — choose whether work sessions start automatically after a break or wait for you diff --git a/gradle.properties b/gradle.properties index 863f65c..5bab94f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.github.akshayashokcode.devfocus pluginName = DevFocus pluginRepositoryUrl = https://github.com/AkshayAshokCode/DevFocus # SemVer format -> https://semver.org -pluginVersion = 2.1.0 +pluginVersion = 2.2.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 233 diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusResetAction.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusResetAction.kt new file mode 100644 index 0000000..dc8ac34 --- /dev/null +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusResetAction.kt @@ -0,0 +1,22 @@ +package com.github.akshayashokcode.devfocus.actions + +import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +/** Reset the timer and session back to the initial state. */ +class DevFocusResetAction : AnAction() { + + override fun actionPerformed(e: AnActionEvent) { + e.project?.getService(PomodoroTimerService::class.java)?.reset() + } + + override fun update(e: AnActionEvent) { + val service = e.project?.getService(PomodoroTimerService::class.java) + e.presentation.isEnabledAndVisible = + service != null && service.state.value != PomodoroTimerService.TimerState.IDLE + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusSkipBreakAction.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusSkipBreakAction.kt new file mode 100644 index 0000000..621ee68 --- /dev/null +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusSkipBreakAction.kt @@ -0,0 +1,22 @@ +package com.github.akshayashokcode.devfocus.actions + +import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +/** Skip the current break and move straight to the next work session. */ +class DevFocusSkipBreakAction : AnAction() { + + override fun actionPerformed(e: AnActionEvent) { + e.project?.getService(PomodoroTimerService::class.java)?.skipBreak() + } + + override fun update(e: AnActionEvent) { + val service = e.project?.getService(PomodoroTimerService::class.java) + e.presentation.isEnabledAndVisible = + service != null && service.currentPhase.value.isBreak + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusToggleAction.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusToggleAction.kt new file mode 100644 index 0000000..f1e5512 --- /dev/null +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/actions/DevFocusToggleAction.kt @@ -0,0 +1,31 @@ +package com.github.akshayashokcode.devfocus.actions + +import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +/** Start the timer if idle/paused; pause it if running. */ +class DevFocusToggleAction : AnAction() { + + override fun actionPerformed(e: AnActionEvent) { + val service = e.project?.getService(PomodoroTimerService::class.java) ?: return + if (service.state.value == PomodoroTimerService.TimerState.RUNNING) { + service.pause() + } else { + service.start() + } + } + + override fun update(e: AnActionEvent) { + val service = e.project?.getService(PomodoroTimerService::class.java) + e.presentation.isEnabledAndVisible = service != null + if (service != null) { + e.presentation.text = + if (service.state.value == PomodoroTimerService.TimerState.RUNNING) "Pause Timer" + else "Start / Resume Timer" + } + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/model/SavedPreset.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/model/SavedPreset.kt new file mode 100644 index 0000000..da24660 --- /dev/null +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/model/SavedPreset.kt @@ -0,0 +1,8 @@ +package com.github.akshayashokcode.devfocus.model + +data class SavedPreset( + val name: String, + val sessionMinutes: Int, + val breakMinutes: Int, + val sessionsPerRound: Int +) 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 05eb086..b9780a3 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 @@ -31,7 +31,6 @@ 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 } @@ -88,12 +87,13 @@ class PomodoroTimerService(private val project: Project) { persistState() job = coroutineScope.launch { - var ticks = 0 while (remainingTimeMs > 0 && isActive) { delay(ONE_SECOND) remainingTimeMs -= ONE_SECOND _timeLeft.value = formatTime(remainingTimeMs) - if (++ticks % SAVE_INTERVAL_TICKS == 0) persistState() + // Guard: never save at remainingMs=0 — phase hasn't transitioned yet, + // so a crash here would restore a stale WORK state and replay the session. + if (remainingTimeMs > 0) persistState() } if (remainingTimeMs <= 0) { // Do NOT set IDLE here — onSessionComplete updates phase/session first, @@ -148,45 +148,44 @@ class PomodoroTimerService(private val project: Project) { if (sessionNum >= totalSessions) { // Full round done — start long break - playWorkEndSound() val longMin = settings.longBreakMinutes - notifyWithAction( - title = "🎉 Round Complete!", - body = "Outstanding! $totalSessions sessions done. Enjoy a $longMin-min long break.", - actionText = "Skip Long Break", - action = { skipBreak() } - ) _currentSession.value = 1 internalPhase = TimerPhase.LONG_BREAK _currentPhase.value = TimerPhase.LONG_BREAK remainingTimeMs = TimeUnit.MINUTES.toMillis(longMin.toLong()) _timeLeft.value = formatTime(remainingTimeMs) _state.value = TimerState.IDLE - persistState() + persistState() // save new phase before any side effects + playWorkEndSound() + notifyWithAction( + title = "🎉 Round Complete!", + body = "Outstanding! $totalSessions sessions done. Enjoy a $longMin-min long break.", + actionText = "Skip Long Break", + action = { skipBreak() } + ) start() // long break always auto-starts } else { // Short break between sessions - playWorkEndSound() _currentSession.value = sessionNum + 1 + internalPhase = TimerPhase.BREAK + _currentPhase.value = TimerPhase.BREAK + remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong()) + _timeLeft.value = formatTime(remainingTimeMs) + _state.value = TimerState.IDLE + persistState() // save new phase before any side effects + playWorkEndSound() notifyWithAction( title = "✅ Session $sessionNum Complete!", body = "Great work! Starting ${settings.breakMinutes}-min break ☕.", actionText = "Skip Break", action = { skipBreak() } ) - internalPhase = TimerPhase.BREAK - _currentPhase.value = TimerPhase.BREAK - remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong()) - _timeLeft.value = formatTime(remainingTimeMs) - _state.value = TimerState.IDLE - persistState() start() // short breaks always auto-start } } else { // BREAK or LONG_BREAK complete - playBreakEndSound() val nextSession = _currentSession.value val autoStart = appSettings.autoStartNextSession internalPhase = TimerPhase.WORK @@ -194,7 +193,8 @@ class PomodoroTimerService(private val project: Project) { remainingTimeMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong()) _timeLeft.value = formatTime(remainingTimeMs) _state.value = TimerState.IDLE - persistState() + persistState() // save new phase before any side effects + playBreakEndSound() if (autoStart) { notify( @@ -235,6 +235,21 @@ class PomodoroTimerService(private val project: Project) { fun getSettings(): PomodoroSettings = settings + fun getRemainingSessionMs(): Long = remainingTimeMs + + fun getRemainingRoundMs(): Long { + val sessionMs = TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong()) + val breakMs = TimeUnit.MINUTES.toMillis(settings.breakMinutes.toLong()) + val longBreakMs = TimeUnit.MINUTES.toMillis(settings.longBreakMinutes.toLong()) + val T = settings.sessionsPerRound + val N = _currentSession.value + return when (internalPhase) { + TimerPhase.LONG_BREAK -> remainingTimeMs + TimerPhase.WORK -> remainingTimeMs + (T - N) * (sessionMs + breakMs) + longBreakMs + TimerPhase.BREAK -> remainingTimeMs + (T - N) * (sessionMs + breakMs) + sessionMs + longBreakMs + } + } + fun getProgress(): Float { val totalMs = when (internalPhase) { TimerPhase.WORK -> TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong()) @@ -253,6 +268,7 @@ class PomodoroTimerService(private val project: Project) { savedRemainingTimeMs = remainingTimeMs savedCurrentSession = _currentSession.value savedPhase = internalPhase.name + savedTimerState = _state.value.name savedTimerWasRunning = _state.value == TimerState.RUNNING savedSessionMinutes = settings.sessionMinutes savedBreakMinutes = settings.breakMinutes @@ -292,7 +308,11 @@ class PomodoroTimerService(private val project: Project) { else TimeUnit.MINUTES.toMillis(settings.sessionMinutes.toLong()) _timeLeft.value = formatTime(remainingTimeMs) - if (saved.savedTimerWasRunning && savedMs > 0) { + // Restore as PAUSED for both RUNNING and PAUSED — fall back to old boolean for existing saves + val priorState = runCatching { TimerState.valueOf(saved.savedTimerState) }.getOrElse { + if (saved.savedTimerWasRunning) TimerState.RUNNING else TimerState.IDLE + } + if ((priorState == TimerState.RUNNING || priorState == TimerState.PAUSED) && savedMs > 0) { _state.value = TimerState.PAUSED } } 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 ad9988e..c840d64 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 @@ -1,5 +1,6 @@ package com.github.akshayashokcode.devfocus.services.settings +import com.github.akshayashokcode.devfocus.model.SavedPreset import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.Service import com.intellij.openapi.components.State @@ -18,15 +19,24 @@ class DevFocusSettingsState : PersistentStateComponent reliably) + var presetNames: MutableList = mutableListOf(), + var presetSessions: MutableList = mutableListOf(), + var presetBreaks: MutableList = mutableListOf(), + var presetCounts: MutableList = mutableListOf() ) private var state = SettingsState() @@ -60,6 +70,10 @@ class DevFocusSettingsState : PersistentStateComponent = + state.presetNames.indices.mapNotNull { i -> + SavedPreset( + name = state.presetNames[i], + sessionMinutes = state.presetSessions.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null, + breakMinutes = state.presetBreaks.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null, + sessionsPerRound = state.presetCounts.getOrNull(i)?.toIntOrNull() ?: return@mapNotNull null + ) + } + + fun addSavedPreset(preset: SavedPreset) { + state.presetNames.add(preset.name) + state.presetSessions.add(preset.sessionMinutes.toString()) + state.presetBreaks.add(preset.breakMinutes.toString()) + state.presetCounts.add(preset.sessionsPerRound.toString()) + } } 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 725b595..03bd45e 100644 --- a/src/main/kotlin/com/github/akshayashokcode/devfocus/toolWindow/PomodoroToolWindowPanel.kt +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/toolWindow/PomodoroToolWindowPanel.kt @@ -2,15 +2,19 @@ package com.github.akshayashokcode.devfocus.toolWindow import com.github.akshayashokcode.devfocus.model.PomodoroMode import com.github.akshayashokcode.devfocus.model.PomodoroSettings +import com.github.akshayashokcode.devfocus.model.SavedPreset import com.github.akshayashokcode.devfocus.services.pomodoro.PomodoroTimerService +import com.github.akshayashokcode.devfocus.services.settings.DevFocusSettingsState import com.github.akshayashokcode.devfocus.ui.components.CircularTimerPanel import com.github.akshayashokcode.devfocus.ui.components.SessionIndicatorPanel import com.github.akshayashokcode.devfocus.ui.settings.PomodoroSettingsDialog import com.github.akshayashokcode.devfocus.ui.settings.PomodoroSettingsPanel import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.ui.components.JBPanel +import com.intellij.util.IconUtil import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import java.awt.BorderLayout @@ -22,6 +26,9 @@ import java.awt.GridBagConstraints import java.awt.GridBagLayout import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import javax.swing.* class PomodoroToolWindowPanel(private val project: Project) : JBPanel>(BorderLayout()), Disposable { @@ -29,12 +36,23 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel private val timerService = project.getService(PomodoroTimerService::class.java) ?: error("PomodoroTimerService not available") + private val appSettings: DevFocusSettingsState + get() = ApplicationManager.getApplication().getService(DevFocusSettingsState::class.java) + private enum class LayoutMode { COMPACT, VERTICAL, HORIZONTAL } private var currentLayout = LayoutMode.VERTICAL - // Mode selector - private val modeComboBox = JComboBox(PomodoroMode.entries.toTypedArray()).apply { + // Mode selector — holds PomodoroMode entries plus any SavedPreset items + private val modeComboBox = JComboBox().apply { + PomodoroMode.entries.forEach { addItem(it) } selectedItem = PomodoroMode.CLASSIC + setRenderer { _, value, _, _, _ -> + JLabel(when (value) { + is PomodoroMode -> value.toString() + is SavedPreset -> "📌 ${value.name}" + else -> value?.toString() ?: "" + }) + } } // Settings button @@ -48,7 +66,7 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel // 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) + font = font.deriveFont(Font.PLAIN, 12f) } // Daily session count — shown below info label @@ -60,26 +78,47 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel private val sessionTextLabel = JLabel("Session 1 of 4").apply { horizontalAlignment = SwingConstants.CENTER - font = font.deriveFont(Font.BOLD, 14f) + font = font.deriveFont(Font.BOLD, 13f) } // Phase label: "Focus" or "Break" or "Long Break" private val phaseLabel = JLabel("Focus").apply { horizontalAlignment = SwingConstants.CENTER - font = font.deriveFont(Font.BOLD, 13f) + font = font.deriveFont(Font.BOLD, 16f) foreground = Color(74, 144, 226) } + // transparent foreground = text invisible but still occupies layout space + private val sessionEndTimeLabel = JLabel("Focusing until 00:00 am").apply { + horizontalAlignment = SwingConstants.CENTER + font = font.deriveFont(Font.PLAIN, 12f) + foreground = Color(0, 0, 0, 0) + } + + private val roundEndTimeLabel = JLabel("All done by 00:00 am").apply { + horizontalAlignment = SwingConstants.CENTER + font = font.deriveFont(Font.PLAIN, 12f) + foreground = Color(0, 0, 0, 0) + } + private val circularTimer = CircularTimerPanel() private val sessionIndicator = SessionIndicatorPanel() - // Control buttons - private val startButton = JButton("Start").apply { - preferredSize = Dimension(80, 32) - font = font.deriveFont(Font.BOLD) + // Single play/pause toggle — icon swaps based on timer state + private val playPauseButton = JButton(IconUtil.scale(AllIcons.Actions.Execute, null, 1.5f)).apply { + toolTipText = "Start" + preferredSize = Dimension(40, 32) + isBorderPainted = false + isContentAreaFilled = false + isFocusPainted = false + } + private val resetButton = JButton(IconUtil.scale(AllIcons.Actions.Restart, null, 1.5f)).apply { + toolTipText = "Reset" + preferredSize = Dimension(40, 32) + isBorderPainted = false + isContentAreaFilled = false + isFocusPainted = false } - 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 { @@ -88,11 +127,19 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel } // Custom settings panel (Custom mode only) - private val settingsPanel = PomodoroSettingsPanel { session, breakTime, sessions -> - timerService.applySettings(PomodoroSettings(PomodoroMode.CUSTOM, session, breakTime, sessions)) - updateInfoLabel(session, breakTime) - updateProgressBar(sessions) - } + private val settingsPanel = PomodoroSettingsPanel( + applySettingsCallback = { session, breakTime, sessions -> + timerService.applySettings(PomodoroSettings(PomodoroMode.CUSTOM, session, breakTime, sessions)) + updateInfoLabel(session, breakTime) + updateProgressBar(sessions) + }, + savePresetCallback = { name, session, breakTime, sessions -> + val preset = SavedPreset(name, session, breakTime, sessions) + appSettings.addSavedPreset(preset) + modeComboBox.addItem(preset) + modeComboBox.selectedItem = preset + } + ) private val scope = CoroutineScope(Dispatchers.Default) private var stateJob: Job? = null @@ -102,7 +149,9 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel private var dailyJob: Job? = null init { + appSettings.getSavedPresets().forEach { modeComboBox.addItem(it) } buildUI() + applyStoredColors() setupListeners() observeTimer() updateSettingsPanelVisibility() @@ -125,17 +174,12 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel * 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) } val buttonPanel = JPanel(FlowLayout(FlowLayout.CENTER, 4, 2)).apply { - add(startButton) - add(pauseButton) + add(playPauseButton) add(resetButton) } val skipPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 0)).apply { add(skipBreakButton) } @@ -153,10 +197,6 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel * 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) @@ -170,17 +210,29 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel add(centeredRow(dailyCountLabel)) } - val timerPanel = JPanel(BorderLayout()).apply { - border = BorderFactory.createEmptyBorder(8, 10, 8, 10) - add(circularTimer, BorderLayout.CENTER) + // Timer group: circle + phase label + end time — centered as a unit + val timerGroup = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(8, 10, 4, 10) + isOpaque = false + add(circularTimer, BorderLayout.CENTER) + }) + add(Box.createVerticalStrut(4)) + add(centeredRow(phaseLabel)) + add(centeredRow(sessionEndTimeLabel)) } val skipPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).apply { add(skipBreakButton) } - val controlsPanel = JPanel().apply { + // Bottom group: session stats + dots + buttons — pinned to bottom + val bottomGroup = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(centeredRow(phaseLabel)) + isOpaque = false + border = BorderFactory.createEmptyBorder(0, 0, 6, 0) add(centeredRow(sessionTextLabel)) + add(centeredRow(roundEndTimeLabel)) add(progressRow()) add(buttonRow(8)) add(skipPanel) @@ -188,8 +240,8 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel val centerPanel = JPanel(BorderLayout()).apply { add(infoPanel, BorderLayout.NORTH) - add(timerPanel, BorderLayout.CENTER) - add(controlsPanel, BorderLayout.SOUTH) + add(JPanel(GridBagLayout()).apply { add(timerGroup) }, BorderLayout.CENTER) + add(bottomGroup, BorderLayout.SOUTH) } add(topPanel, BorderLayout.NORTH) @@ -202,40 +254,51 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel * 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) add(settingsButton, BorderLayout.EAST) } - val timerPanel = JPanel(BorderLayout()).apply { - border = BorderFactory.createEmptyBorder(8, 12, 8, 6) - add(circularTimer, BorderLayout.CENTER) + // Timer group: circle + phase label + end time — centered in left column + val timerGroup = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(8, 12, 4, 6) + isOpaque = false + add(circularTimer, BorderLayout.CENTER) + }) + add(Box.createVerticalStrut(4)) + add(centeredRow(phaseLabel)) + add(centeredRow(sessionEndTimeLabel)) } val skipPanel = JPanel(FlowLayout(FlowLayout.CENTER, 0, 2)).apply { add(skipBreakButton) } - val rightPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) + val rightPanel = JPanel(BorderLayout()).apply { border = BorderFactory.createEmptyBorder(4, 4, 4, 12) - add(Box.createVerticalGlue()) - add(centeredRow(infoLabel)) - add(centeredRow(dailyCountLabel)) - add(centeredRow(phaseLabel)) - add(centeredRow(sessionTextLabel)) - add(progressRow()) - add(buttonRow(6)) - add(skipPanel) - add(Box.createVerticalGlue()) + add(JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(centeredRow(infoLabel)) + add(centeredRow(dailyCountLabel)) + }, BorderLayout.NORTH) + add(JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + border = BorderFactory.createEmptyBorder(0, 0, 6, 0) + add(centeredRow(sessionTextLabel)) + add(centeredRow(roundEndTimeLabel)) + add(progressRow()) + add(buttonRow(6)) + add(skipPanel) + }, BorderLayout.SOUTH) } val splitPanel = JPanel(GridBagLayout()).apply { val gbc = GridBagConstraints().apply { fill = GridBagConstraints.BOTH; weighty = 1.0 } - gbc.weightx = 0.55; gbc.gridx = 0; add(timerPanel, gbc) + gbc.weightx = 0.55; gbc.gridx = 0; add(JPanel(GridBagLayout()).apply { add(timerGroup) }, gbc) gbc.weightx = 0.45; gbc.gridx = 1; add(rightPanel, gbc) } @@ -254,7 +317,7 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel } private fun buttonRow(gap: Int) = JPanel(FlowLayout(FlowLayout.CENTER, gap, 5)).apply { - add(startButton); add(pauseButton); add(resetButton) + add(playPauseButton); add(resetButton) } // --------------------------------------------------------------------------- @@ -288,22 +351,41 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel // --------------------------------------------------------------------------- private fun setupListeners() { - startButton.addActionListener { timerService.start() } - pauseButton.addActionListener { timerService.pause() } + playPauseButton.addActionListener { + if (timerService.state.value == PomodoroTimerService.TimerState.RUNNING) + timerService.pause() else timerService.start() + } resetButton.addActionListener { timerService.reset() } skipBreakButton.addActionListener { timerService.skipBreak() } modeComboBox.addActionListener { - val selectedMode = modeComboBox.selectedItem as PomodoroMode - if (selectedMode != PomodoroMode.CUSTOM) { - timerService.applyMode(selectedMode) - updateInfoLabel(selectedMode.sessionMinutes, selectedMode.breakMinutes) - updateProgressBar(selectedMode.sessionsPerRound) + when (val selected = modeComboBox.selectedItem) { + is PomodoroMode -> when (selected) { + PomodoroMode.CUSTOM -> { + val s = timerService.getSettings() + settingsPanel.loadValues(s.sessionMinutes, s.breakMinutes, s.sessionsPerRound) + } + else -> { + timerService.applyMode(selected) + updateInfoLabel(selected.sessionMinutes, selected.breakMinutes) + updateProgressBar(selected.sessionsPerRound) + } + } + is SavedPreset -> { + timerService.applySettings( + PomodoroSettings(PomodoroMode.CUSTOM, selected.sessionMinutes, selected.breakMinutes, selected.sessionsPerRound) + ) + updateInfoLabel(selected.sessionMinutes, selected.breakMinutes) + updateProgressBar(selected.sessionsPerRound) + } } updateSettingsPanelVisibility() } - settingsButton.addActionListener { PomodoroSettingsDialog(project).show() } + settingsButton.addActionListener { + PomodoroSettingsDialog(project).show() + applyStoredColors() + } } private fun updateSettingsPanelVisibility() { @@ -323,6 +405,30 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel sessionTextLabel.text = "Session $currentSession of $totalSessions" } + private fun applyStoredColors() { + circularTimer.focusColor = runCatching { Color.decode(appSettings.focusColorHex) }.getOrDefault(Color(74, 144, 226)) + circularTimer.breakColor = runCatching { Color.decode(appSettings.breakColorHex) }.getOrDefault(Color(243, 156, 18)) + circularTimer.repaint() + } + + private val wallTimeFormatter = DateTimeFormatter.ofPattern("h:mm a") + + private fun formatWallTime(epochMs: Long): String = + Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault()).format(wallTimeFormatter) + + private fun updateEndTimeLabels() { + val now = System.currentTimeMillis() + val phasePrefix = when (timerService.currentPhase.value) { + PomodoroTimerService.TimerPhase.WORK -> "Focusing" + PomodoroTimerService.TimerPhase.BREAK -> "Break" + PomodoroTimerService.TimerPhase.LONG_BREAK -> "Long break" + } + sessionEndTimeLabel.text = "$phasePrefix until ${formatWallTime(now + timerService.getRemainingSessionMs())}" + sessionEndTimeLabel.foreground = null + roundEndTimeLabel.text = "All done by ${formatWallTime(now + timerService.getRemainingRoundMs())}" + roundEndTimeLabel.foreground = null + } + // --------------------------------------------------------------------------- // Timer observation // --------------------------------------------------------------------------- @@ -334,6 +440,9 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel val progress = timerService.getProgress() val isBreak = timerService.currentPhase.value.isBreak circularTimer.updateTimer(time, progress, isBreak) + if (timerService.state.value == PomodoroTimerService.TimerState.RUNNING) { + updateEndTimeLabels() + } } } } @@ -341,24 +450,28 @@ class PomodoroToolWindowPanel(private val project: Project) : JBPanel stateJob = scope.launch { timerService.state.collectLatest { state -> SwingUtilities.invokeLater { - startButton.isEnabled = state != PomodoroTimerService.TimerState.RUNNING - pauseButton.isEnabled = state == PomodoroTimerService.TimerState.RUNNING resetButton.isEnabled = state != PomodoroTimerService.TimerState.IDLE - 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 (state) { - PomodoroTimerService.TimerState.IDLE, PomodoroTimerService.TimerState.PAUSED -> { - startButton.putClientProperty("JButton.buttonType", "default") - startButton.requestFocusInWindow() + PomodoroTimerService.TimerState.IDLE -> { + playPauseButton.icon = IconUtil.scale(AllIcons.Actions.Execute, null, 1.5f) + playPauseButton.toolTipText = "Start" + playPauseButton.requestFocusInWindow() + sessionEndTimeLabel.foreground = Color(0, 0, 0, 0) + roundEndTimeLabel.foreground = Color(0, 0, 0, 0) + } + PomodoroTimerService.TimerState.PAUSED -> { + playPauseButton.icon = IconUtil.scale(AllIcons.Actions.Execute, null, 1.5f) + playPauseButton.toolTipText = "Resume" + playPauseButton.requestFocusInWindow() + sessionEndTimeLabel.foreground = Color(0, 0, 0, 0) + roundEndTimeLabel.foreground = Color(0, 0, 0, 0) } PomodoroTimerService.TimerState.RUNNING -> { - pauseButton.putClientProperty("JButton.buttonType", "default") - pauseButton.requestFocusInWindow() + playPauseButton.icon = IconUtil.scale(AllIcons.Actions.Pause, null, 1.5f) + playPauseButton.toolTipText = "Pause" + playPauseButton.requestFocusInWindow() + updateEndTimeLabels() } } diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/CircularTimerPanel.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/CircularTimerPanel.kt index 384feac..3e3e232 100644 --- a/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/CircularTimerPanel.kt +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/components/CircularTimerPanel.kt @@ -10,8 +10,8 @@ class CircularTimerPanel : JPanel() { private var progress: Float = 1.0f // 0.0 to 1.0 (1.0 = full, 0.0 = empty) private var isBreakTime: Boolean = false - private val workColor = Color(74, 144, 226) - private val breakColor = Color(243, 156, 18) + var focusColor: Color = Color(74, 144, 226) + var breakColor: Color = Color(243, 156, 18) private val backgroundColor: Color get() = UIManager.getColor("Separator.separatorColor") ?: UIManager.getColor("Component.borderColor") @@ -58,7 +58,7 @@ class CircularTimerPanel : JPanel() { // Progress arc (clockwise from top, depleting) val arcAngle = (360 * progress).toInt() - g2d.color = if (isBreakTime) breakColor else workColor + g2d.color = if (isBreakTime) breakColor else focusColor g2d.stroke = BasicStroke(strokeWidth, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) g2d.drawArc(arcX, arcY, diameter, diameter, 90, -arcAngle) 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 4a14fe2..60c6293 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,6 +5,9 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper import java.awt.BorderLayout +import java.awt.Color +import java.awt.Dimension +import java.awt.FlowLayout import javax.swing.* class PomodoroSettingsDialog(project: Project) : DialogWrapper(project) { @@ -13,14 +16,35 @@ class PomodoroSettingsDialog(project: Project) : DialogWrapper(project) { .getService(DevFocusSettingsState::class.java) private val soundCheckbox = JCheckBox("Enable notification sounds", settings.soundEnabled) - private val autoStartCheckbox = JCheckBox( "Auto-start next work session after break", settings.autoStartNextSession ) + private var selectedFocusColor = parseColor(settings.focusColorHex, Color(74, 144, 226)) + private var selectedBreakColor = parseColor(settings.breakColorHex, Color(243, 156, 18)) + + private val focusSwatchButton = swatchButton(selectedFocusColor) + private val breakSwatchButton = swatchButton(selectedBreakColor) + init { title = "DevFocus Settings" + + focusSwatchButton.addActionListener { + val color = JColorChooser.showDialog(focusSwatchButton, "Focus Ring Color", selectedFocusColor) + if (color != null) { + selectedFocusColor = color + focusSwatchButton.background = color + } + } + breakSwatchButton.addActionListener { + val color = JColorChooser.showDialog(breakSwatchButton, "Break Ring Color", selectedBreakColor) + if (color != null) { + selectedBreakColor = color + breakSwatchButton.background = color + } + } + init() } @@ -36,6 +60,12 @@ class PomodoroSettingsDialog(project: Project) : DialogWrapper(project) { 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(Box.createVerticalStrut(14)) + add(JSeparator()) + add(Box.createVerticalStrut(10)) + add(colorRow("Focus ring color:", focusSwatchButton)) + add(Box.createVerticalStrut(8)) + add(colorRow("Break ring color:", breakSwatchButton)) } add(panel, BorderLayout.NORTH) } @@ -44,6 +74,28 @@ class PomodoroSettingsDialog(project: Project) : DialogWrapper(project) { override fun doOKAction() { settings.soundEnabled = soundCheckbox.isSelected settings.autoStartNextSession = autoStartCheckbox.isSelected + settings.focusColorHex = toHex(selectedFocusColor) + settings.breakColorHex = toHex(selectedBreakColor) super.doOKAction() } + + private fun colorRow(label: String, swatch: JButton) = + JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + add(JLabel(label).apply { preferredSize = Dimension(130, 24) }) + add(swatch) + } + + private fun swatchButton(color: Color) = JButton().apply { + background = color + preferredSize = Dimension(44, 22) + isOpaque = true + isBorderPainted = true + isFocusPainted = false + } + + private fun toHex(color: Color) = + String.format("#%02x%02x%02x", color.red, color.green, color.blue) + + private fun parseColor(hex: String, fallback: Color) = + runCatching { Color.decode(hex) }.getOrDefault(fallback) } diff --git a/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/settings/PomodoroSettingsPanel.kt b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/settings/PomodoroSettingsPanel.kt index 71e5f4b..d317aca 100644 --- a/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/settings/PomodoroSettingsPanel.kt +++ b/src/main/kotlin/com/github/akshayashokcode/devfocus/ui/settings/PomodoroSettingsPanel.kt @@ -1,97 +1,73 @@ package com.github.akshayashokcode.devfocus.ui.settings -import com.github.akshayashokcode.devfocus.util.SettingsValidationResult -import com.github.akshayashokcode.devfocus.util.validateSettings -import java.awt.Color import java.awt.Dimension import java.awt.GridLayout import javax.swing.* -import javax.swing.border.LineBorder class PomodoroSettingsPanel( - private val applySettingsCallback: (Int, Int, Int) -> Unit -) : JPanel(GridLayout(4, 2, 8, 5)) { + private val applySettingsCallback: (Int, Int, Int) -> Unit, + private val savePresetCallback: (name: String, session: Int, breakTime: Int, sessions: Int) -> Unit +) : JPanel(GridLayout(5, 2, 8, 5)) { - private val sessionField = JTextField("25").apply { - preferredSize = Dimension(60, 28) + private val sessionSpinner = JSpinner(SpinnerNumberModel(25, 5, 120, 5)).apply { + preferredSize = Dimension(80, 28) } - private val breakField = JTextField("5").apply { - preferredSize = Dimension(60, 28) + private val breakSpinner = JSpinner(SpinnerNumberModel(5, 1, 60, 1)).apply { + preferredSize = Dimension(80, 28) } - private val sessionsField = JTextField("4").apply { - preferredSize = Dimension(60, 28) + private val sessionsSpinner = JSpinner(SpinnerNumberModel(4, 1, 10, 1)).apply { + preferredSize = Dimension(80, 28) } private val applyButton = JButton("Apply").apply { preferredSize = Dimension(100, 32) } + private val savePresetButton = JButton("Save as Preset").apply { + preferredSize = Dimension(100, 32) + } init { border = BorderFactory.createEmptyBorder(10, 15, 10, 15) add(JLabel("Session Duration (min):")) - add(sessionField) + add(sessionSpinner) add(JLabel("Break Duration (min):")) - add(breakField) + add(breakSpinner) add(JLabel("Sessions per Round:")) - add(sessionsField) - add(JLabel()) // spacer + add(sessionsSpinner) + add(JLabel()) add(applyButton) - - clearOnType(sessionField) - clearOnType(breakField) - clearOnType(sessionsField) + add(JLabel()) + add(savePresetButton) applyButton.addActionListener { - // Reset all fields to default border - val defaultBorder = UIManager.getLookAndFeel().getDefaults().getBorder("TextField.border") - sessionField.border = defaultBorder - breakField.border = defaultBorder - sessionsField.border = defaultBorder - - val session = sessionField.text.toIntOrNull() - val breakTime = breakField.text.toIntOrNull() - val sessions = sessionsField.text.toIntOrNull() - - when (val result = validateSettings(session, breakTime, sessions)) { - is SettingsValidationResult.Valid -> { - applySettingsCallback( - result.settings.sessionMinutes, - result.settings.breakMinutes, - result.settings.sessionsPerRound - ) - JOptionPane.showMessageDialog(this, "Settings applied successfully.") - } - - is SettingsValidationResult.Invalid -> { - val errorBorder = LineBorder(Color.RED, 2) - - // Highlight the appropriate field - when (result.field) { - "session" -> sessionField.border = errorBorder - "break" -> breakField.border = errorBorder - "sessions" -> sessionsField.border = errorBorder - } + applySettingsCallback( + sessionSpinner.value as Int, + breakSpinner.value as Int, + sessionsSpinner.value as Int + ) + } - // Show popup error as well - JOptionPane.showMessageDialog( - this, - result.errorMessage, - "Validation Error", - JOptionPane.ERROR_MESSAGE - ) - } + savePresetButton.addActionListener { + val name = JOptionPane.showInputDialog( + this, + "Enter a name for this preset:", + "Save Preset", + JOptionPane.PLAIN_MESSAGE + ) + if (!name.isNullOrBlank()) { + savePresetCallback( + name.trim(), + sessionSpinner.value as Int, + breakSpinner.value as Int, + sessionsSpinner.value as Int + ) } } } - private fun clearOnType(field: JTextField) { - val defaultBorder = UIManager.getLookAndFeel().getDefaults().getBorder("TextField.border") - field.document.addDocumentListener(object : javax.swing.event.DocumentListener { - override fun insertUpdate(e: javax.swing.event.DocumentEvent?) = clear() - override fun removeUpdate(e: javax.swing.event.DocumentEvent?) = clear() - override fun changedUpdate(e: javax.swing.event.DocumentEvent?) = clear() - private fun clear() { - field.border = defaultBorder - } - }) + + fun loadValues(sessionMin: Int, breakMin: Int, sessionsPerRound: Int) { + sessionSpinner.value = sessionMin.coerceIn(5, 120) + breakSpinner.value = breakMin.coerceIn(1, 60) + sessionsSpinner.value = sessionsPerRound.coerceIn(1, 10) } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index cf50950..7896864 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -7,14 +7,26 @@ >akshayashokcode
+ Timer state survives restarts and crashes. Built-in Classic and Deep Work modes, plus + unlimited named custom presets switchable from the mode dropdown. Live end-time display + shows exactly when your session ends and when your full round finishes — so you can plan + your day around your focus blocks. Customizable ring colors, a persistent status bar + countdown, actionable notifications, and full keyboard action support. ]]>
- Improved session timer and enhanced UX. + +
  • End time display — live "Ends at X:XX PM" and "Free by X:XX PM" labels appear while the timer runs, hidden when paused
  • +
  • Saved custom presets — name and save your own timing configurations; they appear directly in the mode dropdown
  • +
  • Customizable ring colors — pick Focus and Break accent colors from a color picker in Settings
  • +
  • Icon play/pause toggle — single button replaces separate Start/Pause text buttons
  • +
  • Spinner inputs in Custom mode — step controls with min/max bounds replace free-text fields
  • + + ]]>
    diff --git a/src/test/kotlin/com/github/akshayashokcode/devfocus/MyPluginTest.kt b/src/test/kotlin/com/github/akshayashokcode/devfocus/MyPluginTest.kt index 246ccca..9ef80d1 100644 --- a/src/test/kotlin/com/github/akshayashokcode/devfocus/MyPluginTest.kt +++ b/src/test/kotlin/com/github/akshayashokcode/devfocus/MyPluginTest.kt @@ -6,7 +6,6 @@ import com.intellij.psi.xml.XmlFile import com.intellij.testFramework.TestDataPath import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.PsiErrorElementUtil -import com.github.akshayashokcode.devfocus.services.MyProjectService @TestDataPath("\$CONTENT_ROOT/src/test/testData") class MyPluginTest : BasePlatformTestCase() { @@ -29,11 +28,5 @@ class MyPluginTest : BasePlatformTestCase() { myFixture.testRename("foo.xml", "foo_after.xml", "a2") } - fun testProjectService() { - val projectService = project.service() - - assertNotSame(projectService.getRandomNumber(), projectService.getRandomNumber()) - } - override fun getTestDataPath() = "src/test/testData/rename" }