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
@@ -5,6 +5,7 @@ talk to a *running* instance over D-Bus instead of starting a second one, which
|
||||
is what makes them usable as global shortcuts:
|
||||
|
||||
kadence-desktop --toggle # bind this to a key in System Settings
|
||||
kadence-desktop --mini # show/hide the floating mini timer
|
||||
kadence-desktop --status # prints "Focus 18:42 · Write the docs · 3/8 today"
|
||||
"""
|
||||
|
||||
@@ -27,6 +28,7 @@ REMOTE_VERBS = {
|
||||
"interrupt": "Interrupt",
|
||||
"status": "Status",
|
||||
"report": "SendReport",
|
||||
"mini": "ToggleMini",
|
||||
"show": "Raise",
|
||||
}
|
||||
|
||||
@@ -53,6 +55,9 @@ def _remote(verb: str) -> int:
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="kadence-desktop", description=__doc__)
|
||||
parser.add_argument("--minimized", action="store_true", help="start in the tray only")
|
||||
parser.add_argument(
|
||||
"--view-only", action="store_true", help="start in view-only mode with timer turned off"
|
||||
)
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
for verb in REMOTE_VERBS:
|
||||
parser.add_argument(
|
||||
@@ -77,7 +82,7 @@ def main() -> int:
|
||||
print("Kadence is already running — raised its window.")
|
||||
return 0
|
||||
|
||||
return KadenceApp(sys.argv, minimized=args.minimized).run()
|
||||
return KadenceApp(sys.argv, minimized=args.minimized, view_only=args.view_only).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -100,8 +100,9 @@ class Api:
|
||||
def refresh_tasks(self) -> dict:
|
||||
return self._request("POST", "/api/tasks/refresh", {})
|
||||
|
||||
def task_done(self, task_id: int, done: bool = True) -> dict:
|
||||
return self._request("POST", f"/api/tasks/{task_id}/done", {"done": done})
|
||||
def patch_task(self, task_id: int, patch: dict) -> dict:
|
||||
"""Edit a task's local fields (pinning, estimate). Completion is not ours."""
|
||||
return self._request("PATCH", f"/api/tasks/{task_id}", patch)
|
||||
|
||||
def create_task(self, title: str, due_date: str | None = None) -> dict:
|
||||
return self._request("POST", "/api/tasks", {"title": title, "due_date": due_date})
|
||||
|
||||
@@ -20,7 +20,7 @@ os.environ.setdefault("QT_QUICK_CONTROLS_MATERIAL_VARIANT", "Normal")
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer, QUrl
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6.QtQml import QQmlApplicationEngine
|
||||
from PyQt6.QtQml import QQmlApplicationEngine, QQmlComponent
|
||||
from PyQt6.QtQuick import QQuickWindow
|
||||
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon
|
||||
|
||||
@@ -28,6 +28,7 @@ from kadence_desktop import APP_ID, __version__
|
||||
from kadence_desktop.config import DesktopConfig
|
||||
from kadence_desktop.controller import Controller
|
||||
from kadence_desktop.integrations import dbus_service
|
||||
from kadence_desktop.integrations.placement import Rect, point_for
|
||||
from kadence_desktop.tray import Tray, render_icon
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -36,7 +37,9 @@ QML_DIR = Path(__file__).resolve().parent / "qml"
|
||||
|
||||
|
||||
class KadenceApp(QObject):
|
||||
def __init__(self, argv: list[str], *, minimized: bool = False) -> None:
|
||||
def __init__(
|
||||
self, argv: list[str], *, minimized: bool = False, view_only: bool = False
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# QApplication (not QGuiApplication): the tray icon and its menu are
|
||||
# widgets, and they are what makes the app usable with no window open.
|
||||
@@ -52,6 +55,8 @@ class KadenceApp(QObject):
|
||||
self._app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
self._config = DesktopConfig.load()
|
||||
if view_only:
|
||||
self._config.view_only = True
|
||||
self._controller = Controller(self._config)
|
||||
|
||||
self._engine = QQmlApplicationEngine()
|
||||
@@ -63,6 +68,8 @@ class KadenceApp(QObject):
|
||||
raise SystemExit("Could not load the QML interface.")
|
||||
self._window: QQuickWindow = self._engine.rootObjects()[0]
|
||||
|
||||
self._mini = self._create_mini_window()
|
||||
|
||||
self._tray: Tray | None = None
|
||||
if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable():
|
||||
self._tray = Tray(self._controller, self)
|
||||
@@ -73,6 +80,15 @@ class KadenceApp(QObject):
|
||||
self._controller.showWindowRequested.connect(self.show_window)
|
||||
self._app.aboutToQuit.connect(self._controller.shutdown)
|
||||
|
||||
# Anything that can change "should the mini timer be up?" lands here.
|
||||
self._controller.miniChanged.connect(self._update_mini)
|
||||
self._controller.miniPlacementChanged.connect(self._place_mini)
|
||||
self._controller.stateChanged.connect(self._update_mini)
|
||||
self._controller.configChanged.connect(self._update_mini)
|
||||
self._window.visibleChanged.connect(self._update_mini)
|
||||
self._update_mini()
|
||||
self._place_mini()
|
||||
|
||||
if self._config.dbus_service:
|
||||
self._register_dbus()
|
||||
|
||||
@@ -88,6 +104,73 @@ class KadenceApp(QObject):
|
||||
self._wakeup.start(500)
|
||||
self._wakeup.timeout.connect(lambda: None)
|
||||
|
||||
# ---- the floating mini timer --------------------------------------------
|
||||
|
||||
def _create_mini_window(self) -> QObject | None:
|
||||
"""Build MiniTimer.qml as a top-level window of its own."""
|
||||
component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / "MiniTimer.qml")))
|
||||
if component.isError(): # pragma: no cover - a QML typo, caught in dev
|
||||
for error in component.errors():
|
||||
log.error("mini timer: %s", error.toString())
|
||||
return None
|
||||
# No parent on purpose: a parented QML Window becomes transient and
|
||||
# cannot outlive — or out-show — its parent. The Python reference on
|
||||
# `self._mini` is what keeps it alive.
|
||||
window = component.create()
|
||||
if window is not None:
|
||||
window.setProperty("objectName", "miniTimer")
|
||||
return window
|
||||
|
||||
def _update_mini(self) -> None:
|
||||
"""Show it when pinned, or when an interval runs with the window away."""
|
||||
if self._mini is None:
|
||||
return
|
||||
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())
|
||||
)
|
||||
if bool(self._mini.property("visible")) != wanted:
|
||||
if wanted:
|
||||
# Each time it comes up it lands where you asked for, even if it
|
||||
# was dragged elsewhere during the last interval.
|
||||
self._place_mini()
|
||||
self._mini.setProperty("visible", wanted)
|
||||
|
||||
def _mini_screen_rect(self) -> Rect:
|
||||
"""The usable area of the screen the app is on — panels excluded."""
|
||||
screen = self._window.screen() or self._app.primaryScreen()
|
||||
area = screen.availableGeometry()
|
||||
return Rect(area.x(), area.y(), area.width(), area.height())
|
||||
|
||||
def _place_mini(self) -> None:
|
||||
"""Put the mini timer in the configured corner.
|
||||
|
||||
Setting `x`/`y` is what works on X11; on Wayland the client may not
|
||||
place itself, so the same point goes into the KWin rule, which is
|
||||
honoured when the window is mapped.
|
||||
"""
|
||||
if self._mini is None:
|
||||
return
|
||||
config = self._config
|
||||
if config.mini_timer_corner == "remember":
|
||||
self._controller.ensure_keep_above(None)
|
||||
if config.mini_timer_x >= 0 and config.mini_timer_y >= 0:
|
||||
self._mini.setProperty("x", config.mini_timer_x)
|
||||
self._mini.setProperty("y", config.mini_timer_y)
|
||||
return
|
||||
|
||||
size = (
|
||||
int(self._mini.property("width") or 252),
|
||||
int(self._mini.property("height") or 152),
|
||||
)
|
||||
point = point_for(
|
||||
config.mini_timer_corner, self._mini_screen_rect(), size, config.mini_timer_margin
|
||||
)
|
||||
self._mini.setProperty("x", point[0])
|
||||
self._mini.setProperty("y", point[1])
|
||||
self._controller.ensure_keep_above(point)
|
||||
|
||||
# ---- window ------------------------------------------------------------
|
||||
|
||||
def show_window(self) -> None:
|
||||
@@ -117,6 +200,7 @@ class KadenceApp(QObject):
|
||||
"interrupt": lambda: (controller.interrupt(), "noted")[1],
|
||||
"status": controller.statusLine,
|
||||
"send_report": lambda: (controller.sendReport(), "sending")[1],
|
||||
"toggle_mini": controller.toggleMini,
|
||||
"raise_window": lambda: (self.show_window(), "shown")[1],
|
||||
}
|
||||
self._dbus = dbus_service.TimerDBusService(actions, self)
|
||||
@@ -126,6 +210,8 @@ class KadenceApp(QObject):
|
||||
# ---- lifecycle ---------------------------------------------------------
|
||||
|
||||
def quit(self) -> None:
|
||||
if self._mini is not None:
|
||||
self._mini.setProperty("visible", False)
|
||||
self._controller.shutdown()
|
||||
if self._tray:
|
||||
self._tray.hide()
|
||||
|
||||
@@ -5,9 +5,11 @@ the cmdline, matches against the configured names, and either terminates the
|
||||
process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending
|
||||
on the setting.
|
||||
|
||||
Matching is deliberately forgiving — `steam` should catch `steamwebhelper`, and
|
||||
`telegram` should catch `Telegram` — but it never matches ourselves, PID 1, or
|
||||
anything outside the current user's processes.
|
||||
Matching is deliberately forgiving — `steam` catches `steamwebhelper`, and
|
||||
`telegram` catches `Telegram` — and an entry containing `*` or `?` is treated as
|
||||
a glob (`steam*`, `*discord*`, `telegram-?`), matched against both the process
|
||||
name and its command line. It never matches ourselves, PID 1, or anything
|
||||
outside the current user's processes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,6 +18,7 @@ import logging
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from fnmatch import fnmatch
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -24,6 +27,21 @@ log = logging.getLogger(__name__)
|
||||
_SELF_MARKERS = ("kadence", "python3 -m kadence_desktop")
|
||||
|
||||
|
||||
def matches(pattern: str, name: str, cmdline: str) -> bool:
|
||||
"""Whether one block-list entry matches a process.
|
||||
|
||||
A mask is anchored to the process *name* (so `steam*` does not match every
|
||||
command that merely mentions steam) but also tried against the whole command
|
||||
line, which is how `*discord*` catches an Electron app launched by path.
|
||||
"""
|
||||
pattern = pattern.strip().lower()
|
||||
if not pattern:
|
||||
return False
|
||||
if any(char in pattern for char in "*?["):
|
||||
return fnmatch(name, pattern) or fnmatch(cmdline, pattern)
|
||||
return pattern in name or pattern in cmdline
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunningApp:
|
||||
pid: int
|
||||
@@ -65,11 +83,11 @@ def scan(patterns: list[str]) -> list[RunningApp]:
|
||||
if not comm:
|
||||
continue
|
||||
cmdline = _read(entry / "cmdline").replace("\x00", " ").strip()
|
||||
haystack = f"{comm} {cmdline}".lower()
|
||||
if any(marker in haystack for marker in _SELF_MARKERS):
|
||||
name, command = comm.lower(), cmdline.lower()
|
||||
if any(marker in f"{name} {command}" for marker in _SELF_MARKERS):
|
||||
continue
|
||||
|
||||
match = next((pattern for pattern in wanted if pattern in haystack), None)
|
||||
match = next((pattern for pattern in wanted if matches(pattern, name, command)), None)
|
||||
if match:
|
||||
found.append(RunningApp(pid=pid, name=comm, cmdline=cmdline[:200], matched=match))
|
||||
return found
|
||||
|
||||
@@ -38,6 +38,38 @@ class DesktopConfig:
|
||||
|
||||
start_minimized: bool = False
|
||||
close_to_tray: bool = True
|
||||
view_only: bool = False
|
||||
"""When true, the app runs in view-only mode with the timer turned off."""
|
||||
|
||||
# ---- the floating mini timer ------------------------------------------
|
||||
mini_timer: bool = True
|
||||
"""Allow the small always-on-top countdown window at all."""
|
||||
|
||||
mini_timer_auto: bool = True
|
||||
"""Show it by itself while an interval runs and the main window is away."""
|
||||
|
||||
mini_timer_keep_above: bool = True
|
||||
"""On KDE, install a KWin rule so it really floats above every window.
|
||||
|
||||
Wayland gives an application no way to ask for "always on top", so this is
|
||||
the only honest route; see `integrations.keep_above`. Switching it off
|
||||
removes the rule again.
|
||||
"""
|
||||
|
||||
mini_timer_corner: str = "bottom-right"
|
||||
"""Where it appears: a screen corner, an edge centre, or `remember`.
|
||||
|
||||
One of: top-left, top-centre, top-right, bottom-left, bottom-centre,
|
||||
bottom-right, centre, remember. `remember` puts it back where you last
|
||||
dragged it instead.
|
||||
"""
|
||||
|
||||
mini_timer_margin: int = 24
|
||||
"""Gap between the mini timer and the screen edges, in pixels."""
|
||||
|
||||
mini_timer_x: int = -1
|
||||
mini_timer_y: int = -1
|
||||
"""Where it was last dragged to. -1 = let the compositor place it."""
|
||||
|
||||
# ---- system integration (each one is a real desktop feature) -----------
|
||||
tray_icon: bool = True
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,12 @@ hotkey for "start a pomodoro" needs no X11 key-grabbing hack:
|
||||
|
||||
Owning the name also gives single-instance behaviour for free: a second launch
|
||||
sees the name is taken, calls `Raise()` on the first, and exits.
|
||||
|
||||
The verbs live on a **QDBusAbstractAdaptor**, not on the exported object
|
||||
itself. Qt only honours the "D-Bus Interface" class info on an adaptor; export
|
||||
a plain QObject and the interface is named after its class
|
||||
(`local.kadence.TimerDBusService`), which is exactly the "No such interface"
|
||||
every documented command then hits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,8 +21,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSlot
|
||||
from PyQt6.QtDBus import QDBusConnection
|
||||
from PyQt6.QtCore import QObject, pyqtClassInfo, pyqtSlot
|
||||
from PyQt6.QtDBus import QDBusAbstractAdaptor, QDBusConnection
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,13 +32,14 @@ INTERFACE = "org.kadence.Timer"
|
||||
|
||||
|
||||
class TimerDBusService(QObject):
|
||||
"""The exported object. Every slot is a verb you can bind to a key."""
|
||||
"""Holds the actions; the adaptor below is what the bus sees."""
|
||||
|
||||
def __init__(self, actions: dict[str, Callable[[], object]], parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._actions = actions
|
||||
self.adaptor = TimerAdaptor(self)
|
||||
|
||||
def _run(self, name: str) -> str:
|
||||
def run(self, name: str) -> str:
|
||||
action = self._actions.get(name)
|
||||
if action is None:
|
||||
return f"unknown action: {name}"
|
||||
@@ -43,71 +50,113 @@ class TimerDBusService(QObject):
|
||||
log.exception("D-Bus action %s failed", name)
|
||||
return f"error: {exc}"
|
||||
|
||||
|
||||
# Qt generates no introspection XML for a Python adaptor, and without it
|
||||
# `busctl introspect` and KDE's D-Bus shortcut browser have nothing to list —
|
||||
# so the interface describes itself. Every verb takes no arguments and returns
|
||||
# one string.
|
||||
VERBS = (
|
||||
"StartWork",
|
||||
"StartBreak",
|
||||
"Toggle",
|
||||
"Pause",
|
||||
"Resume",
|
||||
"Complete",
|
||||
"Stop",
|
||||
"Extend",
|
||||
"Interrupt",
|
||||
"Status",
|
||||
"SendReport",
|
||||
"ToggleMini",
|
||||
"Raise",
|
||||
)
|
||||
|
||||
INTROSPECTION = "\n".join(
|
||||
[f'<interface name="{INTERFACE}">']
|
||||
+ [
|
||||
f' <method name="{verb}">\n <arg type="s" direction="out"/>\n </method>'
|
||||
for verb in VERBS
|
||||
]
|
||||
+ ["</interface>"]
|
||||
)
|
||||
|
||||
|
||||
@pyqtClassInfo("D-Bus Interface", INTERFACE)
|
||||
@pyqtClassInfo("D-Bus Introspection", INTROSPECTION)
|
||||
class TimerAdaptor(QDBusAbstractAdaptor):
|
||||
"""The published interface. Every slot is a verb you can bind to a key."""
|
||||
|
||||
def __init__(self, parent: TimerDBusService) -> None:
|
||||
super().__init__(parent)
|
||||
self._service = parent
|
||||
|
||||
# D-Bus methods are PascalCase by convention.
|
||||
@pyqtSlot(result=str)
|
||||
def StartWork(self) -> str: # noqa: N802 - D-Bus methods are PascalCase
|
||||
def StartWork(self) -> str: # noqa: N802
|
||||
"""Start a focus interval."""
|
||||
return self._run("start_work")
|
||||
return self._service.run("start_work")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def StartBreak(self) -> str: # noqa: N802
|
||||
return self._run("start_break")
|
||||
return self._service.run("start_break")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Toggle(self) -> str: # noqa: N802
|
||||
"""Start if idle, pause if running, resume if paused — one hotkey."""
|
||||
return self._run("toggle")
|
||||
return self._service.run("toggle")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Pause(self) -> str: # noqa: N802
|
||||
return self._run("pause")
|
||||
return self._service.run("pause")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Resume(self) -> str: # noqa: N802
|
||||
return self._run("resume")
|
||||
return self._service.run("resume")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Complete(self) -> str: # noqa: N802
|
||||
return self._run("complete")
|
||||
return self._service.run("complete")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Stop(self) -> str: # noqa: N802
|
||||
return self._run("stop")
|
||||
return self._service.run("stop")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Extend(self) -> str: # noqa: N802
|
||||
return self._run("extend")
|
||||
return self._service.run("extend")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Interrupt(self) -> str: # noqa: N802
|
||||
"""Count a distraction without stopping the clock."""
|
||||
return self._run("interrupt")
|
||||
return self._service.run("interrupt")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Status(self) -> str: # noqa: N802
|
||||
"""One line: what is running and how much is left."""
|
||||
return self._run("status")
|
||||
return self._service.run("status")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def SendReport(self) -> str: # noqa: N802
|
||||
return self._run("send_report")
|
||||
return self._service.run("send_report")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def ToggleMini(self) -> str: # noqa: N802
|
||||
"""Show or hide the floating mini timer."""
|
||||
return self._service.run("toggle_mini")
|
||||
|
||||
@pyqtSlot(result=str)
|
||||
def Raise(self) -> str: # noqa: N802
|
||||
"""Show the window (used by the second instance)."""
|
||||
return self._run("raise_window")
|
||||
"""Show the main window (used by a second instance)."""
|
||||
return self._service.run("raise_window")
|
||||
|
||||
|
||||
def register(service: TimerDBusService) -> bool:
|
||||
"""Own the name and export the object. False means another instance has it."""
|
||||
"""Own the name and export the adaptor. False means another instance has it."""
|
||||
bus = QDBusConnection.sessionBus()
|
||||
if not bus.registerService(SERVICE_NAME):
|
||||
return False
|
||||
bus.registerObject(
|
||||
OBJECT_PATH,
|
||||
service,
|
||||
QDBusConnection.RegisterOption.ExportAllSlots | QDBusConnection.RegisterOption.ExportAllInvokables,
|
||||
)
|
||||
log.info("D-Bus service %s%s is up", SERVICE_NAME, OBJECT_PATH)
|
||||
bus.registerObject(OBJECT_PATH, service, QDBusConnection.RegisterOption.ExportAdaptors)
|
||||
log.info("D-Bus service %s%s is up as %s", SERVICE_NAME, OBJECT_PATH, INTERFACE)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Keeping the mini timer above everything else — on Wayland, for real.
|
||||
|
||||
Wayland has no "always on top" an ordinary application can ask for: the
|
||||
`Qt.WindowStaysOnTopHint` flag is honoured on X11 and quietly ignored by a
|
||||
Wayland compositor. KWin does have the mechanism, but it lives in *window
|
||||
rules*, so the only honest way to float above other applications on KDE is to
|
||||
install one — which is what this module does, reversibly and with a backup.
|
||||
|
||||
The rule matches this one window (title `Kadence mini`, app id `kadence`) and
|
||||
forces: keep above, skip the task bar, skip the switcher, skip the pager. KWin
|
||||
is asked to re-read its configuration over D-Bus, so it takes effect at once
|
||||
without logging out.
|
||||
|
||||
Everywhere else this is a no-op and the Qt flag does the job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DESCRIPTION = "Kadence mini timer"
|
||||
WINDOW_TITLE = "Kadence mini"
|
||||
WINDOW_CLASS = "kadence"
|
||||
|
||||
# KWin's rule-policy codes: 2 is "Force".
|
||||
_FORCE = "2"
|
||||
# KWin's string-match codes: 1 is "Exact Match", 2 is "Substring Match".
|
||||
_EXACT = "1"
|
||||
_SUBSTRING = "2"
|
||||
|
||||
# Matched on the window title, as a **substring**.
|
||||
#
|
||||
# Two traps here, both found by asking KWin what it actually sees:
|
||||
# * Qt appends the application's display name, so the window KWin knows is
|
||||
# "Kadence mini — Kadence", and an exact match on "Kadence mini" never
|
||||
# fired;
|
||||
# * the app id depends on how the app was started (`kadence` from the desktop
|
||||
# entry, `python3` from a bare `python3 -m ...`), so requiring the class as
|
||||
# well would make the rule stop matching for some launches.
|
||||
RULE = {
|
||||
"Description": DESCRIPTION,
|
||||
"above": "true",
|
||||
"aboverule": _FORCE,
|
||||
"skiptaskbar": "true",
|
||||
"skiptaskbarrule": _FORCE,
|
||||
"skipswitcher": "true",
|
||||
"skipswitcherrule": _FORCE,
|
||||
"skippager": "true",
|
||||
"skippagerrule": _FORCE,
|
||||
"title": WINDOW_TITLE,
|
||||
"titlematch": _SUBSTRING,
|
||||
"wmclassmatch": "0",
|
||||
}
|
||||
|
||||
|
||||
def rules_path() -> Path:
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
||||
return Path(base) / "kwinrulesrc"
|
||||
|
||||
|
||||
def supported() -> bool:
|
||||
"""Only KDE has this mechanism; elsewhere the Qt flag is all there is."""
|
||||
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
|
||||
return "KDE" in desktop.upper()
|
||||
|
||||
|
||||
def _parser() -> configparser.RawConfigParser:
|
||||
parser = configparser.RawConfigParser(strict=False)
|
||||
# KDE keys are case-sensitive ("Description" must not become "description").
|
||||
parser.optionxform = str # type: ignore[assignment]
|
||||
return parser
|
||||
|
||||
|
||||
def _load() -> configparser.RawConfigParser:
|
||||
parser = _parser()
|
||||
path = rules_path()
|
||||
if path.exists():
|
||||
parser.read(path, encoding="utf-8")
|
||||
if not parser.has_section("General"):
|
||||
parser.add_section("General")
|
||||
return parser
|
||||
|
||||
|
||||
def _our_section(parser: configparser.RawConfigParser) -> str | None:
|
||||
for section in parser.sections():
|
||||
if section == "General":
|
||||
continue
|
||||
if parser.get(section, "Description", fallback="") == DESCRIPTION:
|
||||
return section
|
||||
return None
|
||||
|
||||
|
||||
def _save(parser: configparser.RawConfigParser) -> None:
|
||||
path = rules_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# One backup, before the first time we ever touch the file.
|
||||
backup = path.with_suffix(path.suffix + ".kadence-backup")
|
||||
if path.exists() and not backup.exists():
|
||||
shutil.copy2(path, backup)
|
||||
|
||||
temp = path.with_suffix(path.suffix + ".kadence-tmp")
|
||||
with temp.open("w", encoding="utf-8") as handle:
|
||||
parser.write(handle, space_around_delimiters=False)
|
||||
temp.replace(path)
|
||||
|
||||
|
||||
def _reconfigure() -> None:
|
||||
"""Ask KWin to re-read its rules; without it the change waits for a relogin."""
|
||||
interface = QDBusInterface("org.kde.KWin", "/KWin", "org.kde.KWin", QDBusConnection.sessionBus())
|
||||
if interface.isValid():
|
||||
interface.call("reconfigure")
|
||||
|
||||
|
||||
def _wanted(position: tuple[int, int] | None) -> dict[str, str]:
|
||||
"""The rule as it should be, with the forced position when there is one."""
|
||||
rule = dict(RULE)
|
||||
if position is not None:
|
||||
# KWin stores a forced position as "x,y" — this is what places the
|
||||
# window on Wayland, where a client may not place itself.
|
||||
rule["position"] = f"{position[0]},{position[1]}"
|
||||
rule["positionrule"] = _FORCE
|
||||
return rule
|
||||
|
||||
|
||||
def installed() -> bool:
|
||||
try:
|
||||
return _our_section(_load()) is not None
|
||||
except (configparser.Error, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def up_to_date(position: tuple[int, int] | None = None) -> bool:
|
||||
"""Whether the installed rule matches what this version would write.
|
||||
|
||||
An older build wrote an exact title match that never fired; without this
|
||||
check an upgrade would leave the broken rule in place forever, because the
|
||||
rule *exists*. The position is part of the comparison, so moving the mini
|
||||
timer to another corner rewrites the rule — and nothing else does.
|
||||
"""
|
||||
try:
|
||||
parser = _load()
|
||||
section = _our_section(parser)
|
||||
if section is None:
|
||||
return False
|
||||
wanted = _wanted(position)
|
||||
if any(parser.get(section, key, fallback=None) != value for key, value in wanted.items()):
|
||||
return False
|
||||
# A position left over from a previous corner must not linger.
|
||||
if position is None and parser.has_option(section, "position"):
|
||||
return False
|
||||
return True
|
||||
except (configparser.Error, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def apply(position: tuple[int, int] | None = None) -> tuple[bool, str]:
|
||||
"""Install (or refresh) the rule, optionally forcing a position."""
|
||||
if not supported():
|
||||
return False, "Keeping a window above others this way needs KWin (KDE)."
|
||||
try:
|
||||
parser = _load()
|
||||
section = _our_section(parser) or str(uuid.uuid4())
|
||||
if not parser.has_section(section):
|
||||
parser.add_section(section)
|
||||
for key in ("position", "positionrule"):
|
||||
parser.remove_option(section, key)
|
||||
for key, value in _wanted(position).items():
|
||||
parser.set(section, key, value)
|
||||
|
||||
listed = [item for item in parser.get("General", "rules", fallback="").split(",") if item]
|
||||
if section not in listed:
|
||||
listed.append(section)
|
||||
parser.set("General", "rules", ",".join(listed))
|
||||
parser.set("General", "count", str(len(listed)))
|
||||
_save(parser)
|
||||
except (configparser.Error, OSError) as exc:
|
||||
log.warning("could not write the KWin rule: %s", exc)
|
||||
return False, f"Could not write {rules_path()}: {exc}"
|
||||
|
||||
_reconfigure()
|
||||
if position is not None:
|
||||
return True, "KWin keeps the mini timer above other windows, in the corner you chose."
|
||||
return True, "KWin will now keep the mini timer above other windows."
|
||||
|
||||
|
||||
def remove() -> tuple[bool, str]:
|
||||
"""Take the rule back out, leaving the rest of the file alone."""
|
||||
try:
|
||||
parser = _load()
|
||||
section = _our_section(parser)
|
||||
if section is None:
|
||||
return True, "No KWin rule to remove."
|
||||
parser.remove_section(section)
|
||||
listed = [
|
||||
item
|
||||
for item in parser.get("General", "rules", fallback="").split(",")
|
||||
if item and item != section
|
||||
]
|
||||
parser.set("General", "rules", ",".join(listed))
|
||||
parser.set("General", "count", str(len(listed)))
|
||||
_save(parser)
|
||||
except (configparser.Error, OSError) as exc:
|
||||
return False, f"Could not update {rules_path()}: {exc}"
|
||||
|
||||
_reconfigure()
|
||||
return True, "Removed the KWin rule; the mini timer floats only as far as Qt can ask."
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Where the mini timer appears.
|
||||
|
||||
Two mechanisms, because one is not enough:
|
||||
|
||||
* **X11** — the application may place its own window, so the computed point is
|
||||
applied directly.
|
||||
* **Wayland** — it may not: a client cannot position an xdg-toplevel, and
|
||||
setting `x`/`y` is quietly ignored. KWin *can* place it, through the same
|
||||
window rule that already forces "keep above", so the point is written there.
|
||||
|
||||
The geometry maths is a pure function so it can be checked without a screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
CORNERS = (
|
||||
"top-left",
|
||||
"top-centre",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-centre",
|
||||
"bottom-right",
|
||||
"centre",
|
||||
"remember",
|
||||
)
|
||||
|
||||
# What the settings page shows, in the order it shows them.
|
||||
CORNER_LABELS = {
|
||||
"top-left": "Top left",
|
||||
"top-centre": "Top centre",
|
||||
"top-right": "Top right",
|
||||
"bottom-left": "Bottom left",
|
||||
"bottom-centre": "Bottom centre",
|
||||
"bottom-right": "Bottom right",
|
||||
"centre": "Centre of the screen",
|
||||
"remember": "Where I last dragged it",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rect:
|
||||
"""A screen's usable area — panels and docks already taken off."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
def point_for(corner: str, screen: Rect, size: tuple[int, int], margin: int = 24) -> tuple[int, int]:
|
||||
"""Top-left point that puts a `size` window in `corner` of `screen`."""
|
||||
width, height = size
|
||||
margin = max(0, margin)
|
||||
|
||||
vertical, _, horizontal = corner.partition("-")
|
||||
if corner == "centre":
|
||||
vertical, horizontal = "centre", "centre"
|
||||
|
||||
if horizontal == "left":
|
||||
x = screen.x + margin
|
||||
elif horizontal == "right":
|
||||
x = screen.x + screen.width - width - margin
|
||||
else: # centre
|
||||
x = screen.x + (screen.width - width) // 2
|
||||
|
||||
if vertical == "top":
|
||||
y = screen.y + margin
|
||||
elif vertical == "bottom":
|
||||
y = screen.y + screen.height - height - margin
|
||||
else: # centre
|
||||
y = screen.y + (screen.height - height) // 2
|
||||
|
||||
# Never place it off-screen, however small the screen or large the margin.
|
||||
x = max(screen.x, min(x, screen.x + max(screen.width - width, 0)))
|
||||
y = max(screen.y, min(y, screen.y + max(screen.height - height, 0)))
|
||||
return int(x), int(y)
|
||||
@@ -1,12 +1,13 @@
|
||||
// What the blocker is doing right now, and what it will do.
|
||||
// The focus guard: what is blocked, when, and what it caught.
|
||||
//
|
||||
// The rules themselves are server-side (edit them in the web UI or below); this
|
||||
// page is about *enforcement on this machine* — the helper, the hosts file and
|
||||
// the process watcher.
|
||||
// Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is
|
||||
// expanded server-side into real hostnames; this page shows that count and says
|
||||
// plainly when a pattern cannot be expressed that way.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "."
|
||||
import "components"
|
||||
|
||||
ScrollView {
|
||||
@@ -15,176 +16,308 @@ ScrollView {
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth
|
||||
spacing: 16
|
||||
width: page.availableWidth - 56
|
||||
x: 28
|
||||
y: 26
|
||||
spacing: 22
|
||||
|
||||
Banner {
|
||||
Layout.fillWidth: true
|
||||
visible: !controller.helperInstalled
|
||||
text: "The privileged helper is not installed, so websites cannot be blocked. "
|
||||
+ "Run: sudo desktop/helper/install.sh"
|
||||
actionText: ""
|
||||
PageHeader {
|
||||
eyebrow: controller.blockerReason
|
||||
title: controller.blockerActive ? "Blocking is on" : "Blocking is off"
|
||||
caption: "Rules are shared with the web app; this machine enforces them."
|
||||
|
||||
Switch {
|
||||
text: "Enabled"
|
||||
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
|
||||
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
|
||||
}
|
||||
}
|
||||
|
||||
Banner {
|
||||
Layout.fillWidth: true
|
||||
visible: !controller.helperInstalled
|
||||
text: "The privileged helper is not installed, so websites cannot be blocked. "
|
||||
+ "Run: sudo desktop/helper/install.sh"
|
||||
}
|
||||
Banner {
|
||||
visible: controller.blockerError !== ""
|
||||
text: controller.blockerError
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
spacing: 16
|
||||
|
||||
Card {
|
||||
title: controller.blockerActive ? "🛡 Blocking is ON" : "Blocking is off"
|
||||
subtitle: controller.blockerReason
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 20
|
||||
StatTile {
|
||||
label: "Domains"
|
||||
value: controller.blockedDomains.length
|
||||
caption: "redirected to 127.0.0.1"
|
||||
}
|
||||
StatTile {
|
||||
label: "Apps"
|
||||
value: controller.blockedApps.length
|
||||
caption: "closed on sight"
|
||||
}
|
||||
StatTile {
|
||||
label: "Attempts today"
|
||||
value: controller.stats.today ? controller.stats.today.blocked_attempts : 0
|
||||
caption: "and counting"
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Switch {
|
||||
text: "Enforce on this machine"
|
||||
checked: controller.config.enforce_blocking
|
||||
onToggled: controller.setConfig("enforce_blocking", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Websites"
|
||||
checked: controller.config.block_hosts_file
|
||||
onToggled: controller.setConfig("block_hosts_file", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Apps"
|
||||
checked: controller.config.block_apps
|
||||
onToggled: controller.setConfig("block_apps", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Blocked websites"
|
||||
subtitle: "One hostname per line. `www.` and `m.` are added automatically."
|
||||
|
||||
TextArea {
|
||||
id: domainsArea
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 140
|
||||
wrapMode: TextArea.NoWrap
|
||||
font.family: "Roboto Mono, monospace"
|
||||
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("\n")
|
||||
placeholderText: "youtube.com\nreddit.com\ntwitter.com"
|
||||
}
|
||||
RowLayout {
|
||||
PrimaryButton {
|
||||
text: "Save websites"
|
||||
onClicked: {
|
||||
var list = domainsArea.text.split("\n").map(function (s) { return s.trim() })
|
||||
.filter(function (s) { return s.length > 0 })
|
||||
controller.patchSettings({ blocker: { blocked_domains: list } })
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Blocked applications"
|
||||
subtitle: "Process names, matched loosely: `steam` also catches `steamwebhelper`."
|
||||
|
||||
TextArea {
|
||||
id: appsArea
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 100
|
||||
wrapMode: TextArea.NoWrap
|
||||
font.family: "Roboto Mono, monospace"
|
||||
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("\n")
|
||||
placeholderText: "steam\ndiscord\ntelegram-desktop"
|
||||
}
|
||||
RowLayout {
|
||||
PrimaryButton {
|
||||
text: "Save applications"
|
||||
onClicked: {
|
||||
var list = appsArea.text.split("\n").map(function (s) { return s.trim() })
|
||||
.filter(function (s) { return s.length > 0 })
|
||||
controller.patchSettings({ blocker: { blocked_apps: list } })
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "When to block"
|
||||
subtitle: "Server-side, so every device agrees."
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Switch {
|
||||
text: "Blocking enabled"
|
||||
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
|
||||
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
|
||||
}
|
||||
ComboBox {
|
||||
Layout.preferredWidth: 220
|
||||
model: ["During focus intervals", "Always", "On a schedule"]
|
||||
currentIndex: {
|
||||
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
|
||||
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
|
||||
}
|
||||
onActivated: function (index) {
|
||||
var modes = ["during_work", "always", "scheduled"]
|
||||
controller.patchSettings({ blocker: { mode: modes[index] } })
|
||||
}
|
||||
}
|
||||
Switch {
|
||||
text: "Keep blocking during breaks"
|
||||
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
|
||||
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Today's temptations"
|
||||
subtitle: "Every attempt the blocker turned away — this is what the daily report jokes about."
|
||||
|
||||
Panel {
|
||||
padding: 0
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: controller.stats.today ? controller.stats.today.blocked_top : []
|
||||
model: [
|
||||
{ label: "Hostnames", value: "" + controller.blockedDomains.length, caption: "after expansion" },
|
||||
{ label: "Apps", value: "" + controller.blockedApps.length, caption: "closed on sight" },
|
||||
{ label: "Attempts today",
|
||||
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "0",
|
||||
caption: "turned away" },
|
||||
{ label: "Enforcing",
|
||||
value: controller.config.enforce_blocking ? "Yes" : "No",
|
||||
caption: "on this computer" }
|
||||
]
|
||||
delegate: RowLayout {
|
||||
required property int index
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
Label { text: modelData.target; Layout.fillWidth: true }
|
||||
Label { text: "× " + modelData.count; font.weight: Font.DemiBold }
|
||||
spacing: 0
|
||||
Rectangle {
|
||||
visible: index > 0
|
||||
width: 1
|
||||
Layout.preferredHeight: 70
|
||||
color: Theme.rule
|
||||
}
|
||||
Stat {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 18
|
||||
label: modelData.label
|
||||
value: modelData.value
|
||||
caption: modelData.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
Label {
|
||||
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
|
||||
text: "Nothing yet today. Either the blocker is off, or you are behaving."
|
||||
opacity: 0.6
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
SectionTitle { text: "When" }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
ComboBox {
|
||||
Layout.preferredWidth: 240
|
||||
font.pixelSize: 13
|
||||
model: ["While a focus interval runs", "Always", "On a schedule"]
|
||||
currentIndex: {
|
||||
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
|
||||
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
|
||||
}
|
||||
onActivated: function (index) {
|
||||
var modes = ["during_work", "always", "scheduled"]
|
||||
controller.patchSettings({ blocker: { mode: modes[index] } })
|
||||
}
|
||||
}
|
||||
Switch {
|
||||
text: "Through breaks"
|
||||
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
|
||||
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
|
||||
}
|
||||
Switch {
|
||||
text: "Close blocked apps"
|
||||
checked: controller.settings.blocker ? controller.settings.blocker.kill_blocked_apps : true
|
||||
onToggled: controller.patchSettings({ blocker: { kill_blocked_apps: checked } })
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
SectionTitle { text: "Masks" }
|
||||
Panel {
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: [
|
||||
{ pattern: "youtube.com", meaning: "the site itself, plus www. and m." },
|
||||
{ pattern: "*.google.com", meaning: "every subdomain in the expansion list" },
|
||||
{ pattern: "*gram*", meaning: "matches apps; a hosts file cannot express it" }
|
||||
]
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
Label {
|
||||
text: modelData.pattern
|
||||
color: Theme.ink
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.monoFamily
|
||||
Layout.preferredWidth: 140
|
||||
}
|
||||
Label {
|
||||
text: modelData.meaning
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: page.width > 900 ? 3 : 1
|
||||
columnSpacing: 18
|
||||
rowSpacing: 18
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
SectionTitle { text: "Blocked websites" }
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 160
|
||||
radius: 10
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: domainsArea.activeFocus ? Theme.ember : Theme.rule
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
clip: true
|
||||
|
||||
TextArea {
|
||||
id: domainsArea
|
||||
background: null
|
||||
wrapMode: TextArea.NoWrap
|
||||
font.family: Theme.monoFamily
|
||||
font.pixelSize: 12
|
||||
color: Theme.ink
|
||||
placeholderText: "youtube.com\n*.reddit.com"
|
||||
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("
|
||||
")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
SectionTitle { text: "Exceptions" }
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 160
|
||||
radius: 10
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: allowArea.activeFocus ? Theme.ember : Theme.rule
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
clip: true
|
||||
|
||||
TextArea {
|
||||
id: allowArea
|
||||
background: null
|
||||
wrapMode: TextArea.NoWrap
|
||||
font.family: Theme.monoFamily
|
||||
font.pixelSize: 12
|
||||
color: Theme.ink
|
||||
placeholderText: "music.youtube.com\n*.docs.google.com"
|
||||
text: (controller.settings.blocker ? controller.settings.blocker.allowed_domains : []).join("
|
||||
")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
SectionTitle { text: "Blocked applications" }
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 160
|
||||
radius: 10
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: appsArea.activeFocus ? Theme.ember : Theme.rule
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
clip: true
|
||||
|
||||
TextArea {
|
||||
id: appsArea
|
||||
background: null
|
||||
wrapMode: TextArea.NoWrap
|
||||
font.family: Theme.monoFamily
|
||||
font.pixelSize: 12
|
||||
color: Theme.ink
|
||||
placeholderText: "steam*\n*discord*"
|
||||
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("
|
||||
")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
TextField {
|
||||
id: subdomainField
|
||||
Layout.preferredWidth: 420
|
||||
placeholderText: "*.site expands to: www, m, app…"
|
||||
text: (controller.settings.blocker && controller.settings.blocker.subdomain_expansions
|
||||
? controller.settings.blocker.subdomain_expansions : []).join(", ")
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
PrimaryButton {
|
||||
text: "Save lists"
|
||||
onClicked: {
|
||||
function split(text, sep) {
|
||||
return text.split(sep).map(function (s) { return s.trim() })
|
||||
.filter(function (s) { return s.length > 0 })
|
||||
}
|
||||
controller.patchSettings({
|
||||
blocker: {
|
||||
blocked_domains: split(domainsArea.text, "\n"),
|
||||
allowed_domains: split(allowArea.text, "\n"),
|
||||
blocked_apps: split(appsArea.text, "\n"),
|
||||
subdomain_expansions: split(subdomainField.text, ",")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
SectionTitle { text: "Today's temptations" }
|
||||
Repeater {
|
||||
model: controller.stats.today ? controller.stats.today.blocked_top : []
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: modelData.target
|
||||
color: Theme.ink
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.monoFamily
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Label {
|
||||
text: "× " + modelData.count
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
Label {
|
||||
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
|
||||
text: "Nothing yet — either the blocker is off, or you are behaving."
|
||||
color: Theme.faint
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.preferredHeight: 10 }
|
||||
}
|
||||
|
||||
Component.onCompleted: controller.loadSettings()
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
// The window.
|
||||
//
|
||||
// Qt Quick Controls' Material style, following the session's own light/dark
|
||||
// preference, with a navigation rail on the left and the pages stacked on the
|
||||
// right. Everything it shows comes from `controller`, the Python object bound
|
||||
// into the QML context — there is no second copy of the state here.
|
||||
// A quiet left column — wordmark, navigation as words, and the two things worth
|
||||
// knowing from anywhere (what the timer is doing, whether the blocker is on) —
|
||||
// with the pages on the right. Qt Quick Controls supply the behaviour; the
|
||||
// chrome comes from `Theme`, so the desktop app and the web app are the same
|
||||
// product rather than two takes on it.
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
|
||||
import "."
|
||||
import "components"
|
||||
|
||||
ApplicationWindow {
|
||||
id: window
|
||||
|
||||
width: 1100
|
||||
height: 760
|
||||
minimumWidth: 420
|
||||
minimumHeight: 560
|
||||
width: 1180
|
||||
height: 820
|
||||
minimumWidth: 460
|
||||
minimumHeight: 600
|
||||
visible: false
|
||||
color: Theme.paper
|
||||
title: controller.running
|
||||
? controller.clockText + " · " + controller.kindLabel + " — Kadence"
|
||||
: "Kadence"
|
||||
|
||||
Material.theme: Material.System
|
||||
Material.primary: "#6750A4"
|
||||
Material.accent: "#6750A4"
|
||||
Material.theme: Theme.dark ? Material.Dark : Material.Light
|
||||
Material.primary: Theme.ember
|
||||
Material.accent: Theme.ember
|
||||
Material.background: Theme.paper
|
||||
Material.foreground: Theme.ink
|
||||
|
||||
readonly property color workColor: "#2a78d6"
|
||||
readonly property color shortBreakColor: "#eb6834"
|
||||
readonly property color longBreakColor: "#1baf7a"
|
||||
readonly property bool wide: width >= 820
|
||||
property int page: 0
|
||||
|
||||
function kindColor(kind) {
|
||||
if (kind === "short_break") return shortBreakColor
|
||||
if (kind === "long_break") return longBreakColor
|
||||
return workColor
|
||||
}
|
||||
|
||||
// Closing the window keeps the app alive in the tray — the timer is still
|
||||
// running, and that is the whole point of a tray icon.
|
||||
onClosing: function (close) {
|
||||
if (controller.config.close_to_tray && controller.config.tray_icon) {
|
||||
close.accepted = false
|
||||
@@ -54,10 +48,9 @@ ApplicationWindow {
|
||||
function onShowWindowRequested() { window.show(); window.raise(); window.requestActivate() }
|
||||
}
|
||||
|
||||
// ---- shortcuts ---------------------------------------------------------
|
||||
Shortcut { sequence: "Space"; onActivated: controller.toggle() }
|
||||
Shortcut { sequence: "Ctrl+Return"; onActivated: controller.complete() }
|
||||
Shortcut { sequence: "Ctrl+E"; onActivated: controller.extend(5) }
|
||||
Shortcut { sequence: "Space"; onActivated: if (controller.timerEnabled) controller.toggle() }
|
||||
Shortcut { sequence: "Ctrl+Return"; onActivated: if (controller.timerEnabled) controller.complete() }
|
||||
Shortcut { sequence: "Ctrl+E"; onActivated: if (controller.timerEnabled) controller.extend(5) }
|
||||
Shortcut { sequence: "Ctrl+R"; onActivated: controller.refreshSources() }
|
||||
Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 }
|
||||
Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 }
|
||||
@@ -65,143 +58,167 @@ ApplicationWindow {
|
||||
Shortcut { sequence: "Ctrl+4"; onActivated: window.page = 3 }
|
||||
Shortcut { sequence: "Ctrl+5"; onActivated: window.page = 4 }
|
||||
Shortcut { sequence: "Ctrl+W"; onActivated: window.hide() }
|
||||
|
||||
header: ToolBar {
|
||||
visible: !window.wide
|
||||
Material.background: Material.backgroundColor
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
Label {
|
||||
text: ["Timer", "Tasks", "Blocker", "Report", "Settings"][window.page]
|
||||
font.pixelSize: 18
|
||||
font.weight: Font.Medium
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
StatusPill {
|
||||
running: controller.running
|
||||
paused: controller.status === "paused"
|
||||
connected: controller.connected
|
||||
text: controller.connected ? controller.clockText : "offline"
|
||||
accent: window.kindColor(controller.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
Shortcut { sequence: "Ctrl+M"; onActivated: controller.toggleMini() }
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// ---- navigation rail ------------------------------------------------
|
||||
Pane {
|
||||
// ---- the rail --------------------------------------------------------
|
||||
Item {
|
||||
visible: window.wide
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: 212
|
||||
Material.elevation: 0
|
||||
padding: 12
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
width: 1
|
||||
height: parent.height
|
||||
color: Theme.rule
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 4
|
||||
anchors.margins: 22
|
||||
spacing: 2
|
||||
|
||||
RowLayout {
|
||||
Layout.bottomMargin: 16
|
||||
Layout.leftMargin: 8
|
||||
spacing: 10
|
||||
Rectangle {
|
||||
width: 36; height: 36; radius: 18
|
||||
color: Material.accent
|
||||
Label { anchors.centerIn: parent; text: "🍅"; font.pixelSize: 18 }
|
||||
ColumnLayout {
|
||||
Layout.bottomMargin: 26
|
||||
spacing: 0
|
||||
Label {
|
||||
text: "Kadence"
|
||||
color: Theme.ink
|
||||
font.pixelSize: 23
|
||||
font.family: Theme.displayFamily
|
||||
font.letterSpacing: -0.5
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 0
|
||||
Label { text: "Kadence"; font.pixelSize: 17; font.weight: Font.DemiBold }
|
||||
Label {
|
||||
text: controller.connected ? "connected" : "offline"
|
||||
font.pixelSize: 11
|
||||
opacity: 0.6
|
||||
}
|
||||
Label {
|
||||
text: "find your rhythm"
|
||||
color: Theme.muted
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ label: "Timer", icon: "⏱" },
|
||||
{ label: "Tasks", icon: "✓" },
|
||||
{ label: "Blocker", icon: "🛡" },
|
||||
{ label: "Report", icon: "📊" },
|
||||
{ label: "Settings", icon: "⚙" }
|
||||
]
|
||||
delegate: ItemDelegate {
|
||||
model: ["Timer", "Tasks", "Blocker", "Report", "Settings"]
|
||||
delegate: Item {
|
||||
required property int index
|
||||
required property var modelData
|
||||
required property string modelData
|
||||
Layout.fillWidth: true
|
||||
height: 44
|
||||
highlighted: window.page === index
|
||||
onClicked: window.page = index
|
||||
implicitHeight: 34
|
||||
|
||||
background: Rectangle {
|
||||
radius: 22
|
||||
color: parent.highlighted ? Material.accent
|
||||
: (parent.hovered ? Material.listHighlightColor : "transparent")
|
||||
opacity: parent.highlighted ? 0.18 : 1
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 2
|
||||
height: 16
|
||||
radius: 1
|
||||
color: Theme.ember
|
||||
visible: window.page === index
|
||||
}
|
||||
contentItem: RowLayout {
|
||||
spacing: 12
|
||||
Label { text: modelData.icon; font.pixelSize: 16 }
|
||||
Label {
|
||||
text: modelData.label
|
||||
font.weight: parent.parent.highlighted ? Font.DemiBold : Font.Normal
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Label {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData
|
||||
color: window.page === index ? Theme.ink : (mouse.containsMouse ? Theme.ink : Theme.muted)
|
||||
font.pixelSize: 14
|
||||
font.weight: window.page === index ? Font.DemiBold : Font.Normal
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: window.page = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
StatusPill {
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule; Layout.bottomMargin: 10 }
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
running: controller.running
|
||||
paused: controller.status === "paused"
|
||||
connected: controller.connected
|
||||
text: controller.connected ? (controller.running ? controller.clockText : "idle") : "offline"
|
||||
accent: window.kindColor(controller.kind)
|
||||
spacing: 8
|
||||
Rectangle {
|
||||
width: 7; height: 7; radius: 3.5
|
||||
color: controller.running ? Theme.kindColor(controller.kind) : Theme.faint
|
||||
SequentialAnimation on opacity {
|
||||
running: controller.running && controller.status === "running"
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.35; duration: 900 }
|
||||
NumberAnimation { to: 1.0; duration: 900 }
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: controller.connected
|
||||
? (controller.timerEnabled
|
||||
? (controller.running ? controller.clockText : "Idle")
|
||||
: "View mode")
|
||||
: "Offline"
|
||||
color: Theme.ink
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.monoFamily
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: controller.completedToday + "/" + controller.goal
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 6
|
||||
text: controller.completedToday + "/" + controller.goal + " today"
|
||||
font.pixelSize: 12
|
||||
opacity: 0.7
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
visible: controller.blockerActive
|
||||
text: "🛡 blocking is on"
|
||||
font.pixelSize: 12
|
||||
color: "#e34948"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: "● BLOCKING"
|
||||
color: Theme.clay
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 1.1
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pages ----------------------------------------------------------
|
||||
// ---- pages -----------------------------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 0
|
||||
|
||||
Banner {
|
||||
ToolBar {
|
||||
visible: !window.wide
|
||||
Layout.fillWidth: true
|
||||
visible: !controller.connected
|
||||
text: controller.connectionError !== ""
|
||||
? controller.connectionError
|
||||
: "Cannot reach the backend. Start it with `docker compose up`."
|
||||
actionText: "Settings"
|
||||
onActionClicked: window.page = 4
|
||||
background: Rectangle {
|
||||
color: Theme.paper
|
||||
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: Theme.rule }
|
||||
}
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
Label {
|
||||
text: "Kadence"
|
||||
color: Theme.ink
|
||||
font.pixelSize: 18
|
||||
font.family: Theme.displayFamily
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Label {
|
||||
text: controller.connected
|
||||
? (controller.timerEnabled
|
||||
? (controller.running ? controller.clockText : "Idle")
|
||||
: "View mode")
|
||||
: "Offline"
|
||||
color: Theme.ink
|
||||
font.pixelSize: 13
|
||||
font.family: Theme.monoFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StackLayout {
|
||||
@@ -215,20 +232,29 @@ ApplicationWindow {
|
||||
ReportPage {}
|
||||
SettingsPage {}
|
||||
}
|
||||
|
||||
TabBar {
|
||||
visible: !window.wide
|
||||
Layout.fillWidth: true
|
||||
currentIndex: window.page
|
||||
onCurrentIndexChanged: window.page = currentIndex
|
||||
background: Rectangle {
|
||||
color: Theme.paper
|
||||
Rectangle { width: parent.width; height: 1; color: Theme.rule }
|
||||
}
|
||||
TabButton { text: "Timer" }
|
||||
TabButton { text: "Tasks" }
|
||||
TabButton { text: "Blocker" }
|
||||
TabButton { text: "Report" }
|
||||
TabButton { text: "Settings" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- footer: bottom navigation on a narrow window ----------------------
|
||||
footer: TabBar {
|
||||
visible: !window.wide
|
||||
currentIndex: window.page
|
||||
onCurrentIndexChanged: window.page = currentIndex
|
||||
TabButton { text: "⏱" }
|
||||
TabButton { text: "✓" }
|
||||
TabButton { text: "🛡" }
|
||||
TabButton { text: "📊" }
|
||||
TabButton { text: "⚙" }
|
||||
}
|
||||
|
||||
Snackbar { id: snackbar }
|
||||
|
||||
// The floating mini timer is created by app.py as its own top-level window:
|
||||
// nested inside this one it would be transient, and a transient window
|
||||
// cannot be shown while its parent is hidden — which is precisely when the
|
||||
// mini timer earns its keep.
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// The floating mini timer.
|
||||
//
|
||||
// A small pill you keep in a corner while you work: the countdown, what you are
|
||||
// working on, and the three controls you actually reach for. It appears on
|
||||
// demand (tray, Ctrl+M, the D-Bus verb) and — if you let it — by itself
|
||||
// whenever an interval is running and the main window is out of the way.
|
||||
//
|
||||
// Everything has its own row. An earlier version saved space by swapping the
|
||||
// task line for the controls on hover and by drawing the progress bar over the
|
||||
// bottom edge; both of those put things on top of each other, and a timer you
|
||||
// have to hover to operate is a timer you cannot operate at a glance.
|
||||
//
|
||||
// Dragging uses the compositor's own move (`startSystemMove`), the only thing
|
||||
// that works on Wayland, and the window refuses keyboard focus rather than
|
||||
// being a `Qt.Tool` — Qt hides tool windows whenever the application is
|
||||
// deactivated, which made this one vanish on every click in another app.
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
|
||||
import "."
|
||||
|
||||
Window {
|
||||
id: mini
|
||||
|
||||
width: 252
|
||||
height: card.implicitHeight + 12
|
||||
color: "transparent"
|
||||
|
||||
// KWin's rule matches a substring of this, so it must stay distinct from
|
||||
// the main window's caption. Qt appends " — Kadence" to it.
|
||||
title: "Kadence mini"
|
||||
|
||||
flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
|
||||
| Qt.WindowDoesNotAcceptFocus
|
||||
|
||||
readonly property color accent: Theme.kindColor(controller.kind)
|
||||
readonly property bool paused: controller.status === "paused"
|
||||
|
||||
// Placement is decided in app.py: it is the only piece that knows about
|
||||
// screens, and on Wayland it has to go through a KWin rule anyway.
|
||||
onXChanged: saveTimer.restart()
|
||||
onYChanged: saveTimer.restart()
|
||||
Timer {
|
||||
id: saveTimer
|
||||
interval: 800
|
||||
onTriggered: if (mini.visible) controller.saveMiniPosition(mini.x, mini.y)
|
||||
}
|
||||
|
||||
// A small pill button. `filled` marks the one action you want by default —
|
||||
// Resume when paused, Start when idle.
|
||||
component MiniButton: Rectangle {
|
||||
id: button
|
||||
property string label: ""
|
||||
property bool filled: false
|
||||
signal clicked()
|
||||
|
||||
implicitWidth: buttonText.implicitWidth + 18
|
||||
implicitHeight: 24
|
||||
radius: 7
|
||||
color: filled
|
||||
? (buttonHover.hovered ? Qt.lighter(mini.accent, 1.08) : mini.accent)
|
||||
: (buttonHover.hovered ? Qt.alpha(mini.accent, 0.12) : "transparent")
|
||||
border.width: filled ? 0 : 1
|
||||
border.color: buttonHover.hovered ? mini.accent : Theme.rule
|
||||
Behavior on color { ColorAnimation { duration: 110 } }
|
||||
|
||||
Label {
|
||||
id: buttonText
|
||||
anchors.fill: parent
|
||||
text: button.label
|
||||
color: button.filled ? "white" : Theme.ink
|
||||
font.pixelSize: 11
|
||||
font.weight: button.filled ? Font.DemiBold : Font.Normal
|
||||
font.family: Theme.sansFamily
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
HoverHandler { id: buttonHover; cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler { onTapped: button.clicked() }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
radius: 14
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.rule
|
||||
implicitHeight: body.implicitHeight + 24 + progressLane.height + 5
|
||||
|
||||
HoverHandler { id: hover }
|
||||
|
||||
DragHandler {
|
||||
target: null
|
||||
grabPermissions: PointerHandler.CanTakeOverFromAnything
|
||||
onActiveChanged: if (active) mini.startSystemMove()
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
acceptedButtons: Qt.LeftButton
|
||||
onDoubleTapped: controller.showWindowRequested()
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: body
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 3
|
||||
|
||||
// ---- state -------------------------------------------------------
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 7
|
||||
|
||||
Rectangle {
|
||||
width: 6; height: 6; radius: 3
|
||||
color: controller.running ? mini.accent : Theme.faint
|
||||
SequentialAnimation on opacity {
|
||||
running: controller.running && !mini.paused
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.3; duration: 900 }
|
||||
NumberAnimation { to: 1.0; duration: 900 }
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: (controller.running ? controller.kindLabel : "Idle").toUpperCase()
|
||||
+ (mini.paused ? " · PAUSED" : "")
|
||||
color: mini.paused ? Theme.amber : (controller.running ? mini.accent : Theme.muted)
|
||||
font.pixelSize: 9
|
||||
font.letterSpacing: 1.2
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Label {
|
||||
text: controller.completedToday + "/" + controller.goal
|
||||
color: Theme.faint
|
||||
font.pixelSize: 10
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Label {
|
||||
text: "✕"
|
||||
color: closeHover.hovered ? Theme.ink : Theme.faint
|
||||
font.pixelSize: 11
|
||||
opacity: hover.hovered ? 1 : 0.35
|
||||
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||
HoverHandler { id: closeHover; cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler { onTapped: controller.setMiniVisible(false) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the time ----------------------------------------------------
|
||||
Label {
|
||||
text: controller.clockText
|
||||
color: Theme.ink
|
||||
opacity: mini.paused ? 0.55 : 1
|
||||
font.pixelSize: 32
|
||||
font.family: Theme.displayFamily
|
||||
font.letterSpacing: -1
|
||||
Layout.topMargin: -2
|
||||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
// ---- what you are on ---------------------------------------------
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: controller.taskTitle !== ""
|
||||
? controller.taskTitle
|
||||
: (controller.running ? "no task attached" : "double-click to open Kadence")
|
||||
color: Theme.muted
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// ---- controls, always visible --------------------------------------
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
spacing: 6
|
||||
|
||||
MiniButton {
|
||||
label: mini.paused ? "Resume" : "Pause"
|
||||
filled: mini.paused
|
||||
visible: controller.running
|
||||
onClicked: mini.paused ? controller.resume() : controller.pause()
|
||||
}
|
||||
MiniButton {
|
||||
label: "Finish"
|
||||
visible: controller.running
|
||||
onClicked: controller.complete()
|
||||
}
|
||||
MiniButton {
|
||||
label: "+5"
|
||||
visible: controller.running
|
||||
onClicked: controller.extend(5)
|
||||
}
|
||||
MiniButton {
|
||||
label: "Start focus"
|
||||
filled: true
|
||||
visible: !controller.running
|
||||
onClicked: controller.startWork()
|
||||
}
|
||||
MiniButton {
|
||||
label: "Break"
|
||||
visible: !controller.running
|
||||
onClicked: controller.startBreak()
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- progress, in a lane of its own ----------------------------------
|
||||
Item {
|
||||
id: progressLane
|
||||
objectName: "progressLane"
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 1
|
||||
anchors.rightMargin: 1
|
||||
anchors.bottomMargin: 1
|
||||
height: 3
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
width: parent.width * Math.max(0, Math.min(1, controller.progress))
|
||||
height: parent.height
|
||||
color: mini.accent
|
||||
opacity: controller.running ? (mini.paused ? 0.45 : 1) : 0.25
|
||||
Behavior on width { NumberAnimation { duration: 400 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
// The day, summarised — the same payload the Telegram message is built from.
|
||||
// The day in numbers — the same payload the Telegram message is built from.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "."
|
||||
import "components"
|
||||
|
||||
ScrollView {
|
||||
@@ -14,166 +15,214 @@ ScrollView {
|
||||
readonly property var metrics: payload ? payload.metrics : null
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth
|
||||
spacing: 16
|
||||
width: page.availableWidth - 56
|
||||
x: 28
|
||||
y: 26
|
||||
spacing: 22
|
||||
|
||||
PageHeader {
|
||||
eyebrow: controller.report.sent_at ? "sent to Telegram" : "not sent yet"
|
||||
title: page.payload ? "Report · " + page.payload.date : "Report"
|
||||
caption: page.payload ? page.payload.verdict : ""
|
||||
|
||||
GhostButton { text: "Reload"; onClicked: controller.loadReport() }
|
||||
PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
|
||||
}
|
||||
|
||||
Panel {
|
||||
padding: 0
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: [
|
||||
{ label: "Pomodoros",
|
||||
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—",
|
||||
caption: page.metrics && page.metrics.goal_met ? "goal met" : "goal not met yet",
|
||||
accent: true },
|
||||
{ label: "Focus",
|
||||
value: page.metrics ? page.metrics.focus_minutes + "m" : "—",
|
||||
caption: page.metrics ? page.metrics.break_minutes + "m resting" : "" },
|
||||
{ label: "Score",
|
||||
value: page.payload ? "" + Math.round(page.payload.score) : "—",
|
||||
caption: "out of 100" },
|
||||
{ label: "Streak",
|
||||
value: page.payload ? page.payload.streak.current + "d" : "—",
|
||||
caption: page.payload ? "best " + page.payload.streak.best : "" }
|
||||
]
|
||||
delegate: RowLayout {
|
||||
required property int index
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Rectangle {
|
||||
visible: index > 0
|
||||
width: 1
|
||||
Layout.preferredHeight: 84
|
||||
color: Theme.rule
|
||||
}
|
||||
Stat {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
label: modelData.label
|
||||
value: modelData.value
|
||||
caption: modelData.caption
|
||||
size: 38
|
||||
accent: modelData.accent ? Theme.work : Theme.ink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
spacing: 16
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: page.payload ? "Report for " + page.payload.date : "Report"
|
||||
font.pixelSize: 20
|
||||
font.weight: Font.Medium
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Button { text: "⟳"; flat: true; onClicked: controller.loadReport() }
|
||||
PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: page.width > 760 ? 4 : 2
|
||||
columnSpacing: 12
|
||||
rowSpacing: 12
|
||||
|
||||
StatTile {
|
||||
label: "Pomodoros"
|
||||
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—"
|
||||
caption: page.metrics && page.metrics.goal_met ? "goal met 🎯" : "keep going"
|
||||
accent: window.workColor
|
||||
}
|
||||
StatTile {
|
||||
label: "Focus"
|
||||
value: page.metrics ? page.metrics.focus_minutes + "m" : "—"
|
||||
caption: page.metrics ? page.metrics.break_minutes + "m resting" : ""
|
||||
}
|
||||
StatTile {
|
||||
label: "Score"
|
||||
value: page.payload ? Math.round(page.payload.score) : "—"
|
||||
caption: "out of 100"
|
||||
}
|
||||
StatTile {
|
||||
label: "Streak"
|
||||
value: page.payload ? page.payload.streak.current + "d" : "—"
|
||||
caption: page.payload ? "best " + page.payload.streak.best : ""
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Last 30 days"
|
||||
subtitle: "Goal line is the dashed row; darker bars cleared it."
|
||||
spacing: 10
|
||||
SectionTitle { text: "Last 30 days"; trailing: "goal line dashed" }
|
||||
Panel {
|
||||
BarRow {
|
||||
Layout.fillWidth: true
|
||||
days: controller.stats.range ? controller.stats.range : []
|
||||
goal: controller.goal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
visible: page.payload && page.payload.achievements.length > 0
|
||||
title: "Achievements"
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Repeater {
|
||||
model: page.payload ? page.payload.achievements : []
|
||||
delegate: Pane {
|
||||
required property var modelData
|
||||
Material.elevation: 0
|
||||
Material.roundedScale: Material.SmallScale
|
||||
padding: 10
|
||||
background: Rectangle {
|
||||
radius: 10
|
||||
color: Material.accent
|
||||
opacity: 0.14
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: page.payload && page.payload.achievements.length > 0
|
||||
spacing: 10
|
||||
SectionTitle { text: "Achievements" }
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Repeater {
|
||||
model: page.payload ? page.payload.achievements : []
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
implicitWidth: badge.implicitWidth + 24
|
||||
implicitHeight: badge.implicitHeight + 18
|
||||
radius: 10
|
||||
color: Qt.alpha(Theme.ember, 0.08)
|
||||
border.width: 1
|
||||
border.color: Qt.alpha(Theme.ember, 0.22)
|
||||
|
||||
ColumnLayout {
|
||||
id: badge
|
||||
anchors.centerIn: parent
|
||||
spacing: 1
|
||||
Label {
|
||||
text: modelData.emoji + " " + modelData.title
|
||||
color: Theme.ink
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 1
|
||||
Label {
|
||||
text: modelData.emoji + " " + modelData.title
|
||||
font.weight: Font.DemiBold
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Label {
|
||||
text: modelData.detail
|
||||
visible: modelData.detail !== ""
|
||||
font.pixelSize: 11
|
||||
opacity: 0.75
|
||||
}
|
||||
Label {
|
||||
visible: modelData.detail !== ""
|
||||
text: modelData.detail
|
||||
color: Theme.muted
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
visible: page.payload && page.payload.fun_facts.length > 0
|
||||
title: "The funny part"
|
||||
Repeater {
|
||||
model: page.payload ? page.payload.fun_facts : []
|
||||
delegate: Label {
|
||||
required property var modelData
|
||||
text: modelData
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
visible: page.metrics && page.metrics.tasks_completed.length > 0
|
||||
title: "Closed today"
|
||||
Repeater {
|
||||
model: page.metrics ? page.metrics.tasks_completed : []
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
Label { text: "✅"; font.pixelSize: 12 }
|
||||
Label { text: modelData.title; Layout.fillWidth: true; elide: Text.ElideRight }
|
||||
Label { text: modelData.source; font.pixelSize: 11; opacity: 0.6 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "All time"
|
||||
RowLayout {
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: page.payload && page.payload.fun_facts.length > 0
|
||||
spacing: 8
|
||||
SectionTitle { text: "The funny part" }
|
||||
Repeater {
|
||||
model: page.payload ? page.payload.fun_facts : []
|
||||
delegate: Label {
|
||||
required property var modelData
|
||||
text: modelData
|
||||
color: Theme.ink
|
||||
font.pixelSize: 13
|
||||
font.family: Theme.sansFamily
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
StatTile {
|
||||
label: "Pomodoros"
|
||||
value: controller.stats.lifetime ? controller.stats.lifetime.pomodoros : "—"
|
||||
}
|
||||
StatTile {
|
||||
label: "Hours focused"
|
||||
value: controller.stats.lifetime ? controller.stats.lifetime.focus_hours : "—"
|
||||
}
|
||||
StatTile {
|
||||
label: "Tasks closed"
|
||||
value: controller.stats.lifetime ? controller.stats.lifetime.tasks_completed : "—"
|
||||
}
|
||||
StatTile {
|
||||
label: "Blocked"
|
||||
value: controller.stats.lifetime ? controller.stats.lifetime.blocked_attempts : "—"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: page.payload && page.payload.verdict !== ""
|
||||
text: page.payload ? page.payload.verdict : ""
|
||||
font.italic: true
|
||||
font.pixelSize: 14
|
||||
opacity: 0.85
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: page.metrics && page.metrics.tasks_completed.length > 0
|
||||
spacing: 6
|
||||
SectionTitle { text: "Closed today" }
|
||||
Repeater {
|
||||
model: page.metrics ? page.metrics.tasks_completed : []
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
Label {
|
||||
text: modelData.title
|
||||
color: Theme.ink
|
||||
font.pixelSize: 13
|
||||
font.family: Theme.sansFamily
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Label {
|
||||
text: modelData.source
|
||||
color: Theme.faint
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
SectionTitle { text: "All time" }
|
||||
Panel {
|
||||
padding: 0
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: [
|
||||
{ label: "Pomodoros",
|
||||
value: controller.stats.lifetime ? "" + controller.stats.lifetime.pomodoros : "—" },
|
||||
{ label: "Hours focused",
|
||||
value: controller.stats.lifetime ? "" + controller.stats.lifetime.focus_hours : "—" },
|
||||
{ label: "Tasks closed",
|
||||
value: controller.stats.lifetime ? "" + controller.stats.lifetime.tasks_completed : "—" },
|
||||
{ label: "Blocked",
|
||||
value: controller.stats.lifetime ? "" + controller.stats.lifetime.blocked_attempts : "—" }
|
||||
]
|
||||
delegate: RowLayout {
|
||||
required property int index
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Rectangle {
|
||||
visible: index > 0
|
||||
width: 1
|
||||
Layout.preferredHeight: 64
|
||||
color: Theme.rule
|
||||
}
|
||||
Stat {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 18
|
||||
label: modelData.label
|
||||
value: modelData.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.preferredHeight: 10 }
|
||||
}
|
||||
|
||||
Component.onCompleted: controller.loadReport()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Settings, split by where they live.
|
||||
//
|
||||
// Anything under "This computer" is local to this machine (and saved in
|
||||
// ~/.config/kadence/desktop.json). Everything else is server-side, shared with
|
||||
// the web app and the Telegram bot.
|
||||
// "This computer" is local to this machine (~/.config/kadence/desktop.json).
|
||||
// Everything else is server-side, shared with the web app and the Telegram bot.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "."
|
||||
import "components"
|
||||
|
||||
ScrollView {
|
||||
@@ -29,362 +29,484 @@ ScrollView {
|
||||
controller.patchSettings({ notifications: { telegram: patch } })
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth
|
||||
spacing: 16
|
||||
component Field: ColumnLayout {
|
||||
property string label: ""
|
||||
default property alias content: holder.data
|
||||
spacing: 4
|
||||
Label {
|
||||
text: parent.label
|
||||
color: Theme.muted
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
ColumnLayout { id: holder; spacing: 0 }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth - 56
|
||||
x: 28
|
||||
y: 26
|
||||
spacing: 26
|
||||
|
||||
PageHeader {
|
||||
eyebrow: "Shared with the web app and the Telegram bot"
|
||||
title: "Settings"
|
||||
caption: "Secrets are masked once stored — leave the dots alone to keep the saved value."
|
||||
GhostButton { text: "Open the web app"; onClicked: controller.openWebUi() }
|
||||
}
|
||||
|
||||
// ---- backend ----------------------------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
spacing: 16
|
||||
|
||||
// ---- connection --------------------------------------------------
|
||||
Card {
|
||||
title: "Backend"
|
||||
subtitle: controller.connected ? "Connected to " + controller.config.server_url
|
||||
: controller.connectionError
|
||||
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
SectionTitle {
|
||||
text: "Backend"
|
||||
trailing: controller.connected ? "connected" : "offline"
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
TextField {
|
||||
id: serverField
|
||||
Layout.fillWidth: true
|
||||
TextField {
|
||||
id: serverField
|
||||
Layout.fillWidth: true
|
||||
text: controller.config.server_url
|
||||
placeholderText: "http://localhost:8000"
|
||||
}
|
||||
TextField {
|
||||
id: tokenField
|
||||
Layout.preferredWidth: 180
|
||||
text: controller.config.api_token
|
||||
placeholderText: "API token (optional)"
|
||||
echoMode: TextInput.Password
|
||||
}
|
||||
PrimaryButton {
|
||||
text: "Connect"
|
||||
onClicked: {
|
||||
controller.setConfig("server_url", serverField.text.trim())
|
||||
controller.setConfig("api_token", tokenField.text)
|
||||
}
|
||||
text: controller.config.server_url
|
||||
placeholderText: "http://localhost:8000"
|
||||
}
|
||||
TextField {
|
||||
id: tokenField
|
||||
Layout.preferredWidth: 180
|
||||
text: controller.config.api_token
|
||||
placeholderText: "API token (optional)"
|
||||
echoMode: TextInput.Password
|
||||
}
|
||||
PrimaryButton {
|
||||
text: "Connect"
|
||||
onClicked: {
|
||||
controller.setConfig("server_url", serverField.text.trim())
|
||||
controller.setConfig("api_token", tokenField.text)
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
Label { text: "Poll every"; font.pixelSize: 12 }
|
||||
}
|
||||
Label {
|
||||
visible: !controller.connected && controller.connectionError !== ""
|
||||
text: controller.connectionError
|
||||
color: Theme.clay
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// ---- timer -------------------------------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
SectionTitle { text: "Timer" }
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 20
|
||||
Field {
|
||||
label: "Focus (min)"
|
||||
SpinBox {
|
||||
from: 1; to: 60
|
||||
value: controller.config.poll_seconds
|
||||
onValueModified: controller.setConfig("poll_seconds", value)
|
||||
}
|
||||
Label { text: "seconds"; font.pixelSize: 12 }
|
||||
Item { Layout.fillWidth: true }
|
||||
Button { text: "Open the web app"; flat: true; onClicked: controller.openWebUi() }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- timer -------------------------------------------------------
|
||||
Card {
|
||||
title: "Timer"
|
||||
subtitle: "Shared with the web app and the Telegram bot."
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: page.width > 700 ? 4 : 2
|
||||
columnSpacing: 16
|
||||
rowSpacing: 8
|
||||
|
||||
ColumnLayout {
|
||||
Label { text: "Focus (min)"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: page.pomodoro ? page.pomodoro.work_minutes : 25
|
||||
onValueModified: page.patchPomodoro("work_minutes", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Short break"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 120
|
||||
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
|
||||
onValueModified: page.patchPomodoro("short_break_minutes", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Long break"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
|
||||
onValueModified: page.patchPomodoro("long_break_minutes", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Long break every"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 12
|
||||
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
|
||||
onValueModified: page.patchPomodoro("cycles_before_long_break", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Daily goal"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 40
|
||||
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
|
||||
onValueModified: page.patchPomodoro("daily_goal_pomodoros", value)
|
||||
}
|
||||
from: 1; to: 240
|
||||
value: page.pomodoro ? page.pomodoro.work_minutes : 25
|
||||
onValueModified: page.patchPomodoro("work_minutes", value)
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
Switch {
|
||||
text: "Auto-start breaks"
|
||||
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
|
||||
onToggled: page.patchPomodoro("auto_start_breaks", checked)
|
||||
Field {
|
||||
label: "Short break"
|
||||
SpinBox {
|
||||
from: 1; to: 120
|
||||
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
|
||||
onValueModified: page.patchPomodoro("short_break_minutes", value)
|
||||
}
|
||||
Switch {
|
||||
text: "Auto-start next focus"
|
||||
checked: page.pomodoro ? page.pomodoro.auto_start_work : false
|
||||
onToggled: page.patchPomodoro("auto_start_work", checked)
|
||||
}
|
||||
Field {
|
||||
label: "Long break"
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
|
||||
onValueModified: page.patchPomodoro("long_break_minutes", value)
|
||||
}
|
||||
Switch {
|
||||
text: "Allow pausing"
|
||||
checked: page.pomodoro ? page.pomodoro.allow_pause : true
|
||||
onToggled: page.patchPomodoro("allow_pause", checked)
|
||||
}
|
||||
Field {
|
||||
label: "Long break every"
|
||||
SpinBox {
|
||||
from: 1; to: 12
|
||||
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
|
||||
onValueModified: page.patchPomodoro("cycles_before_long_break", value)
|
||||
}
|
||||
Switch {
|
||||
text: "Strict mode"
|
||||
checked: page.pomodoro ? page.pomodoro.strict_mode : false
|
||||
onToggled: page.patchPomodoro("strict_mode", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Abandoning a focus interval requires a reason."
|
||||
}
|
||||
Field {
|
||||
label: "Daily goal"
|
||||
SpinBox {
|
||||
from: 1; to: 40
|
||||
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
|
||||
onValueModified: page.patchPomodoro("daily_goal_pomodoros", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- desktop integration ----------------------------------------
|
||||
Card {
|
||||
title: "This computer"
|
||||
subtitle: "Native desktop features. Stored locally, not on the server."
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 18
|
||||
Switch {
|
||||
text: "Timer enabled (off = view-only mode)"
|
||||
checked: controller.timerEnabled
|
||||
onToggled: controller.setTimerEnabled(checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Auto-start breaks"
|
||||
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
|
||||
onToggled: page.patchPomodoro("auto_start_breaks", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Auto-start next focus"
|
||||
checked: page.pomodoro ? page.pomodoro.auto_start_work : false
|
||||
onToggled: page.patchPomodoro("auto_start_work", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Allow pausing"
|
||||
checked: page.pomodoro ? page.pomodoro.allow_pause : true
|
||||
onToggled: page.patchPomodoro("allow_pause", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Strict mode"
|
||||
checked: page.pomodoro ? page.pomodoro.strict_mode : false
|
||||
onToggled: page.patchPomodoro("strict_mode", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Abandoning a focus interval requires a reason."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
// ---- this computer ------------------------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
SectionTitle { text: "This computer"; trailing: "native desktop features" }
|
||||
|
||||
Switch {
|
||||
text: "Tray icon"
|
||||
checked: controller.config.tray_icon
|
||||
onToggled: controller.setConfig("tray_icon", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Close to tray"
|
||||
checked: controller.config.close_to_tray
|
||||
onToggled: controller.setConfig("close_to_tray", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Notifications"
|
||||
checked: controller.config.notifications
|
||||
onToggled: controller.setConfig("notifications", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Notification buttons"
|
||||
checked: controller.config.notification_actions
|
||||
onToggled: controller.setConfig("notification_actions", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "“Start break” / “Skip” directly on the notification."
|
||||
}
|
||||
Switch {
|
||||
text: "Keep the screen awake"
|
||||
checked: controller.config.inhibit_screensaver
|
||||
onToggled: controller.setConfig("inhibit_screensaver", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Do Not Disturb while focusing"
|
||||
checked: controller.config.do_not_disturb
|
||||
onToggled: controller.setConfig("do_not_disturb", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Pause music when focus starts"
|
||||
checked: controller.config.pause_media
|
||||
onToggled: controller.setConfig("pause_media", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
|
||||
}
|
||||
Switch {
|
||||
text: "Taskbar progress"
|
||||
checked: controller.config.taskbar_progress
|
||||
onToggled: controller.setConfig("taskbar_progress", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Pause when the screen locks"
|
||||
checked: controller.config.pause_on_lock
|
||||
onToggled: controller.setConfig("pause_on_lock", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "D-Bus control"
|
||||
checked: controller.config.dbus_service
|
||||
onToggled: controller.setConfig("dbus_service", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "org.kadence.Desktop — bind it to a global shortcut."
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 18
|
||||
Switch {
|
||||
text: "Start in view-only mode"
|
||||
checked: controller.config.view_only
|
||||
onToggled: controller.setConfig("view_only", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Tray icon"
|
||||
checked: controller.config.tray_icon
|
||||
onToggled: controller.setConfig("tray_icon", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Close to tray"
|
||||
checked: controller.config.close_to_tray
|
||||
onToggled: controller.setConfig("close_to_tray", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Notifications"
|
||||
checked: controller.config.notifications
|
||||
onToggled: controller.setConfig("notifications", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Notification buttons"
|
||||
checked: controller.config.notification_actions
|
||||
onToggled: controller.setConfig("notification_actions", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Keep the screen awake"
|
||||
checked: controller.config.inhibit_screensaver
|
||||
onToggled: controller.setConfig("inhibit_screensaver", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Do Not Disturb while focusing"
|
||||
checked: controller.config.do_not_disturb
|
||||
onToggled: controller.setConfig("do_not_disturb", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Pause music on focus"
|
||||
checked: controller.config.pause_media
|
||||
onToggled: controller.setConfig("pause_media", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
|
||||
}
|
||||
Switch {
|
||||
text: "Mini timer"
|
||||
checked: controller.config.mini_timer
|
||||
onToggled: controller.setConfig("mini_timer", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "A small always-on-top countdown. Ctrl+M, or the tray menu."
|
||||
}
|
||||
Switch {
|
||||
text: "Mini timer follows the window"
|
||||
checked: controller.config.mini_timer_auto
|
||||
onToggled: controller.setConfig("mini_timer_auto", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Show it while an interval runs and the main window is hidden."
|
||||
}
|
||||
Switch {
|
||||
text: "Keep the mini timer above everything"
|
||||
enabled: controller.config.keep_above_supported
|
||||
checked: controller.config.mini_timer_keep_above
|
||||
onToggled: controller.setConfig("mini_timer_keep_above", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: controller.config.keep_above_supported
|
||||
? "Installs a KWin window rule — the only way to float above "
|
||||
+ "other apps on Wayland. Switching this off removes it again."
|
||||
: "Needs KWin (KDE). Elsewhere the Qt window flag is used."
|
||||
}
|
||||
Switch {
|
||||
text: "Taskbar progress"
|
||||
checked: controller.config.taskbar_progress
|
||||
onToggled: controller.setConfig("taskbar_progress", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Pause when the screen locks"
|
||||
checked: controller.config.pause_on_lock
|
||||
onToggled: controller.setConfig("pause_on_lock", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "D-Bus control"
|
||||
checked: controller.config.dbus_service
|
||||
onToggled: controller.setConfig("dbus_service", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "org.kadence.Desktop — bind it to a global shortcut."
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 20
|
||||
Field {
|
||||
label: "Mini timer appears"
|
||||
ComboBox {
|
||||
implicitWidth: 210
|
||||
textRole: "label"
|
||||
valueRole: "key"
|
||||
model: [
|
||||
{ key: "top-left", label: "Top left" },
|
||||
{ key: "top-centre", label: "Top centre" },
|
||||
{ key: "top-right", label: "Top right" },
|
||||
{ key: "bottom-left", label: "Bottom left" },
|
||||
{ key: "bottom-centre", label: "Bottom centre" },
|
||||
{ key: "bottom-right", label: "Bottom right" },
|
||||
{ key: "centre", label: "Centre of the screen" },
|
||||
{ key: "remember", label: "Where I last dragged it" }
|
||||
]
|
||||
currentIndex: {
|
||||
var corner = controller.config.mini_timer_corner
|
||||
for (var i = 0; i < model.length; i++)
|
||||
if (model[i].key === corner) return i
|
||||
return 5
|
||||
}
|
||||
onActivated: function (index) {
|
||||
controller.setConfig("mini_timer_corner", model[index].key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Field {
|
||||
label: "Gap from the edges (px)"
|
||||
SpinBox {
|
||||
from: 0; to: 400; stepSize: 4
|
||||
value: controller.config.mini_timer_margin
|
||||
onValueModified: controller.setConfig("mini_timer_margin", value)
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label { text: "Pause after"; font.pixelSize: 12 }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 20
|
||||
Field {
|
||||
label: "Pause after idle (min, 0 = never)"
|
||||
SpinBox {
|
||||
from: 0; to: 120
|
||||
value: controller.config.pause_on_idle_minutes
|
||||
onValueModified: controller.setConfig("pause_on_idle_minutes", value)
|
||||
}
|
||||
Label { text: "idle minutes (0 = never)"; font.pixelSize: 12 }
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Switch {
|
||||
text: "Start with the session"
|
||||
checked: controller.config.autostart
|
||||
onToggled: controller.setConfig("autostart", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "systemd user service"
|
||||
checked: controller.config.service_enabled
|
||||
onToggled: controller.setConfig("service_enabled", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Restarts the app if it dies; logs to the journal."
|
||||
}
|
||||
Switch {
|
||||
text: "Start minimised"
|
||||
checked: controller.config.start_minimized
|
||||
onToggled: controller.setConfig("start_minimized", checked)
|
||||
Field {
|
||||
label: "Poll the backend every (s)"
|
||||
SpinBox {
|
||||
from: 1; to: 60
|
||||
value: controller.config.poll_seconds
|
||||
onValueModified: controller.setConfig("poll_seconds", value)
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
// ---- telegram ----------------------------------------------------
|
||||
Card {
|
||||
title: "Telegram"
|
||||
subtitle: "Daily report, morning briefing and the nudges that get you back to work."
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Switch {
|
||||
text: "Enabled"
|
||||
checked: page.telegram ? page.telegram.enabled : false
|
||||
onToggled: page.patchTelegram("enabled", checked)
|
||||
}
|
||||
TextField {
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Bot token from @BotFather"
|
||||
text: page.telegram ? page.telegram.bot_token : ""
|
||||
onEditingFinished: if (text !== "••••••••") page.patchTelegram("bot_token", text)
|
||||
}
|
||||
TextField {
|
||||
Layout.preferredWidth: 160
|
||||
placeholderText: "Chat id"
|
||||
text: page.telegram ? page.telegram.chat_id : ""
|
||||
onEditingFinished: page.patchTelegram("chat_id", text)
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 18
|
||||
Switch {
|
||||
text: "Start with the session"
|
||||
checked: controller.config.autostart
|
||||
onToggled: controller.setConfig("autostart", checked)
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
Switch {
|
||||
text: "Daily report"
|
||||
checked: page.telegram ? page.telegram.daily_report : true
|
||||
onToggled: page.patchTelegram("daily_report", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Morning briefing"
|
||||
checked: page.telegram ? page.telegram.morning_briefing : true
|
||||
onToggled: page.patchTelegram("morning_briefing", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Weekly review"
|
||||
checked: page.telegram ? page.telegram.weekly_report : true
|
||||
onToggled: page.patchTelegram("weekly_report", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Nudge me when idle"
|
||||
checked: page.telegram ? page.telegram.nudge_enabled : true
|
||||
onToggled: page.patchTelegram("nudge_enabled", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Chat commands"
|
||||
checked: page.telegram ? page.telegram.commands_enabled : true
|
||||
onToggled: page.patchTelegram("commands_enabled", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "systemd user service"
|
||||
checked: controller.config.service_enabled
|
||||
onToggled: controller.setConfig("service_enabled", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Restarts the app if it dies; logs to the journal."
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
ColumnLayout {
|
||||
Label { text: "Report at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
|
||||
TextField {
|
||||
text: page.telegram ? page.telegram.daily_report_time : "21:00"
|
||||
onEditingFinished: page.patchTelegram("daily_report_time", text)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Briefing at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
|
||||
TextField {
|
||||
text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
|
||||
onEditingFinished: page.patchTelegram("morning_briefing_time", text)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Nudge after (min idle)"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 10; to: 600
|
||||
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
|
||||
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Tone"; font.pixelSize: 11; opacity: 0.7 }
|
||||
ComboBox {
|
||||
model: ["friendly", "coach", "drill_sergeant", "dry"]
|
||||
currentIndex: page.report ? model.indexOf(page.report.tone) : 0
|
||||
onActivated: function (index) {
|
||||
controller.patchSettings({ notifications: { report: { tone: model[index] } } })
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Language"; font.pixelSize: 11; opacity: 0.7 }
|
||||
ComboBox {
|
||||
model: ["en", "ru"]
|
||||
currentIndex: page.report && page.report.language === "ru" ? 1 : 0
|
||||
onActivated: function (index) {
|
||||
controller.patchSettings({ notifications: { report: { language: model[index] } } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Shortcuts and scripting"
|
||||
subtitle: "The app exposes org.kadence.Desktop on the session bus."
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: "Roboto Mono, monospace"
|
||||
font.pixelSize: 11
|
||||
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
|
||||
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
|
||||
+ "kadence-desktop --toggle # same thing from a terminal\n\n"
|
||||
+ "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
|
||||
}
|
||||
Label {
|
||||
text: "Kadence " + appVersion + " · sources, blocking rules and reports live on the backend."
|
||||
font.pixelSize: 11
|
||||
opacity: 0.6
|
||||
Switch {
|
||||
text: "Start minimised"
|
||||
checked: controller.config.start_minimized
|
||||
onToggled: controller.setConfig("start_minimized", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- telegram -------------------------------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
SectionTitle { text: "Telegram"; trailing: "reports, briefings and nudges" }
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
Switch {
|
||||
text: "Enabled"
|
||||
checked: page.telegram ? page.telegram.enabled : false
|
||||
onToggled: page.patchTelegram("enabled", checked)
|
||||
}
|
||||
TextField {
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Bot token from @BotFather"
|
||||
text: page.telegram ? page.telegram.bot_token : ""
|
||||
onEditingFinished: if (text !== "••••••••") page.patchTelegram("bot_token", text)
|
||||
}
|
||||
TextField {
|
||||
Layout.preferredWidth: 160
|
||||
placeholderText: "Chat id"
|
||||
text: page.telegram ? page.telegram.chat_id : ""
|
||||
onEditingFinished: page.patchTelegram("chat_id", text)
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 18
|
||||
Switch {
|
||||
text: "Daily report"
|
||||
checked: page.telegram ? page.telegram.daily_report : true
|
||||
onToggled: page.patchTelegram("daily_report", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Morning briefing"
|
||||
checked: page.telegram ? page.telegram.morning_briefing : true
|
||||
onToggled: page.patchTelegram("morning_briefing", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Weekly review"
|
||||
checked: page.telegram ? page.telegram.weekly_report : true
|
||||
onToggled: page.patchTelegram("weekly_report", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Nudge me when idle"
|
||||
checked: page.telegram ? page.telegram.nudge_enabled : true
|
||||
onToggled: page.patchTelegram("nudge_enabled", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Chat commands"
|
||||
checked: page.telegram ? page.telegram.commands_enabled : true
|
||||
onToggled: page.patchTelegram("commands_enabled", checked)
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 20
|
||||
Field {
|
||||
label: "Report at (HH:MM)"
|
||||
TextField {
|
||||
implicitWidth: 110
|
||||
text: page.telegram ? page.telegram.daily_report_time : "21:00"
|
||||
onEditingFinished: page.patchTelegram("daily_report_time", text)
|
||||
}
|
||||
}
|
||||
Field {
|
||||
label: "Briefing at (HH:MM)"
|
||||
TextField {
|
||||
implicitWidth: 110
|
||||
text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
|
||||
onEditingFinished: page.patchTelegram("morning_briefing_time", text)
|
||||
}
|
||||
}
|
||||
Field {
|
||||
label: "Nudge after (min idle)"
|
||||
SpinBox {
|
||||
from: 10; to: 600
|
||||
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
|
||||
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
|
||||
}
|
||||
}
|
||||
Field {
|
||||
label: "Tone"
|
||||
ComboBox {
|
||||
implicitWidth: 170
|
||||
model: ["friendly", "coach", "drill_sergeant", "dry"]
|
||||
currentIndex: page.report ? model.indexOf(page.report.tone) : 0
|
||||
onActivated: function (index) {
|
||||
controller.patchSettings({ notifications: { report: { tone: model[index] } } })
|
||||
}
|
||||
}
|
||||
}
|
||||
Field {
|
||||
label: "Language"
|
||||
ComboBox {
|
||||
implicitWidth: 110
|
||||
model: ["en", "ru"]
|
||||
currentIndex: page.report && page.report.language === "ru" ? 1 : 0
|
||||
onActivated: function (index) {
|
||||
controller.patchSettings({ notifications: { report: { language: model[index] } } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- scripting -------------------------------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
SectionTitle { text: "Shortcuts and scripting" }
|
||||
Panel {
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: Theme.muted
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.monoFamily
|
||||
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
|
||||
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
|
||||
+ "kadence-desktop --toggle"
|
||||
}
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: Theme.faint
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
text: "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: "Kadence " + appVersion
|
||||
color: Theme.faint
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.preferredHeight: 10 }
|
||||
}
|
||||
|
||||
Component.onCompleted: controller.loadSettings()
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
// Everything you owe, from every source, grouped by deadline.
|
||||
// Everything you owe, grouped by deadline.
|
||||
//
|
||||
// There is no checkbox: a task is closed in Jira, Todoist or Google Tasks, and
|
||||
// the next refresh notices. Two places to tick something off is two places to
|
||||
// be wrong.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "."
|
||||
import "components"
|
||||
|
||||
Item {
|
||||
ScrollView {
|
||||
id: page
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
|
||||
function bucketTasks(bucket) {
|
||||
var out = []
|
||||
@@ -17,130 +24,170 @@ Item {
|
||||
return out
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
function bucketColor(bucket) {
|
||||
if (bucket === "overdue") return Theme.clay
|
||||
if (bucket === "today") return Theme.amber
|
||||
if (bucket === "active") return Theme.pine
|
||||
if (bucket === "upcoming") return Theme.faint
|
||||
return Theme.rule
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Label {
|
||||
text: controller.tasks.length + " open"
|
||||
font.pixelSize: 13
|
||||
opacity: 0.7
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Button { text: "+ Add"; onClicked: addDialog.open() }
|
||||
PrimaryButton { text: "⟳ Refresh sources"; onClicked: controller.refreshSources() }
|
||||
ColumnLayout {
|
||||
width: page.availableWidth - 56
|
||||
x: 28
|
||||
y: 26
|
||||
spacing: 22
|
||||
|
||||
PageHeader {
|
||||
eyebrow: "From Jira, Todoist, Google Tasks and your own list"
|
||||
title: "Tasks"
|
||||
caption: "Close them where they live — the next refresh credits them to today."
|
||||
|
||||
GhostButton { text: "Add"; onClicked: addDialog.open() }
|
||||
PrimaryButton { text: "Refresh sources"; onClicked: controller.refreshSources() }
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
Repeater {
|
||||
model: [
|
||||
{ key: "overdue", label: "Overdue" },
|
||||
{ key: "today", label: "Due today" },
|
||||
{ key: "active", label: "In progress" },
|
||||
{ key: "upcoming", label: "Coming up" },
|
||||
{ key: "someday", label: "No deadline" }
|
||||
]
|
||||
delegate: ColumnLayout {
|
||||
required property var modelData
|
||||
property var items: page.bucketTasks(modelData.key)
|
||||
visible: items.length > 0
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.parent.availableWidth
|
||||
spacing: 12
|
||||
SectionTitle { text: modelData.label; trailing: "" + parent.items.length }
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ key: "overdue", label: "Overdue", mark: "🔴" },
|
||||
{ key: "today", label: "Due today", mark: "🟡" },
|
||||
{ key: "upcoming", label: "Coming up", mark: "🔵" },
|
||||
{ key: "someday", label: "No deadline", mark: "⚪" }
|
||||
]
|
||||
delegate: Card {
|
||||
model: parent.items
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
property var items: page.bucketTasks(modelData.key)
|
||||
visible: items.length > 0
|
||||
title: modelData.mark + " " + modelData.label + " (" + items.length + ")"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: row.implicitHeight + 20
|
||||
|
||||
Repeater {
|
||||
model: parent.parent.items
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.rule
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: row
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: 10
|
||||
anchors.bottomMargin: 10
|
||||
spacing: 14
|
||||
|
||||
Rectangle {
|
||||
width: 2
|
||||
Layout.fillHeight: true
|
||||
radius: 1
|
||||
color: page.bucketColor(modelData.bucket)
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
CheckBox {
|
||||
checked: modelData.is_done
|
||||
onToggled: controller.setTaskDone(modelData.id, checked)
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 3
|
||||
Label {
|
||||
text: modelData.title
|
||||
color: Theme.ink
|
||||
font.pixelSize: 14
|
||||
font.weight: modelData.pinned ? Font.DemiBold : Font.Normal
|
||||
font.family: Theme.sansFamily
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 12
|
||||
Label {
|
||||
text: modelData.title
|
||||
font.pixelSize: 14
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
text: modelData.source_label
|
||||
color: Theme.faint; font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 6
|
||||
Label {
|
||||
text: modelData.source_label
|
||||
font.pixelSize: 10
|
||||
opacity: 0.65
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.project
|
||||
text: "· " + modelData.project
|
||||
font.pixelSize: 10
|
||||
opacity: 0.65
|
||||
}
|
||||
Label {
|
||||
visible: modelData.days_overdue > 0
|
||||
text: "· " + modelData.days_overdue + "d overdue"
|
||||
font.pixelSize: 10
|
||||
color: "#e34948"
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.due_date && modelData.days_overdue === 0
|
||||
text: "· " + modelData.due_date
|
||||
font.pixelSize: 10
|
||||
opacity: 0.65
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.priority
|
||||
text: "· " + modelData.priority
|
||||
font.pixelSize: 10
|
||||
opacity: 0.65
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.project && modelData.project !== modelData.source_label
|
||||
text: modelData.project || ""
|
||||
color: Theme.faint; font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.status
|
||||
text: modelData.status || ""
|
||||
color: Theme.faint; font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Label {
|
||||
visible: modelData.days_overdue > 0
|
||||
text: modelData.days_overdue + "d overdue"
|
||||
color: Theme.clay; font.pixelSize: 11
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.due_date && modelData.days_overdue === 0
|
||||
text: "due " + (modelData.due_date || "")
|
||||
color: Theme.faint; font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
ToolButton {
|
||||
text: "↗"
|
||||
visible: !!modelData.url
|
||||
onClicked: controller.openUrl(modelData.url)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Open in " + modelData.source_label
|
||||
}
|
||||
ToolButton {
|
||||
text: "▶"
|
||||
onClicked: { controller.attachTask(modelData.id); controller.startWork(); window.page = 0 }
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Start a pomodoro on this"
|
||||
}
|
||||
|
||||
GhostButton {
|
||||
text: modelData.pinned ? "Unpin" : "Pin"
|
||||
implicitHeight: 28
|
||||
onClicked: controller.setTaskPinned(modelData.id, !modelData.pinned)
|
||||
}
|
||||
GhostButton {
|
||||
visible: !!modelData.url
|
||||
text: "Open"
|
||||
implicitHeight: 28
|
||||
onClicked: controller.openUrl(modelData.url)
|
||||
}
|
||||
GhostButton {
|
||||
text: "Focus"
|
||||
implicitHeight: 28
|
||||
onClicked: {
|
||||
controller.attachTask(modelData.id)
|
||||
controller.startWork()
|
||||
window.page = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
visible: controller.tasks.length === 0
|
||||
title: "Nothing on the list"
|
||||
subtitle: "Connect Jira, Todoist or Google Tasks in Settings → Sources, "
|
||||
+ "or add something here yourself."
|
||||
Button {
|
||||
text: "Open settings"
|
||||
onClicked: window.page = 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Panel {
|
||||
visible: controller.tasks.length === 0
|
||||
ColumnLayout {
|
||||
spacing: 8
|
||||
Label {
|
||||
text: "Nothing on the list"
|
||||
color: Theme.ink
|
||||
font.pixelSize: 17
|
||||
font.family: Theme.displayFamily
|
||||
}
|
||||
Label {
|
||||
text: "Connect Jira, Todoist or Google Tasks in Settings, or add something yourself."
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
GhostButton { text: "Open settings"; onClicked: window.page = 4 }
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.preferredHeight: 10 }
|
||||
}
|
||||
|
||||
Dialog {
|
||||
@@ -153,7 +200,7 @@ Item {
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
spacing: 12
|
||||
TextField {
|
||||
id: titleField
|
||||
Layout.fillWidth: true
|
||||
@@ -162,8 +209,15 @@ Item {
|
||||
TextField {
|
||||
id: dueField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Due date (YYYY-MM-DD, optional)"
|
||||
inputMask: ""
|
||||
placeholderText: "Due date — YYYY-MM-DD (optional)"
|
||||
}
|
||||
Label {
|
||||
text: "Local tasks live only here, so they are the one kind you can delete."
|
||||
color: Theme.faint
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
PrimaryButton {
|
||||
text: "Add"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
||||
/**
|
||||
* The design tokens, shared with the web app's `tokens.ts`.
|
||||
*
|
||||
* Kadence reads like a well-set page rather than a dashboard: warm paper, ink
|
||||
* type, hairline rules instead of boxes and shadows, one warm accent. Light and
|
||||
* dark follow the desktop's own colour scheme.
|
||||
*/
|
||||
QtObject {
|
||||
id: theme
|
||||
|
||||
property SystemPalette system: SystemPalette { colorGroup: SystemPalette.Active }
|
||||
|
||||
// The session decides; we only read it.
|
||||
readonly property bool dark: system.window.hslLightness < 0.5
|
||||
|
||||
readonly property color paper: dark ? "#121110" : "#FAF7F2"
|
||||
readonly property color surface: dark ? "#1A1917" : "#FFFFFF"
|
||||
readonly property color sunken: dark ? "#201E1B" : "#F3EFE8"
|
||||
readonly property color ink: dark ? "#F4F1EA" : "#17150F"
|
||||
readonly property color muted: dark ? "#A79F93" : "#6E675B"
|
||||
readonly property color faint: dark ? "#776F64" : "#9A9388"
|
||||
readonly property color rule: dark ? Qt.rgba(0.96, 0.95, 0.92, 0.14) : Qt.rgba(0.09, 0.08, 0.06, 0.12)
|
||||
readonly property color ember: dark ? "#E0724A" : "#B4441E"
|
||||
readonly property color pine: dark ? "#6FB394" : "#2F6B55"
|
||||
readonly property color amber: dark ? "#D9A441" : "#8A6200"
|
||||
readonly property color clay: dark ? "#E07A6E" : "#A33A2E"
|
||||
|
||||
// Chart colours are the validated data palette, not the brand accent —
|
||||
// a chart has to stay readable for colour-blind readers in both themes.
|
||||
readonly property color work: dark ? "#d95926" : "#eb6834"
|
||||
readonly property color shortBreak: dark ? "#3987e5" : "#2a78d6"
|
||||
readonly property color longBreak: dark ? "#199e70" : "#1baf7a"
|
||||
readonly property var sequential: dark
|
||||
? ["#104281", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4"]
|
||||
: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"]
|
||||
|
||||
// QML's `font.family` takes one name, so the preference list is resolved
|
||||
// once against what is actually installed on this machine.
|
||||
function pickFamily(candidates) {
|
||||
var available = Qt.fontFamilies()
|
||||
for (var i = 0; i < candidates.length; i++)
|
||||
if (available.indexOf(candidates[i]) >= 0)
|
||||
return candidates[i]
|
||||
return candidates[candidates.length - 1]
|
||||
}
|
||||
|
||||
readonly property string displayFamily: pickFamily(
|
||||
["Fraunces", "Noto Serif", "DejaVu Serif", "Liberation Serif", "Georgia", "serif"])
|
||||
readonly property string sansFamily: pickFamily(
|
||||
["Inter", "Noto Sans", "DejaVu Sans", "Liberation Sans", "sans-serif"])
|
||||
readonly property string monoFamily: pickFamily(
|
||||
["JetBrains Mono", "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "monospace"])
|
||||
|
||||
readonly property int radius: 12
|
||||
readonly property int gap: 12
|
||||
readonly property int pad: 18
|
||||
|
||||
function kindColor(kind) {
|
||||
if (kind === "short_break") return shortBreak
|
||||
if (kind === "long_break") return longBreak
|
||||
return work
|
||||
}
|
||||
|
||||
function kindLabel(kind) {
|
||||
if (kind === "short_break") return "Short break"
|
||||
if (kind === "long_break") return "Long break"
|
||||
return "Focus"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
// The timer page: the dial, the controls, and what you are working on.
|
||||
// The timer: the dial, the controls, and what you are working on.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "."
|
||||
import "components"
|
||||
|
||||
ScrollView {
|
||||
@@ -10,149 +11,165 @@ ScrollView {
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
|
||||
property var openTasks: {
|
||||
readonly property var openTasks: {
|
||||
var list = []
|
||||
for (var i = 0; i < controller.tasks.length; i++)
|
||||
if (!controller.tasks[i].is_done) list.push(controller.tasks[i])
|
||||
return list
|
||||
}
|
||||
|
||||
function suggestion() {
|
||||
var buckets = ["overdue", "today", "upcoming", "someday"]
|
||||
for (var b = 0; b < buckets.length; b++)
|
||||
for (var i = 0; i < openTasks.length; i++)
|
||||
if (openTasks[i].pinned || openTasks[i].bucket === buckets[b]) return openTasks[i]
|
||||
return null
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth
|
||||
spacing: 16
|
||||
anchors.margins: 20
|
||||
width: page.availableWidth - 56
|
||||
x: 28
|
||||
y: 26
|
||||
spacing: 22
|
||||
|
||||
PageHeader {
|
||||
eyebrow: Qt.formatDate(new Date(), "dddd, d MMMM")
|
||||
title: !controller.timerEnabled ? "View mode" : (controller.running ? controller.kindLabel : "Ready when you are")
|
||||
caption: !controller.timerEnabled
|
||||
? "Timer is disabled · view-only mode"
|
||||
: (controller.taskTitle !== ""
|
||||
? "Tracking “" + controller.taskTitle + "”"
|
||||
: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
|
||||
+ " before a long break")
|
||||
|
||||
GhostButton {
|
||||
visible: controller.timerEnabled && controller.running && controller.status !== "paused"
|
||||
text: "Pause"
|
||||
onClicked: controller.pause()
|
||||
}
|
||||
GhostButton {
|
||||
visible: controller.timerEnabled && controller.running && controller.status === "paused"
|
||||
text: "Resume"
|
||||
onClicked: controller.resume()
|
||||
}
|
||||
PrimaryButton {
|
||||
visible: controller.timerEnabled && controller.running
|
||||
text: "Finish"
|
||||
onClicked: controller.complete()
|
||||
}
|
||||
PrimaryButton {
|
||||
visible: controller.timerEnabled && !controller.running
|
||||
text: "Start focus"
|
||||
onClicked: controller.startWork()
|
||||
}
|
||||
GhostButton {
|
||||
visible: controller.timerEnabled && !controller.running
|
||||
text: controller.nextKindLabel
|
||||
onClicked: controller.startBreak()
|
||||
}
|
||||
GhostButton {
|
||||
text: controller.timerEnabled ? "Turn off timer" : "Turn on timer"
|
||||
onClicked: controller.setTimerEnabled(!controller.timerEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
columns: page.width > 900 ? 2 : 1
|
||||
columnSpacing: 16
|
||||
rowSpacing: 16
|
||||
columns: page.width > 940 ? 2 : 1
|
||||
columnSpacing: 22
|
||||
rowSpacing: 22
|
||||
|
||||
// ---- dial + controls --------------------------------------------
|
||||
Card {
|
||||
Panel {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
padding: 28
|
||||
|
||||
TimerDial {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.preferredWidth: Math.min(300, page.availableWidth * 0.7)
|
||||
Layout.preferredWidth: Math.min(290, page.availableWidth * 0.62)
|
||||
Layout.preferredHeight: Layout.preferredWidth
|
||||
progress: controller.progress
|
||||
clockText: controller.clockText
|
||||
label: controller.running ? controller.kindLabel : "Next: " + controller.nextKindLabel
|
||||
sublabel: controller.running
|
||||
? (controller.taskTitle !== "" ? controller.taskTitle : " ")
|
||||
: "Press start when you are ready"
|
||||
accent: window.kindColor(controller.kind)
|
||||
progress: controller.timerEnabled ? controller.progress : 0
|
||||
clockText: controller.timerEnabled ? controller.clockText : "—"
|
||||
minutes: Math.max(1, Math.round(controller.plannedSeconds / 60))
|
||||
label: !controller.timerEnabled ? "View mode" : (controller.running ? controller.kindLabel : "Next · " + controller.nextKindLabel)
|
||||
sublabel: !controller.timerEnabled
|
||||
? "Timer disabled"
|
||||
: (controller.taskTitle !== ""
|
||||
? controller.taskTitle
|
||||
: (controller.running ? "" : "Pick something below, or just begin"))
|
||||
accent: controller.timerEnabled ? Theme.kindColor(controller.kind) : Theme.muted
|
||||
paused: controller.status === "paused"
|
||||
idle: !controller.timerEnabled || !controller.running
|
||||
}
|
||||
|
||||
Flow {
|
||||
RowLayout {
|
||||
visible: controller.timerEnabled && controller.running
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: 8
|
||||
spacing: 8
|
||||
Layout.topMargin: 14
|
||||
spacing: 6
|
||||
|
||||
PrimaryButton {
|
||||
visible: !controller.running
|
||||
text: "▶ Start focus"
|
||||
onClicked: controller.startWork()
|
||||
}
|
||||
Button {
|
||||
visible: !controller.running
|
||||
text: "☕ " + controller.nextKindLabel
|
||||
onClicked: controller.startBreak()
|
||||
}
|
||||
Button {
|
||||
visible: controller.running && controller.status !== "paused"
|
||||
text: "⏸ Pause"
|
||||
onClicked: controller.pause()
|
||||
}
|
||||
PrimaryButton {
|
||||
visible: controller.running && controller.status === "paused"
|
||||
text: "▶ Resume"
|
||||
onClicked: controller.resume()
|
||||
}
|
||||
PrimaryButton {
|
||||
visible: controller.running
|
||||
text: "✓ Done"
|
||||
onClicked: controller.complete()
|
||||
}
|
||||
Button {
|
||||
visible: controller.running
|
||||
text: "+5"
|
||||
GhostButton {
|
||||
text: "+5 min"
|
||||
onClicked: controller.extend(5)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Add five minutes"
|
||||
}
|
||||
Button {
|
||||
visible: controller.running
|
||||
text: "🔔"
|
||||
GhostButton {
|
||||
text: "Interruption"
|
||||
onClicked: controller.interrupt()
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Count an interruption without stopping"
|
||||
ToolTip.text: "Count a distraction without stopping the clock"
|
||||
}
|
||||
Button {
|
||||
visible: controller.running
|
||||
text: "⏹"
|
||||
flat: true
|
||||
Material.foreground: "#e34948"
|
||||
GhostButton {
|
||||
text: "Abandon"
|
||||
onClicked: abandonDialog.open()
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Abandon this interval"
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: controller.interruptions > 0
|
||||
text: controller.interruptions + " interruption(s) this interval"
|
||||
visible: controller.timerEnabled && controller.interruptions > 0
|
||||
text: controller.interruptions + " interruption(s) so far"
|
||||
color: Theme.faint
|
||||
font.pixelSize: 11
|
||||
opacity: 0.6
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
|
||||
visible: controller.timerEnabled
|
||||
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
|
||||
color: Theme.faint
|
||||
font.pixelSize: 10
|
||||
opacity: 0.45
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
|
||||
// ---- today + task picker ----------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: 16
|
||||
spacing: 20
|
||||
|
||||
Card {
|
||||
title: "Today"
|
||||
subtitle: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
|
||||
+ " before a long break"
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
SectionTitle { text: "Today" }
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Label {
|
||||
text: controller.completedToday
|
||||
font.pixelSize: 34
|
||||
font.weight: Font.Light
|
||||
color: Theme.ink
|
||||
font.pixelSize: 40
|
||||
font.family: Theme.displayFamily
|
||||
font.letterSpacing: -1
|
||||
}
|
||||
Label {
|
||||
text: "/ " + controller.goal + " pomodoros"
|
||||
font.pixelSize: 16
|
||||
opacity: 0.7
|
||||
text: "of " + controller.goal + " pomodoros"
|
||||
color: Theme.muted
|
||||
font.pixelSize: 14
|
||||
font.family: Theme.sansFamily
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
Layout.bottomMargin: 6
|
||||
Layout.bottomMargin: 7
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
ProgressBar {
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: Math.max(controller.goal, 1)
|
||||
value: Math.min(controller.completedToday, controller.goal)
|
||||
}
|
||||
|
||||
Flow {
|
||||
@@ -162,27 +179,45 @@ ScrollView {
|
||||
model: Math.max(controller.goal, controller.completedToday)
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
width: 14; height: 14; radius: 7
|
||||
color: index < controller.completedToday ? window.workColor : "transparent"
|
||||
width: 11; height: 11; radius: 5.5
|
||||
color: index < controller.completedToday ? Theme.work : "transparent"
|
||||
border.width: index < controller.completedToday ? 0 : 1
|
||||
border.color: Material.hintTextColor
|
||||
border.color: Theme.rule
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: controller.stats.today
|
||||
? controller.stats.today.focus_minutes + "m focused · "
|
||||
+ controller.stats.today.break_minutes + "m rested"
|
||||
+ (controller.stats.today.longest_stretch_minutes > 0
|
||||
? " · longest run " + controller.stats.today.longest_stretch_minutes + "m" : "")
|
||||
: ""
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "What are you working on?"
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
SectionTitle { text: "Working on" }
|
||||
|
||||
ComboBox {
|
||||
id: taskPicker
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: 13
|
||||
font.family: Theme.sansFamily
|
||||
model: {
|
||||
var names = ["No task"]
|
||||
var names = ["Nothing in particular"]
|
||||
for (var i = 0; i < page.openTasks.length; i++)
|
||||
names.push((page.openTasks[i].pinned ? "📌 " : "")
|
||||
names.push((page.openTasks[i].pinned ? "★ " : "")
|
||||
+ page.openTasks[i].title
|
||||
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
|
||||
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
|
||||
return names
|
||||
}
|
||||
onActivated: function (index) {
|
||||
@@ -190,65 +225,76 @@ ScrollView {
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
font.pixelSize: 12
|
||||
opacity: 0.7
|
||||
text: controller.taskTitle !== ""
|
||||
? "This interval is tracked against “" + controller.taskTitle + "”."
|
||||
: "Pick a task so the daily report knows where the time went."
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
spacing: 14
|
||||
Label {
|
||||
text: "🔴 " + (controller.taskCounts.overdue || 0) + " overdue"
|
||||
font.pixelSize: 12
|
||||
text: (controller.taskCounts.overdue || 0) + " overdue"
|
||||
color: (controller.taskCounts.overdue || 0) > 0 ? Theme.clay : Theme.muted
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Label {
|
||||
text: "🟡 " + (controller.taskCounts.today || 0) + " due today"
|
||||
font.pixelSize: 12
|
||||
text: (controller.taskCounts.today || 0) + " due today"
|
||||
color: Theme.muted
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Button { text: "Tasks"; flat: true; onClicked: window.page = 1 }
|
||||
GhostButton { text: "All tasks"; onClicked: window.page = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the day's numbers ------------------------------------------------
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 20
|
||||
Layout.rightMargin: 20
|
||||
Layout.bottomMargin: 20
|
||||
columns: page.width > 760 ? 4 : 2
|
||||
columnSpacing: 12
|
||||
rowSpacing: 12
|
||||
// ---- the day's numbers -------------------------------------------------
|
||||
Panel {
|
||||
padding: 0
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
StatTile {
|
||||
label: "Focus today"
|
||||
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—"
|
||||
caption: controller.completedToday + " pomodoros"
|
||||
}
|
||||
StatTile {
|
||||
label: "Longest stretch"
|
||||
value: controller.stats.today ? controller.stats.today.longest_stretch_minutes + "m" : "—"
|
||||
caption: "unbroken focus"
|
||||
}
|
||||
StatTile {
|
||||
label: "Streak"
|
||||
value: controller.stats.streak ? controller.stats.streak.current + "d" : "—"
|
||||
caption: controller.stats.streak ? "best " + controller.stats.streak.best : ""
|
||||
}
|
||||
StatTile {
|
||||
label: "Blocked"
|
||||
value: controller.stats.today ? controller.stats.today.blocked_attempts : "—"
|
||||
caption: "temptations turned away"
|
||||
Repeater {
|
||||
model: [
|
||||
{ label: "Focus today",
|
||||
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—",
|
||||
caption: controller.completedToday + " intervals" },
|
||||
{ label: "Interruptions",
|
||||
value: controller.stats.today ? "" + controller.stats.today.interruptions : "—",
|
||||
caption: controller.stats.today && controller.stats.today.abandoned > 0
|
||||
? controller.stats.today.abandoned + " abandoned" : "none dropped" },
|
||||
{ label: "Closed today",
|
||||
value: controller.stats.today ? "" + controller.stats.today.tasks_completed_count : "—",
|
||||
caption: "in your own tools" },
|
||||
{ label: "Blocked",
|
||||
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "—",
|
||||
caption: "temptations turned away" }
|
||||
]
|
||||
delegate: RowLayout {
|
||||
required property int index
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Rectangle {
|
||||
visible: index > 0
|
||||
width: 1
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredHeight: 70
|
||||
color: Theme.rule
|
||||
}
|
||||
Stat {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 18
|
||||
label: modelData.label
|
||||
value: modelData.value
|
||||
caption: modelData.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.preferredHeight: 10 }
|
||||
}
|
||||
|
||||
Dialog {
|
||||
@@ -261,9 +307,12 @@ ScrollView {
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
spacing: 12
|
||||
Label {
|
||||
text: controller.clockText + " left. The interval is recorded as abandoned."
|
||||
text: controller.clockText + " left. It will be recorded as abandoned."
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
@@ -272,10 +321,8 @@ ScrollView {
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "What happened? (optional)"
|
||||
}
|
||||
Button {
|
||||
PrimaryButton {
|
||||
text: "Abandon"
|
||||
Material.background: "#e34948"
|
||||
Material.foreground: "white"
|
||||
Layout.alignment: Qt.AlignRight
|
||||
onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() }
|
||||
}
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
// An inline warning strip — used for "the backend is gone" and "the helper is
|
||||
// not installed", the two states where the app cannot do its job.
|
||||
// An inline warning strip for the two states where the app cannot do its job:
|
||||
// the backend is unreachable, or the privileged helper is missing.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import ".."
|
||||
|
||||
Rectangle {
|
||||
id: banner
|
||||
property string text: ""
|
||||
property string actionText: ""
|
||||
signal actionClicked()
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: visible ? row.implicitHeight + 20 : 0
|
||||
color: "#e34948"
|
||||
opacity: 0.92
|
||||
radius: Theme.radius
|
||||
color: Qt.alpha(Theme.clay, 0.12)
|
||||
border.width: 1
|
||||
border.color: Qt.alpha(Theme.clay, 0.4)
|
||||
|
||||
RowLayout {
|
||||
id: row
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
anchors.margins: 12
|
||||
spacing: 12
|
||||
Label {
|
||||
text: banner.text
|
||||
color: "white"
|
||||
color: Theme.ink
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
@@ -29,7 +36,6 @@ Rectangle {
|
||||
visible: banner.actionText !== ""
|
||||
text: banner.actionText
|
||||
flat: true
|
||||
Material.foreground: "white"
|
||||
onClicked: banner.actionClicked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
// A tiny bar chart: magnitude over time, one hue, baseline at zero.
|
||||
//
|
||||
// The ramp step says "did this day clear the goal" without adding a second
|
||||
// colour, and every bar carries its own tooltip, so no bar needs a label.
|
||||
// Pomodoros per day: magnitude over time, so bars, one hue, baseline at zero.
|
||||
// The goal line is the only reference mark; days that cleared it take a darker
|
||||
// step of the same ramp, which keeps "did I hit it" readable without a second
|
||||
// colour.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import ".."
|
||||
|
||||
Item {
|
||||
id: chart
|
||||
property var days: [] // [{date, pomodoros, focus_minutes, goal_met}]
|
||||
property var days: []
|
||||
property int goal: 8
|
||||
property int barHeight: 110
|
||||
property int barHeight: 108
|
||||
|
||||
implicitHeight: barHeight + 18
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: barHeight + 16
|
||||
|
||||
readonly property int maxValue: {
|
||||
var top = goal
|
||||
@@ -25,8 +28,7 @@ Item {
|
||||
anchors.right: parent.right
|
||||
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
|
||||
height: 1
|
||||
color: Material.hintTextColor
|
||||
opacity: 0.45
|
||||
color: Theme.rule
|
||||
visible: chart.goal > 0
|
||||
}
|
||||
|
||||
@@ -48,15 +50,16 @@ Item {
|
||||
width: parent.width
|
||||
height: Math.max(modelData.pomodoros > 0
|
||||
? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2)
|
||||
radius: 4
|
||||
color: modelData.pomodoros === 0 ? Material.dividerColor
|
||||
: (modelData.goal_met ? "#256abf" : "#6da7ec")
|
||||
radius: 3
|
||||
color: modelData.pomodoros === 0
|
||||
? Theme.rule
|
||||
: (modelData.goal_met ? Theme.sequential[4] : Theme.sequential[2])
|
||||
|
||||
ToolTip.visible: hover.hovered
|
||||
ToolTip.text: modelData.date + ": " + modelData.pomodoros
|
||||
ToolTip.text: modelData.date + " · " + modelData.pomodoros
|
||||
+ " pomodoros · " + modelData.focus_minutes + " min"
|
||||
HoverHandler { id: hover }
|
||||
Behavior on height { NumberAnimation { duration: 250 } }
|
||||
Behavior on height { NumberAnimation { duration: 220 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,12 +71,12 @@ Item {
|
||||
anchors.right: parent.right
|
||||
Label {
|
||||
text: chart.days.length ? chart.days[0].date.substring(5) : ""
|
||||
font.pixelSize: 10; opacity: 0.6
|
||||
font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
|
||||
font.pixelSize: 10; opacity: 0.6
|
||||
font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// A Material surface with a title. Used everywhere instead of bare Panes so the
|
||||
// pages share one radius, one padding and one elevation.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
Pane {
|
||||
id: card
|
||||
property string title: ""
|
||||
property string subtitle: ""
|
||||
default property alias content: inner.data
|
||||
|
||||
Material.elevation: 1
|
||||
Material.roundedScale: Material.LargeScale
|
||||
padding: 18
|
||||
Layout.fillWidth: true
|
||||
|
||||
background: Rectangle {
|
||||
radius: 18
|
||||
color: Material.dialogColor
|
||||
border.width: 1
|
||||
border.color: Material.dividerColor
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
visible: card.title !== ""
|
||||
Layout.fillWidth: true
|
||||
Label { text: card.title; font.pixelSize: 16; font.weight: Font.DemiBold }
|
||||
Label {
|
||||
text: card.subtitle
|
||||
visible: card.subtitle !== ""
|
||||
font.pixelSize: 12
|
||||
opacity: 0.7
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: inner
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// The outlined button, for everything that is not the primary action.
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
import ".."
|
||||
|
||||
Button {
|
||||
id: control
|
||||
implicitHeight: 34
|
||||
leftPadding: 14
|
||||
rightPadding: 14
|
||||
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: control.hovered ? Qt.alpha(Theme.ember, 0.07) : "transparent"
|
||||
border.width: 1
|
||||
border.color: control.hovered ? Theme.ember : Theme.rule
|
||||
Behavior on color { ColorAnimation { duration: 110 } }
|
||||
}
|
||||
contentItem: Label {
|
||||
text: control.text
|
||||
color: control.enabled ? Theme.ink : Theme.faint
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.sansFamily
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Eyebrow, title, actions, rule — the same opening every page uses.
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import ".."
|
||||
|
||||
ColumnLayout {
|
||||
property string eyebrow: ""
|
||||
property string title: ""
|
||||
property string caption: ""
|
||||
default property alias actions: actionRow.data
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
visible: eyebrow !== ""
|
||||
text: eyebrow.toUpperCase()
|
||||
color: Theme.muted
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 1.3
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Label {
|
||||
text: title
|
||||
color: Theme.ink
|
||||
font.pixelSize: 27
|
||||
font.family: Theme.displayFamily
|
||||
font.letterSpacing: -0.6
|
||||
}
|
||||
Label {
|
||||
visible: caption !== ""
|
||||
text: caption
|
||||
color: Theme.muted
|
||||
font.pixelSize: 13
|
||||
font.family: Theme.sansFamily
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout { id: actionRow; spacing: 8; Layout.alignment: Qt.AlignRight | Qt.AlignVCenter }
|
||||
}
|
||||
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// A surface with a hairline border. No shadow, no tint — the border does the
|
||||
// separating, which is what keeps a page of panels calm.
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
import ".."
|
||||
|
||||
Rectangle {
|
||||
id: panel
|
||||
default property alias content: inner.data
|
||||
property int padding: Theme.pad
|
||||
property alias spacing: inner.spacing
|
||||
|
||||
color: Theme.surface
|
||||
radius: Theme.radius
|
||||
border.width: 1
|
||||
border.color: Theme.rule
|
||||
implicitHeight: inner.implicitHeight + padding * 2
|
||||
implicitWidth: inner.implicitWidth + padding * 2
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
id: inner
|
||||
anchors.fill: parent
|
||||
anchors.margins: panel.padding
|
||||
spacing: 10
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,28 @@
|
||||
// The filled Material button, painted explicitly.
|
||||
//
|
||||
// Relying on `highlighted: true` leaves the fill to the style, and under some
|
||||
// renderers that fill never appears — white text on a white surface. The
|
||||
// primary action on a page is exactly the thing that must never be invisible,
|
||||
// so it draws its own pill.
|
||||
// The filled button: one per view, painted explicitly so no style can lose it.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
|
||||
import ".."
|
||||
|
||||
Button {
|
||||
id: control
|
||||
|
||||
Material.foreground: "white"
|
||||
font.weight: Font.Medium
|
||||
leftPadding: 20
|
||||
rightPadding: 20
|
||||
implicitHeight: 40
|
||||
implicitHeight: 34
|
||||
leftPadding: 16
|
||||
rightPadding: 16
|
||||
|
||||
background: Rectangle {
|
||||
radius: height / 2
|
||||
color: control.enabled ? Material.accent : Material.buttonDisabledColor
|
||||
radius: 8
|
||||
color: control.enabled ? Theme.ember : Theme.rule
|
||||
opacity: control.pressed ? 0.82 : (control.hovered ? 0.92 : 1)
|
||||
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||
Behavior on opacity { NumberAnimation { duration: 110 } }
|
||||
}
|
||||
|
||||
contentItem: Label {
|
||||
text: control.text
|
||||
font: control.font
|
||||
color: control.enabled ? "white" : Material.hintTextColor
|
||||
color: control.enabled ? "white" : Theme.faint
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.sansFamily
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// An eyebrow label with a rule running to the right edge.
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import ".."
|
||||
|
||||
RowLayout {
|
||||
property string text: ""
|
||||
property string trailing: ""
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Label {
|
||||
text: parent.text.toUpperCase()
|
||||
color: Theme.muted
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 1.3
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule }
|
||||
Label {
|
||||
visible: parent.trailing !== ""
|
||||
text: parent.trailing
|
||||
color: Theme.faint
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Transient messages. Material's own popup, anchored to the bottom.
|
||||
// Transient messages, bottom centre, on an ink slab.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
|
||||
import ".."
|
||||
|
||||
Popup {
|
||||
id: snackbar
|
||||
@@ -13,19 +15,23 @@ Popup {
|
||||
}
|
||||
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: parent.height - height - 72
|
||||
width: Math.min(parent.width - 48, Math.max(260, label.implicitWidth + 48))
|
||||
padding: 16
|
||||
y: parent.height - height - 28
|
||||
width: Math.min(parent.width - 48, Math.max(240, label.implicitWidth + 36))
|
||||
padding: 14
|
||||
modal: false
|
||||
focus: false
|
||||
closePolicy: Popup.NoAutoClose
|
||||
Material.elevation: 6
|
||||
Material.roundedScale: Material.SmallScale
|
||||
|
||||
Label {
|
||||
background: Rectangle {
|
||||
color: Theme.ink
|
||||
radius: 10
|
||||
}
|
||||
contentItem: Label {
|
||||
id: label
|
||||
anchors.fill: parent
|
||||
text: snackbar.message
|
||||
color: Theme.paper
|
||||
font.pixelSize: 13
|
||||
font.family: Theme.sansFamily
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// A number and its name. The figure is set in the display serif, which is what
|
||||
// makes a screen of statistics read like a page rather than a readout.
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import ".."
|
||||
|
||||
ColumnLayout {
|
||||
property string label: ""
|
||||
property string value: "—"
|
||||
property string caption: ""
|
||||
property color accent: Theme.ink
|
||||
property int size: 30
|
||||
|
||||
spacing: 2
|
||||
Layout.fillWidth: true
|
||||
|
||||
Label {
|
||||
text: label.toUpperCase()
|
||||
color: Theme.muted
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 1.2
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
}
|
||||
Label {
|
||||
text: value
|
||||
color: accent
|
||||
font.pixelSize: size
|
||||
font.family: Theme.displayFamily
|
||||
font.letterSpacing: -0.5
|
||||
}
|
||||
Label {
|
||||
visible: caption !== ""
|
||||
text: caption
|
||||
color: Theme.faint
|
||||
font.pixelSize: 11
|
||||
font.family: Theme.sansFamily
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// One number, read at a glance. Not a chart.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
Pane {
|
||||
property string label: ""
|
||||
property string value: "—"
|
||||
property string caption: ""
|
||||
property color accent: Material.foreground
|
||||
|
||||
Material.elevation: 1
|
||||
Material.roundedScale: Material.LargeScale
|
||||
padding: 16
|
||||
Layout.fillWidth: true
|
||||
|
||||
background: Rectangle {
|
||||
radius: 16
|
||||
color: Material.dialogColor
|
||||
border.width: 1
|
||||
border.color: Material.dividerColor
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 2
|
||||
Label { text: label.toUpperCase(); font.pixelSize: 10; font.letterSpacing: 1; opacity: 0.6 }
|
||||
Label { text: value; font.pixelSize: 26; font.weight: Font.Light; color: accent }
|
||||
Label {
|
||||
text: caption
|
||||
visible: caption !== ""
|
||||
font.pixelSize: 11
|
||||
opacity: 0.7
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// The little "what is the timer doing" chip, shown in the rail and the toolbar.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
|
||||
Rectangle {
|
||||
property bool running: false
|
||||
property bool paused: false
|
||||
property bool connected: true
|
||||
property string text: ""
|
||||
property color accent: "#2a78d6"
|
||||
|
||||
implicitWidth: Math.max(78, label.implicitWidth + 28)
|
||||
implicitHeight: 32
|
||||
radius: 16
|
||||
color: !connected ? "#e34948" : (running ? accent : "transparent")
|
||||
opacity: paused ? 0.6 : 1
|
||||
border.width: running || !connected ? 0 : 1
|
||||
border.color: Material.hintTextColor
|
||||
|
||||
Label {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: parent.text
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
font.family: "Roboto Mono, monospace"
|
||||
color: (parent.running || !parent.connected) ? "white" : Material.foreground
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,32 @@
|
||||
// The countdown ring, drawn with Canvas.
|
||||
// The dial.
|
||||
//
|
||||
// A hero number inside a progress arc: the number is what you read, the arc is
|
||||
// the glance. The arc colour is the only thing that says focus vs break.
|
||||
// A hairline ring with one tick per minute inside it: the ticks light up as the
|
||||
// minutes are spent, so the dial reads as "how much is left" at a glance and as
|
||||
// "how long was this?" on a second look. The time is the hero, set in the
|
||||
// display serif.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Controls
|
||||
|
||||
import ".."
|
||||
|
||||
Item {
|
||||
id: dial
|
||||
property real progress: 0 // 0..1 elapsed
|
||||
property real progress: 0 // 0..1 elapsed
|
||||
property string clockText: "00:00"
|
||||
property string label: ""
|
||||
property string sublabel: ""
|
||||
property color accent: "#2a78d6"
|
||||
property color accent: Theme.work
|
||||
property bool paused: false
|
||||
property bool idle: false
|
||||
property int minutes: 25
|
||||
|
||||
implicitWidth: 260
|
||||
implicitHeight: 260
|
||||
implicitWidth: 280
|
||||
implicitHeight: 280
|
||||
|
||||
onProgressChanged: canvas.requestPaint()
|
||||
onAccentChanged: canvas.requestPaint()
|
||||
onPausedChanged: canvas.requestPaint()
|
||||
onIdleChanged: canvas.requestPaint()
|
||||
|
||||
Canvas {
|
||||
id: canvas
|
||||
@@ -29,59 +36,85 @@ Item {
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
var centre = Math.min(width, height) / 2
|
||||
var stroke = Math.max(10, centre * 0.11)
|
||||
var radius = centre - stroke
|
||||
|
||||
ctx.lineCap = "round"
|
||||
ctx.lineWidth = stroke
|
||||
var c = Math.min(width, height) / 2
|
||||
var radius = c - 26
|
||||
|
||||
// The ring.
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.strokeStyle = Theme.rule
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.18)
|
||||
ctx.arc(centre, centre, radius, 0, Math.PI * 2)
|
||||
ctx.arc(c, c, radius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
|
||||
if (remaining > 0) {
|
||||
// Minute ticks, inside the ring.
|
||||
var count = dial.minutes <= 60 ? Math.max(1, dial.minutes) : Math.round(dial.minutes / 5)
|
||||
var spent = Math.floor(count * Math.max(0, Math.min(1, dial.progress)))
|
||||
for (var i = 0; i < count; i++) {
|
||||
var angle = (i / count) * Math.PI * 2 - Math.PI / 2
|
||||
var major = i % 5 === 0
|
||||
var outer = radius - 7
|
||||
var inner = outer - (major ? 9 : 5)
|
||||
ctx.beginPath()
|
||||
ctx.globalAlpha = dial.paused ? 0.45 : 1
|
||||
ctx.strokeStyle = dial.accent
|
||||
ctx.arc(centre, centre, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
|
||||
ctx.lineWidth = major ? 1.4 : 1
|
||||
ctx.strokeStyle = (i < spent && !dial.idle) ? dial.accent : Theme.rule
|
||||
ctx.moveTo(c + inner * Math.cos(angle), c + inner * Math.sin(angle))
|
||||
ctx.lineTo(c + outer * Math.cos(angle), c + outer * Math.sin(angle))
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// The remaining arc.
|
||||
if (!dial.idle) {
|
||||
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
|
||||
if (remaining > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.lineWidth = 2.5
|
||||
ctx.lineCap = "round"
|
||||
ctx.globalAlpha = dial.paused ? 0.4 : 1
|
||||
ctx.strokeStyle = dial.accent
|
||||
ctx.arc(c, c, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
width: parent.width * 0.72
|
||||
spacing: 6
|
||||
width: parent.width * 0.7
|
||||
|
||||
Label {
|
||||
width: parent.width
|
||||
text: dial.label.toUpperCase()
|
||||
font.pixelSize: 11
|
||||
font.letterSpacing: 1.5
|
||||
opacity: 0.65
|
||||
color: dial.idle ? Theme.muted : dial.accent
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 1.4
|
||||
font.weight: Font.DemiBold
|
||||
font.family: Theme.sansFamily
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Label {
|
||||
width: parent.width
|
||||
text: dial.clockText
|
||||
font.pixelSize: Math.round(dial.width * 0.2)
|
||||
font.weight: Font.Light
|
||||
font.family: "Roboto Mono, monospace"
|
||||
opacity: dial.paused ? 0.6 : 1
|
||||
color: Theme.ink
|
||||
opacity: dial.paused ? 0.55 : 1
|
||||
font.pixelSize: Math.round(dial.width * 0.21)
|
||||
font.family: Theme.displayFamily
|
||||
font.letterSpacing: -1.5
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
Behavior on opacity { NumberAnimation { duration: 180 } }
|
||||
}
|
||||
Label {
|
||||
width: parent.width
|
||||
text: dial.sublabel
|
||||
visible: dial.sublabel !== ""
|
||||
font.pixelSize: 13
|
||||
opacity: 0.75
|
||||
elide: Text.ElideRight
|
||||
text: dial.sublabel
|
||||
color: Theme.muted
|
||||
font.pixelSize: 12
|
||||
font.family: Theme.sansFamily
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Makes `Theme` a singleton for every file that does `import "."`.
|
||||
singleton Theme 1.0 Theme.qml
|
||||
@@ -82,6 +82,9 @@ class Tray(QObject):
|
||||
controller.stateChanged.connect(self.refresh)
|
||||
controller.connectionChanged.connect(self.refresh)
|
||||
controller.blockerChanged.connect(self.refresh)
|
||||
controller.miniChanged.connect(self.refresh)
|
||||
controller.settingsChanged.connect(self.refresh)
|
||||
controller.configChanged.connect(self.refresh)
|
||||
self.refresh()
|
||||
|
||||
def show(self) -> None:
|
||||
@@ -140,6 +143,18 @@ class Tray(QObject):
|
||||
self._menu.addAction(refresh)
|
||||
|
||||
self._menu.addSeparator()
|
||||
self._action_mini = QAction("Mini timer", self._menu)
|
||||
self._action_mini.setCheckable(True)
|
||||
self._action_mini.triggered.connect(lambda: controller.toggleMini())
|
||||
self._menu.addAction(self._action_mini)
|
||||
|
||||
self._action_view_mode = QAction("View-only mode (timer off)", self._menu)
|
||||
self._action_view_mode.setCheckable(True)
|
||||
self._action_view_mode.triggered.connect(
|
||||
lambda: controller.setTimerEnabled(not controller.timerEnabled)
|
||||
)
|
||||
self._menu.addAction(self._action_view_mode)
|
||||
|
||||
show = QAction("Open Kadence", self._menu)
|
||||
show.triggered.connect(self.showRequested.emit)
|
||||
self._menu.addAction(show)
|
||||
@@ -157,22 +172,32 @@ class Tray(QObject):
|
||||
controller = self._controller
|
||||
running = controller.running
|
||||
paused = controller.status == "paused"
|
||||
timer_enabled = controller.timerEnabled
|
||||
|
||||
self._icon.setIcon(
|
||||
render_icon(controller.progress, controller.kind, running, controller.connected, paused)
|
||||
render_icon(
|
||||
controller.progress,
|
||||
controller.kind,
|
||||
running if timer_enabled else False,
|
||||
controller.connected,
|
||||
paused if timer_enabled else False,
|
||||
)
|
||||
)
|
||||
status = controller.statusLine()
|
||||
blocker = " · 🛡 blocking" if controller.blockerActive else ""
|
||||
self._icon.setToolTip(f"{status}{blocker}")
|
||||
self._action_status.setText(status)
|
||||
|
||||
self._action_start.setVisible(not running)
|
||||
self._action_break.setVisible(not running)
|
||||
self._action_pause.setVisible(running and not paused)
|
||||
self._action_resume.setVisible(running and paused)
|
||||
self._action_done.setVisible(running)
|
||||
self._action_extend.setVisible(running)
|
||||
self._action_abandon.setVisible(running)
|
||||
self._action_start.setVisible(timer_enabled and not running)
|
||||
self._action_break.setVisible(timer_enabled and not running)
|
||||
self._action_pause.setVisible(timer_enabled and running and not paused)
|
||||
self._action_resume.setVisible(timer_enabled and running and paused)
|
||||
self._action_done.setVisible(timer_enabled and running)
|
||||
self._action_extend.setVisible(timer_enabled and running)
|
||||
self._action_abandon.setVisible(timer_enabled and running)
|
||||
self._action_mini.setVisible(timer_enabled)
|
||||
self._action_mini.setChecked(controller.miniVisible)
|
||||
self._action_view_mode.setChecked(not timer_enabled)
|
||||
|
||||
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
|
||||
# Left click toggles the window; middle click toggles the timer, which is
|
||||
|
||||
@@ -129,7 +129,7 @@ class ApiWorker(QObject):
|
||||
"extend": lambda: self._api.extend(int(args.get("minutes", 5))),
|
||||
"interrupt": self._api.interrupt,
|
||||
"attach_task": lambda: self._api.attach_task(args.get("task_id")),
|
||||
"task_done": lambda: self._api.task_done(int(args["task_id"]), bool(args.get("done", True))),
|
||||
"patch_task": lambda: self._api.patch_task(int(args["task_id"]), dict(args.get("patch", {}))),
|
||||
"create_task": lambda: self._api.create_task(str(args["title"]), args.get("due_date")),
|
||||
"refresh_sources": self._api.refresh_tasks,
|
||||
"send_report": self._api.send_report,
|
||||
@@ -156,7 +156,7 @@ class ApiWorker(QObject):
|
||||
return
|
||||
|
||||
self.commandDone.emit(name, result if isinstance(result, dict) else {})
|
||||
if name in ("refresh_sources", "task_done", "create_task"):
|
||||
if name in ("refresh_sources", "patch_task", "create_task"):
|
||||
self.refresh_tasks()
|
||||
if name == "patch_settings":
|
||||
self.refresh_settings()
|
||||
|
||||
Reference in new issue
Block a user