refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency
- Refactored TimerPage component to improve layout and user experience. - Updated theme configuration to utilize design tokens for better maintainability. - Introduced new tokens for colors and typography to align with design principles. - Simplified state management and improved timer functionality. - Enhanced accessibility and responsiveness of UI elements.
This commit is contained in:
84 files changed
+4501
-2682
No files matched your search
@@ -25,7 +25,7 @@ from PyQt6.QtGui import QDesktopServices
|
||||
from kadence_desktop.api import Api
|
||||
from kadence_desktop.blocking.manager import BlockerManager
|
||||
from kadence_desktop.config import DesktopConfig
|
||||
from kadence_desktop.integrations import autostart
|
||||
from kadence_desktop.integrations import autostart, keep_above
|
||||
from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor
|
||||
from kadence_desktop.integrations.launcher import LauncherEntry
|
||||
from kadence_desktop.integrations.mpris import MediaController
|
||||
@@ -53,6 +53,8 @@ class Controller(QObject):
|
||||
blockerChanged = pyqtSignal()
|
||||
connectionChanged = pyqtSignal()
|
||||
configChanged = pyqtSignal()
|
||||
miniChanged = pyqtSignal()
|
||||
miniPlacementChanged = pyqtSignal()
|
||||
toast = pyqtSignal(str)
|
||||
showWindowRequested = pyqtSignal()
|
||||
|
||||
@@ -77,6 +79,7 @@ class Controller(QObject):
|
||||
self._last_status = ""
|
||||
self._completing = False
|
||||
self._paused_by_lock = False
|
||||
self._mini_visible = False
|
||||
|
||||
# ---- system integrations -------------------------------------------
|
||||
self._notifier = Notifier()
|
||||
@@ -119,6 +122,9 @@ class Controller(QObject):
|
||||
self._tick.timeout.connect(self._on_tick)
|
||||
self._tick.start()
|
||||
|
||||
# A Wayland compositor ignores "stay on top", so ask KWin properly.
|
||||
self.ensure_keep_above()
|
||||
|
||||
self._idle_check = QTimer(self)
|
||||
self._idle_check.setInterval(30_000)
|
||||
self._idle_check.timeout.connect(self._check_idle)
|
||||
@@ -219,6 +225,14 @@ class Controller(QObject):
|
||||
def nextKindLabel(self) -> str:
|
||||
return KIND_LABELS.get(str(self._state.get("next_kind", "work")), "Focus")
|
||||
|
||||
@pyqtProperty(bool, notify=settingsChanged)
|
||||
def timerEnabled(self) -> bool:
|
||||
"""When False the app is in view-only mode — no timer controls."""
|
||||
if self._config.view_only:
|
||||
return False
|
||||
pomodoro = self._settings.get("pomodoro", {})
|
||||
return bool(pomodoro.get("timer_enabled", True))
|
||||
|
||||
@pyqtProperty("QVariantList", notify=tasksChanged)
|
||||
def tasks(self) -> list[dict[str, Any]]:
|
||||
return self._tasks
|
||||
@@ -263,6 +277,11 @@ class Controller(QObject):
|
||||
def blockerError(self) -> str:
|
||||
return self._blocker.last_error or ""
|
||||
|
||||
@pyqtProperty(bool, notify=miniChanged)
|
||||
def miniVisible(self) -> bool:
|
||||
"""Whether the floating mini timer is pinned open by the user."""
|
||||
return self._mini_visible
|
||||
|
||||
@pyqtProperty("QVariantMap", notify=configChanged)
|
||||
def config(self) -> dict[str, Any]:
|
||||
from dataclasses import asdict
|
||||
@@ -270,6 +289,8 @@ class Controller(QObject):
|
||||
data = asdict(self._config)
|
||||
data["autostart"] = autostart.autostart_enabled()
|
||||
data["service_enabled"] = autostart.service_enabled()
|
||||
data["keep_above_supported"] = keep_above.supported()
|
||||
data["keep_above_installed"] = keep_above.installed()
|
||||
return data
|
||||
|
||||
# ======================================================================
|
||||
@@ -289,6 +310,8 @@ class Controller(QObject):
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(int)
|
||||
def startWork(self, minutes: int = 0) -> None:
|
||||
if not self.timerEnabled:
|
||||
return
|
||||
args: dict[str, Any] = {"kind": "work"}
|
||||
if minutes:
|
||||
args["minutes"] = minutes
|
||||
@@ -301,12 +324,16 @@ class Controller(QObject):
|
||||
|
||||
@pyqtSlot()
|
||||
def startBreak(self) -> None:
|
||||
if not self.timerEnabled:
|
||||
return
|
||||
kind = str(self._state.get("next_kind", "short_break"))
|
||||
self._send("start", {"kind": "long_break" if kind == "long_break" else "short_break"})
|
||||
|
||||
@pyqtSlot()
|
||||
def toggle(self) -> None:
|
||||
"""One verb for a global hotkey: start → pause → resume."""
|
||||
if not self.timerEnabled:
|
||||
return
|
||||
if not self.running:
|
||||
self.startWork()
|
||||
elif self.status == "paused":
|
||||
@@ -314,6 +341,14 @@ class Controller(QObject):
|
||||
else:
|
||||
self.pause()
|
||||
|
||||
@pyqtSlot(bool)
|
||||
def setTimerEnabled(self, enabled: bool) -> None:
|
||||
"""Toggle view-only mode on or off."""
|
||||
self._config.update(view_only=not enabled)
|
||||
self.configChanged.emit()
|
||||
self.settingsChanged.emit()
|
||||
self.patchSettings({"pomodoro": {"timer_enabled": enabled}})
|
||||
|
||||
@pyqtSlot()
|
||||
def pause(self) -> None:
|
||||
self._send("pause")
|
||||
@@ -345,8 +380,9 @@ class Controller(QObject):
|
||||
self._send("attach_task", {"task_id": task_id if task_id >= 0 else None})
|
||||
|
||||
@pyqtSlot(int, bool)
|
||||
def setTaskDone(self, task_id: int, done: bool) -> None:
|
||||
self._send("task_done", {"task_id": task_id, "done": done})
|
||||
def setTaskPinned(self, task_id: int, pinned: bool) -> None:
|
||||
"""Pinning is the only task edit this app makes — closing one is not."""
|
||||
self._send("patch_task", {"task_id": task_id, "patch": {"pinned": pinned}})
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def createTask(self, title: str, due_date: str) -> None:
|
||||
@@ -413,10 +449,66 @@ class Controller(QObject):
|
||||
self._blocker.enforce_hosts = bool(value)
|
||||
elif key == "block_apps":
|
||||
self._blocker.enforce_apps = bool(value)
|
||||
elif key == "mini_timer_keep_above":
|
||||
ok, message = keep_above.apply() if value else keep_above.remove()
|
||||
self.toast.emit(message)
|
||||
if not ok:
|
||||
self._config.update(mini_timer_keep_above=not value)
|
||||
elif key in ("mini_timer_corner", "mini_timer_margin"):
|
||||
self.miniPlacementChanged.emit()
|
||||
elif key == "enforce_blocking" and not value:
|
||||
self._blocker.release()
|
||||
self.configChanged.emit()
|
||||
|
||||
@pyqtSlot()
|
||||
@pyqtSlot(bool)
|
||||
def setMiniVisible(self, visible: bool = True) -> None:
|
||||
if visible == self._mini_visible:
|
||||
return
|
||||
self._mini_visible = visible
|
||||
self.miniChanged.emit()
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def toggleMini(self) -> str:
|
||||
"""Tray menu, Ctrl+M and the D-Bus verb all land here."""
|
||||
if not self._config.mini_timer:
|
||||
self._config.update(mini_timer=True)
|
||||
self.configChanged.emit()
|
||||
self.setMiniVisible(not self._mini_visible)
|
||||
if self._mini_visible:
|
||||
self.ensure_keep_above()
|
||||
return "shown" if self._mini_visible else "hidden"
|
||||
|
||||
def ensure_keep_above(
|
||||
self, position: tuple[int, int] | None = None, *, announce: bool = False
|
||||
) -> None:
|
||||
"""Install or refresh the KWin rule, including where to place the window.
|
||||
|
||||
A no-op when the rule already says exactly this, so it can be called
|
||||
freely — it only ever touches KWin when something really changed.
|
||||
"""
|
||||
config = self._config
|
||||
if not (config.mini_timer and config.mini_timer_keep_above):
|
||||
return
|
||||
if not keep_above.supported() or keep_above.up_to_date(position):
|
||||
return
|
||||
ok, message = keep_above.apply(position)
|
||||
log.info("keep-above rule: %s", message)
|
||||
if announce or not ok:
|
||||
self.toast.emit(message)
|
||||
self.configChanged.emit()
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
def saveMiniPosition(self, x: int, y: int) -> None:
|
||||
"""Remember where it was dragged to — only in "remember" mode.
|
||||
|
||||
In corner mode the app places the window itself, and recording that
|
||||
placement would quietly overwrite the position you had dragged it to.
|
||||
"""
|
||||
if self._config.mini_timer_corner != "remember":
|
||||
return
|
||||
self._config.update(mini_timer_x=int(x), mini_timer_y=int(y))
|
||||
|
||||
@pyqtSlot()
|
||||
def openWebUi(self) -> None:
|
||||
QDesktopServices.openUrl(QUrl(self._config.server_url))
|
||||
@@ -431,6 +523,8 @@ class Controller(QObject):
|
||||
"""One line for the tray tooltip and the D-Bus `Status` method."""
|
||||
if not self._connected:
|
||||
return "Kadence — backend unreachable"
|
||||
if not self.timerEnabled:
|
||||
return f"Kadence — view mode · {self.completedToday}/{self.goal} today"
|
||||
if not self.running:
|
||||
return f"Kadence — idle · {self.completedToday}/{self.goal} today"
|
||||
return (
|
||||
@@ -503,11 +597,14 @@ class Controller(QObject):
|
||||
self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings")
|
||||
elif name == "refresh_sources":
|
||||
failed = [item for item in result.get("sources", []) if not item.get("ok")]
|
||||
closed = result.get("closed", 0)
|
||||
self.toast.emit(
|
||||
f"{len(failed)} source(s) failed" if failed else f"{result.get('total', 0)} tasks pulled"
|
||||
f"{len(failed)} source(s) failed"
|
||||
if failed
|
||||
else f"{result.get('total', 0)} open · {closed} closed since last refresh"
|
||||
)
|
||||
elif name == "task_done" and result.get("warning"):
|
||||
self.toast.emit(str(result["warning"]))
|
||||
elif name == "refresh_sources" and result.get("closed"):
|
||||
self.toast.emit(f"{result['closed']} task(s) closed in your tools")
|
||||
|
||||
# ======================================================================
|
||||
# Transitions → the system integrations
|
||||
|
||||
Reference in new issue
Block a user