diff --git a/backend/app/settings_schema.py b/backend/app/settings_schema.py index d34e6ce..5e59a7d 100644 --- a/backend/app/settings_schema.py +++ b/backend/app/settings_schema.py @@ -20,6 +20,27 @@ from pydantic import BaseModel, Field, field_validator # -------------------------------------------------------------------------- # Timer # -------------------------------------------------------------------------- +class TimerPreset(BaseModel): + """A named, user-editable recipe of durations the UI can apply in one tap.""" + + name: str + work_minutes: int = Field(default=25, ge=1, le=240) + short_break_minutes: int = Field(default=5, ge=1, le=120) + long_break_minutes: int = Field(default=15, ge=1, le=240) + cycles_before_long_break: int = Field(default=4, ge=1, le=12) + + +def _default_timer_presets() -> list[TimerPreset]: + return [ + TimerPreset( + name="Classic", work_minutes=45, short_break_minutes=15, long_break_minutes=30 + ), + TimerPreset( + name="Пара", work_minutes=85, short_break_minutes=10, long_break_minutes=10 + ), + ] + + class PomodoroSettings(BaseModel): """Lengths and automation of the timer itself. All durations in minutes.""" @@ -28,6 +49,9 @@ class PomodoroSettings(BaseModel): long_break_minutes: int = Field(default=15, ge=1, le=240) cycles_before_long_break: int = Field(default=4, ge=1, le=12) + presets: list[TimerPreset] = Field(default_factory=_default_timer_presets) + """Saved work/break combinations, applied to the fields above in one tap.""" + auto_start_breaks: bool = True """Roll straight into the break when a work interval ends.""" diff --git a/desktop/kadence_desktop/app.py b/desktop/kadence_desktop/app.py index 73243c9..20b7c71 100644 --- a/desktop/kadence_desktop/app.py +++ b/desktop/kadence_desktop/app.py @@ -128,7 +128,12 @@ class KadenceApp(QObject): config = self._config wanted = bool(config.mini_timer) and ( self._controller.miniVisible - or (config.mini_timer_auto and self._controller.running and not self._window.isVisible()) + or ( + config.mini_timer_auto + and self._controller.running + and not self._window.isVisible() + and not self._controller.mini_dismissed + ) ) if bool(self._mini.property("visible")) != wanted: if wanted: diff --git a/desktop/kadence_desktop/controller.py b/desktop/kadence_desktop/controller.py index 0aa3151..9b0dd6c 100644 --- a/desktop/kadence_desktop/controller.py +++ b/desktop/kadence_desktop/controller.py @@ -80,6 +80,14 @@ class Controller(QObject): self._completing = False self._paused_by_lock = False self._mini_visible = False + self._mini_dismissed = False + """Set when the ✕ on the mini timer was clicked during a running interval. + + `app._update_mini()` would otherwise bring it straight back up through + its "auto-show while running and the window is away" rule, making the + close button look broken. Cleared on the next explicit show and on the + next interval, so a dismissal only lasts for the one you closed it on. + """ # ---- system integrations ------------------------------------------- self._notifier = Notifier() @@ -282,6 +290,11 @@ class Controller(QObject): """Whether the floating mini timer is pinned open by the user.""" return self._mini_visible + @property + def mini_dismissed(self) -> bool: + """For `app._update_mini()`, not QML: see `_mini_dismissed` above.""" + return self._mini_dismissed + @pyqtProperty("QVariantMap", notify=configChanged) def config(self) -> dict[str, Any]: from dataclasses import asdict @@ -463,6 +476,7 @@ class Controller(QObject): @pyqtSlot() @pyqtSlot(bool) def setMiniVisible(self, visible: bool = True) -> None: + self._mini_dismissed = not visible if visible == self._mini_visible: return self._mini_visible = visible @@ -610,6 +624,11 @@ class Controller(QObject): # Transitions → the system integrations # ====================================================================== def _on_transition(self, previous: dict[str, Any], current: dict[str, Any]) -> None: + if current.get("id") and current.get("id") != previous.get("id"): + # A new interval — pause/resume of the same one must not do this — + # gets its own chance to auto-show, even if the last one was closed. + self._mini_dismissed = False + focusing = current.get("kind") == "work" and current.get("status") == "running" if focusing: diff --git a/desktop/kadence_desktop/qml/MiniTimer.qml b/desktop/kadence_desktop/qml/MiniTimer.qml index c2fe8ff..4e2fc04 100644 --- a/desktop/kadence_desktop/qml/MiniTimer.qml +++ b/desktop/kadence_desktop/qml/MiniTimer.qml @@ -78,7 +78,10 @@ Window { verticalAlignment: Text.AlignVCenter } HoverHandler { id: buttonHover; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: button.clicked() } + TapHandler { + grabPermissions: PointerHandler.TakeOverForbidden + onTapped: button.clicked() + } } Rectangle { @@ -151,7 +154,14 @@ Window { opacity: hover.hovered ? 1 : 0.35 Behavior on opacity { NumberAnimation { duration: 120 } } HoverHandler { id: closeHover; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: controller.setMiniVisible(false) } + // The card's DragHandler has CanTakeOverFromAnything so the whole + // pill is draggable; without refusing the take-over here, the tiny + // press-move jitter of a real click steals this tap into a + // startSystemMove() and the close button never fires. + TapHandler { + grabPermissions: PointerHandler.TakeOverForbidden + onTapped: controller.setMiniVisible(false) + } } } diff --git a/desktop/kadence_desktop/qml/SettingsPage.qml b/desktop/kadence_desktop/qml/SettingsPage.qml index 993cae8..9d802e9 100644 --- a/desktop/kadence_desktop/qml/SettingsPage.qml +++ b/desktop/kadence_desktop/qml/SettingsPage.qml @@ -29,6 +29,29 @@ ScrollView { controller.patchSettings({ notifications: { telegram: patch } }) } + // Presets are a saved list on the server (`pomodoro.presets`), each one a + // name plus its own work/short/long minutes — editable here, not baked + // into the UI. `_merge` on the backend replaces list fields wholesale, so + // editing one preset's number means resending the whole list. + function patchPreset(index, key, value) { + if (!page.pomodoro || !page.pomodoro.presets) return + var presets = page.pomodoro.presets.map(function (p) { return Object.assign({}, p) }) + presets[index][key] = value + controller.patchSettings({ pomodoro: { presets: presets } }) + } + function applyPreset(index) { + if (!page.pomodoro || !page.pomodoro.presets) return + var preset = page.pomodoro.presets[index] + controller.patchSettings({ + pomodoro: { + work_minutes: preset.work_minutes, + short_break_minutes: preset.short_break_minutes, + long_break_minutes: preset.long_break_minutes, + cycles_before_long_break: preset.cycles_before_long_break + } + }) + } + component Field: ColumnLayout { property string label: "" default property alias content: holder.data @@ -104,6 +127,70 @@ ScrollView { spacing: 12 SectionTitle { text: "Timer" } + // Presets: a saved list of work/short/long combos. "Apply" copies + // one onto the live settings below; the numbers on each row are + // the preset's own and edit it in place. + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + + Repeater { + model: page.pomodoro && page.pomodoro.presets ? page.pomodoro.presets : [] + + RowLayout { + id: presetRow + required property var modelData + required property int index + Layout.fillWidth: true + spacing: 16 + + readonly property bool isActive: page.pomodoro + && page.pomodoro.work_minutes === modelData.work_minutes + && page.pomodoro.short_break_minutes === modelData.short_break_minutes + && page.pomodoro.long_break_minutes === modelData.long_break_minutes + + Label { + text: presetRow.modelData.name + color: presetRow.isActive ? Theme.ember : Theme.ink + font.pixelSize: 13 + font.weight: presetRow.isActive ? Font.DemiBold : Font.Medium + font.family: Theme.sansFamily + Layout.preferredWidth: 70 + } + Field { + label: "Focus" + SpinBox { + from: 1; to: 240 + value: presetRow.modelData.work_minutes + onValueModified: page.patchPreset(presetRow.index, "work_minutes", value) + } + } + Field { + label: "Short break" + SpinBox { + from: 1; to: 120 + value: presetRow.modelData.short_break_minutes + onValueModified: page.patchPreset(presetRow.index, "short_break_minutes", value) + } + } + Field { + label: "Long break" + SpinBox { + from: 1; to: 240 + value: presetRow.modelData.long_break_minutes + onValueModified: page.patchPreset(presetRow.index, "long_break_minutes", value) + } + } + GhostButton { + text: presetRow.isActive ? "Active" : "Apply" + enabled: !presetRow.isActive + onClicked: page.applyPreset(presetRow.index) + } + Item { Layout.fillWidth: true } + } + } + } + Flow { Layout.fillWidth: true spacing: 20