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:
timka123 committed 2026-10-18 23:57:36 +03:00
1 parent 17a0a3c4c9
commit 77b26757bf
84 files changed
+4501 -2682

No files matched your search

+292 -159
View File
@@ -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()
+161 -135
View File
@@ -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.
}
+241
View File
@@ -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 } }
}
}
}
}
+190 -141
View File
@@ -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()
+446 -324
View File
@@ -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()
+163 -109
View File
@@ -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"
+73
View File
@@ -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"
}
}
+194 -147
View File
@@ -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
}
}
}
+2
View File
@@ -0,0 +1,2 @@
# Makes `Theme` a singleton for every file that does `import "."`.
singleton Theme 1.0 Theme.qml