This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
// What the blocker is doing right now, and what it will do.
//
// 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.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
ColumnLayout {
width: page.availableWidth
spacing: 16
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: ""
}
Banner {
Layout.fillWidth: true
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."
Repeater {
model: controller.stats.today ? controller.stats.today.blocked_top : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Label { text: modelData.target; Layout.fillWidth: true }
Label { text: "× " + modelData.count; font.weight: Font.DemiBold }
}
}
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
}
}
}
}
Component.onCompleted: controller.loadSettings()
}
+234
View File
@@ -0,0 +1,234 @@
// 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.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import QtQuick.Window
import "components"
ApplicationWindow {
id: window
width: 1100
height: 760
minimumWidth: 420
minimumHeight: 560
visible: false
title: controller.running
? controller.clockText + " · " + controller.kindLabel + " — Kadence"
: "Kadence"
Material.theme: Material.System
Material.primary: "#6750A4"
Material.accent: "#6750A4"
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
window.hide()
}
}
Connections {
target: controller
function onToast(message) { snackbar.show(message) }
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: "Ctrl+R"; onActivated: controller.refreshSources() }
Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 }
Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 }
Shortcut { sequence: "Ctrl+3"; onActivated: window.page = 2 }
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)
}
}
}
RowLayout {
anchors.fill: parent
spacing: 0
// ---- navigation rail ------------------------------------------------
Pane {
visible: window.wide
Layout.fillHeight: true
Layout.preferredWidth: 212
Material.elevation: 0
padding: 12
ColumnLayout {
anchors.fill: parent
spacing: 4
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 {
spacing: 0
Label { text: "Kadence"; font.pixelSize: 17; font.weight: Font.DemiBold }
Label {
text: controller.connected ? "connected" : "offline"
font.pixelSize: 11
opacity: 0.6
}
}
}
Repeater {
model: [
{ label: "Timer", icon: "⏱" },
{ label: "Tasks", icon: "✓" },
{ label: "Blocker", icon: "🛡" },
{ label: "Report", icon: "📊" },
{ label: "Settings", icon: "⚙" }
]
delegate: ItemDelegate {
required property int index
required property var modelData
Layout.fillWidth: true
height: 44
highlighted: window.page === index
onClicked: window.page = index
background: Rectangle {
radius: 22
color: parent.highlighted ? Material.accent
: (parent.hovered ? Material.listHighlightColor : "transparent")
opacity: parent.highlighted ? 0.18 : 1
}
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
}
}
}
}
Item { Layout.fillHeight: true }
StatusPill {
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)
}
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
}
}
}
// ---- pages ----------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
Banner {
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
}
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: window.page
TimerPage {}
TasksPage {}
BlockerPage {}
ReportPage {}
SettingsPage {}
}
}
}
// ---- 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 }
}
+180
View File
@@ -0,0 +1,180 @@
// The day, summarised — the same payload the Telegram message is built from.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var payload: controller.report.report ? controller.report.report : null
readonly property var metrics: payload ? payload.metrics : null
ColumnLayout {
width: page.availableWidth
spacing: 16
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."
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 {
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
}
}
}
}
}
}
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 {
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
}
}
}
Component.onCompleted: controller.loadReport()
}
@@ -0,0 +1,391 @@
// 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.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var pomodoro: controller.settings.pomodoro ? controller.settings.pomodoro : null
readonly property var telegram: controller.settings.notifications
? controller.settings.notifications.telegram : null
readonly property var report: controller.settings.notifications
? controller.settings.notifications.report : null
function patchPomodoro(key, value) {
var patch = {}; patch[key] = value
controller.patchSettings({ pomodoro: patch })
}
function patchTelegram(key, value) {
var patch = {}; patch[key] = value
controller.patchSettings({ notifications: { telegram: patch } })
}
ColumnLayout {
width: page.availableWidth
spacing: 16
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 {
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)
}
}
}
RowLayout {
Label { text: "Poll every"; font.pixelSize: 12 }
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)
}
}
}
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)
}
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."
}
}
}
// ---- desktop integration ----------------------------------------
Card {
title: "This computer"
subtitle: "Native desktop features. Stored locally, not on the server."
Flow {
Layout.fillWidth: true
spacing: 16
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."
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Pause after"; font.pixelSize: 12 }
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)
}
}
}
// ---- 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: 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)
}
}
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
}
}
}
}
Component.onCompleted: controller.loadSettings()
}
+180
View File
@@ -0,0 +1,180 @@
// Everything you owe, from every source, grouped by deadline.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
Item {
id: page
function bucketTasks(bucket) {
var out = []
for (var i = 0; i < controller.tasks.length; i++) {
var task = controller.tasks[i]
if (!task.is_done && task.bucket === bucket) out.push(task)
}
return out
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 12
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() }
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
contentWidth: availableWidth
clip: true
ColumnLayout {
width: parent.parent.availableWidth
spacing: 12
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 {
required property var modelData
property var items: page.bucketTasks(modelData.key)
visible: items.length > 0
title: modelData.mark + " " + modelData.label + " (" + items.length + ")"
Repeater {
model: parent.parent.items
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 10
CheckBox {
checked: modelData.is_done
onToggled: controller.setTaskDone(modelData.id, checked)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: modelData.title
font.pixelSize: 14
elide: Text.ElideRight
Layout.fillWidth: true
}
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
}
}
}
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"
}
}
}
}
}
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
}
}
}
}
}
Dialog {
id: addDialog
anchors.centerIn: Overlay.overlay
width: 380
modal: true
title: "New task"
standardButtons: Dialog.Cancel
ColumnLayout {
anchors.fill: parent
spacing: 10
TextField {
id: titleField
Layout.fillWidth: true
placeholderText: "What needs doing?"
}
TextField {
id: dueField
Layout.fillWidth: true
placeholderText: "Due date (YYYY-MM-DD, optional)"
inputMask: ""
}
PrimaryButton {
text: "Add"
enabled: titleField.text.trim() !== ""
Layout.alignment: Qt.AlignRight
onClicked: {
controller.createTask(titleField.text.trim(), dueField.text.trim())
titleField.text = ""; dueField.text = ""
addDialog.close()
}
}
}
}
}
+284
View File
@@ -0,0 +1,284 @@
// The timer page: the dial, the controls, and what you are working on.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
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
}
ColumnLayout {
width: page.availableWidth
spacing: 16
anchors.margins: 20
GridLayout {
Layout.fillWidth: true
Layout.margins: 20
columns: page.width > 900 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
// ---- dial + controls --------------------------------------------
Card {
Layout.fillWidth: true
Layout.preferredWidth: 1
TimerDial {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Math.min(300, page.availableWidth * 0.7)
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)
paused: controller.status === "paused"
}
Flow {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 8
spacing: 8
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"
onClicked: controller.extend(5)
ToolTip.visible: hovered
ToolTip.text: "Add five minutes"
}
Button {
visible: controller.running
text: "🔔"
onClicked: controller.interrupt()
ToolTip.visible: hovered
ToolTip.text: "Count an interruption without stopping"
}
Button {
visible: controller.running
text: "⏹"
flat: true
Material.foreground: "#e34948"
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"
font.pixelSize: 11
opacity: 0.6
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
font.pixelSize: 10
opacity: 0.45
}
}
// ---- today + task picker ----------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
spacing: 16
Card {
title: "Today"
subtitle: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
+ " before a long break"
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: controller.completedToday
font.pixelSize: 34
font.weight: Font.Light
}
Label {
text: "/ " + controller.goal + " pomodoros"
font.pixelSize: 16
opacity: 0.7
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 6
}
Item { Layout.fillWidth: true }
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: Math.max(controller.goal, 1)
value: Math.min(controller.completedToday, controller.goal)
}
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
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"
border.width: index < controller.completedToday ? 0 : 1
border.color: Material.hintTextColor
}
}
}
}
Card {
title: "What are you working on?"
ComboBox {
id: taskPicker
Layout.fillWidth: true
model: {
var names = ["No task"]
for (var i = 0; i < page.openTasks.length; i++)
names.push((page.openTasks[i].pinned ? "📌 " : "")
+ page.openTasks[i].title
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
return names
}
onActivated: function (index) {
controller.attachTask(index === 0 ? -1 : page.openTasks[index - 1].id)
}
}
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
Label {
text: "🔴 " + (controller.taskCounts.overdue || 0) + " overdue"
font.pixelSize: 12
}
Label {
text: "🟡 " + (controller.taskCounts.today || 0) + " due today"
font.pixelSize: 12
}
Item { Layout.fillWidth: true }
Button { text: "Tasks"; flat: true; 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
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"
}
}
}
Dialog {
id: abandonDialog
anchors.centerIn: Overlay.overlay
width: 380
modal: true
title: "Abandon this interval?"
standardButtons: Dialog.Cancel
ColumnLayout {
anchors.fill: parent
spacing: 10
Label {
text: controller.clockText + " left. The interval is recorded as abandoned."
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
TextField {
id: reasonField
Layout.fillWidth: true
placeholderText: "What happened? (optional)"
}
Button {
text: "Abandon"
Material.background: "#e34948"
Material.foreground: "white"
Layout.alignment: Qt.AlignRight
onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() }
}
}
}
}
@@ -0,0 +1,36 @@
// 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.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Rectangle {
id: banner
property string text: ""
property string actionText: ""
signal actionClicked()
implicitHeight: visible ? row.implicitHeight + 20 : 0
color: "#e34948"
opacity: 0.92
RowLayout {
id: row
anchors.fill: parent
anchors.margins: 10
spacing: 12
Label {
text: banner.text
color: "white"
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Button {
visible: banner.actionText !== ""
text: banner.actionText
flat: true
Material.foreground: "white"
onClicked: banner.actionClicked()
}
}
}
@@ -0,0 +1,79 @@
// 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.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Item {
id: chart
property var days: [] // [{date, pomodoros, focus_minutes, goal_met}]
property int goal: 8
property int barHeight: 110
implicitHeight: barHeight + 18
readonly property int maxValue: {
var top = goal
for (var i = 0; i < days.length; i++) top = Math.max(top, days[i].pomodoros)
return Math.max(top, 1)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
height: 1
color: Material.hintTextColor
opacity: 0.45
visible: chart.goal > 0
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
height: chart.barHeight
spacing: 2
Repeater {
model: chart.days
delegate: Item {
required property var modelData
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
anchors.bottom: parent.bottom
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")
ToolTip.visible: hover.hovered
ToolTip.text: modelData.date + ": " + modelData.pomodoros
+ " pomodoros · " + modelData.focus_minutes + " min"
HoverHandler { id: hover }
Behavior on height { NumberAnimation { duration: 250 } }
}
}
}
}
RowLayout {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
Label {
text: chart.days.length ? chart.days[0].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6
}
Item { Layout.fillWidth: true }
Label {
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6
}
}
}
@@ -0,0 +1,51 @@
// 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,34 @@
// 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.
import QtQuick
import QtQuick.Controls.Material
Button {
id: control
Material.foreground: "white"
font.weight: Font.Medium
leftPadding: 20
rightPadding: 20
implicitHeight: 40
background: Rectangle {
radius: height / 2
color: control.enabled ? Material.accent : Material.buttonDisabledColor
opacity: control.pressed ? 0.82 : (control.hovered ? 0.92 : 1)
Behavior on opacity { NumberAnimation { duration: 120 } }
}
contentItem: Label {
text: control.text
font: control.font
color: control.enabled ? "white" : Material.hintTextColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
@@ -0,0 +1,33 @@
// Transient messages. Material's own popup, anchored to the bottom.
import QtQuick
import QtQuick.Controls.Material
Popup {
id: snackbar
property string message: ""
function show(text) {
message = text
open()
timer.restart()
}
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
modal: false
focus: false
closePolicy: Popup.NoAutoClose
Material.elevation: 6
Material.roundedScale: Material.SmallScale
Label {
id: label
anchors.fill: parent
text: snackbar.message
wrapMode: Text.WordWrap
}
Timer { id: timer; interval: 5000; onTriggered: snackbar.close() }
}
@@ -0,0 +1,38 @@
// 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
}
}
}
@@ -0,0 +1,29 @@
// 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
}
}
@@ -0,0 +1,87 @@
// The countdown ring, drawn with Canvas.
//
// 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.
import QtQuick
import QtQuick.Controls.Material
Item {
id: dial
property real progress: 0 // 0..1 elapsed
property string clockText: "00:00"
property string label: ""
property string sublabel: ""
property color accent: "#2a78d6"
property bool paused: false
implicitWidth: 260
implicitHeight: 260
onProgressChanged: canvas.requestPaint()
onAccentChanged: canvas.requestPaint()
onPausedChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
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
ctx.beginPath()
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.18)
ctx.arc(centre, centre, radius, 0, Math.PI * 2)
ctx.stroke()
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
if (remaining > 0) {
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.stroke()
}
}
}
Column {
anchors.centerIn: parent
spacing: 4
width: parent.width * 0.72
Label {
width: parent.width
text: dial.label.toUpperCase()
font.pixelSize: 11
font.letterSpacing: 1.5
opacity: 0.65
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
horizontalAlignment: Text.AlignHCenter
}
Label {
width: parent.width
text: dial.sublabel
visible: dial.sublabel !== ""
font.pixelSize: 13
opacity: 0.75
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
}
}