init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,191 @@
|
||||
// What the blocker is doing right now, and what it will do.
|
||||
//
|
||||
// The rules themselves are server-side (edit them in the web UI or below); this
|
||||
// page is about *enforcement on this machine* — the helper, the hosts file and
|
||||
// the process watcher.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "components"
|
||||
|
||||
ScrollView {
|
||||
id: page
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth
|
||||
spacing: 16
|
||||
|
||||
Banner {
|
||||
Layout.fillWidth: true
|
||||
visible: !controller.helperInstalled
|
||||
text: "The privileged helper is not installed, so websites cannot be blocked. "
|
||||
+ "Run: sudo desktop/helper/install.sh"
|
||||
actionText: ""
|
||||
}
|
||||
|
||||
Banner {
|
||||
Layout.fillWidth: true
|
||||
visible: controller.blockerError !== ""
|
||||
text: controller.blockerError
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
spacing: 16
|
||||
|
||||
Card {
|
||||
title: controller.blockerActive ? "🛡 Blocking is ON" : "Blocking is off"
|
||||
subtitle: controller.blockerReason
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 20
|
||||
StatTile {
|
||||
label: "Domains"
|
||||
value: controller.blockedDomains.length
|
||||
caption: "redirected to 127.0.0.1"
|
||||
}
|
||||
StatTile {
|
||||
label: "Apps"
|
||||
value: controller.blockedApps.length
|
||||
caption: "closed on sight"
|
||||
}
|
||||
StatTile {
|
||||
label: "Attempts today"
|
||||
value: controller.stats.today ? controller.stats.today.blocked_attempts : 0
|
||||
caption: "and counting"
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Switch {
|
||||
text: "Enforce on this machine"
|
||||
checked: controller.config.enforce_blocking
|
||||
onToggled: controller.setConfig("enforce_blocking", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Websites"
|
||||
checked: controller.config.block_hosts_file
|
||||
onToggled: controller.setConfig("block_hosts_file", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Apps"
|
||||
checked: controller.config.block_apps
|
||||
onToggled: controller.setConfig("block_apps", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Blocked websites"
|
||||
subtitle: "One hostname per line. `www.` and `m.` are added automatically."
|
||||
|
||||
TextArea {
|
||||
id: domainsArea
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 140
|
||||
wrapMode: TextArea.NoWrap
|
||||
font.family: "Roboto Mono, monospace"
|
||||
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("\n")
|
||||
placeholderText: "youtube.com\nreddit.com\ntwitter.com"
|
||||
}
|
||||
RowLayout {
|
||||
PrimaryButton {
|
||||
text: "Save websites"
|
||||
onClicked: {
|
||||
var list = domainsArea.text.split("\n").map(function (s) { return s.trim() })
|
||||
.filter(function (s) { return s.length > 0 })
|
||||
controller.patchSettings({ blocker: { blocked_domains: list } })
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Blocked applications"
|
||||
subtitle: "Process names, matched loosely: `steam` also catches `steamwebhelper`."
|
||||
|
||||
TextArea {
|
||||
id: appsArea
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: 100
|
||||
wrapMode: TextArea.NoWrap
|
||||
font.family: "Roboto Mono, monospace"
|
||||
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("\n")
|
||||
placeholderText: "steam\ndiscord\ntelegram-desktop"
|
||||
}
|
||||
RowLayout {
|
||||
PrimaryButton {
|
||||
text: "Save applications"
|
||||
onClicked: {
|
||||
var list = appsArea.text.split("\n").map(function (s) { return s.trim() })
|
||||
.filter(function (s) { return s.length > 0 })
|
||||
controller.patchSettings({ blocker: { blocked_apps: list } })
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "When to block"
|
||||
subtitle: "Server-side, so every device agrees."
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Switch {
|
||||
text: "Blocking enabled"
|
||||
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
|
||||
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
|
||||
}
|
||||
ComboBox {
|
||||
Layout.preferredWidth: 220
|
||||
model: ["During focus intervals", "Always", "On a schedule"]
|
||||
currentIndex: {
|
||||
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
|
||||
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
|
||||
}
|
||||
onActivated: function (index) {
|
||||
var modes = ["during_work", "always", "scheduled"]
|
||||
controller.patchSettings({ blocker: { mode: modes[index] } })
|
||||
}
|
||||
}
|
||||
Switch {
|
||||
text: "Keep blocking during breaks"
|
||||
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
|
||||
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Today's temptations"
|
||||
subtitle: "Every attempt the blocker turned away — this is what the daily report jokes about."
|
||||
|
||||
Repeater {
|
||||
model: controller.stats.today ? controller.stats.today.blocked_top : []
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
Label { text: modelData.target; Layout.fillWidth: true }
|
||||
Label { text: "× " + modelData.count; font.weight: Font.DemiBold }
|
||||
}
|
||||
}
|
||||
Label {
|
||||
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
|
||||
text: "Nothing yet today. Either the blocker is off, or you are behaving."
|
||||
opacity: 0.6
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: controller.loadSettings()
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// The window.
|
||||
//
|
||||
// Qt Quick Controls' Material style, following the session's own light/dark
|
||||
// preference, with a navigation rail on the left and the pages stacked on the
|
||||
// right. Everything it shows comes from `controller`, the Python object bound
|
||||
// into the QML context — there is no second copy of the state here.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
|
||||
import "components"
|
||||
|
||||
ApplicationWindow {
|
||||
id: window
|
||||
|
||||
width: 1100
|
||||
height: 760
|
||||
minimumWidth: 420
|
||||
minimumHeight: 560
|
||||
visible: false
|
||||
title: controller.running
|
||||
? controller.clockText + " · " + controller.kindLabel + " — Kadence"
|
||||
: "Kadence"
|
||||
|
||||
Material.theme: Material.System
|
||||
Material.primary: "#6750A4"
|
||||
Material.accent: "#6750A4"
|
||||
|
||||
readonly property color workColor: "#2a78d6"
|
||||
readonly property color shortBreakColor: "#eb6834"
|
||||
readonly property color longBreakColor: "#1baf7a"
|
||||
readonly property bool wide: width >= 820
|
||||
property int page: 0
|
||||
|
||||
function kindColor(kind) {
|
||||
if (kind === "short_break") return shortBreakColor
|
||||
if (kind === "long_break") return longBreakColor
|
||||
return workColor
|
||||
}
|
||||
|
||||
// Closing the window keeps the app alive in the tray — the timer is still
|
||||
// running, and that is the whole point of a tray icon.
|
||||
onClosing: function (close) {
|
||||
if (controller.config.close_to_tray && controller.config.tray_icon) {
|
||||
close.accepted = false
|
||||
window.hide()
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: controller
|
||||
function onToast(message) { snackbar.show(message) }
|
||||
function onShowWindowRequested() { window.show(); window.raise(); window.requestActivate() }
|
||||
}
|
||||
|
||||
// ---- shortcuts ---------------------------------------------------------
|
||||
Shortcut { sequence: "Space"; onActivated: controller.toggle() }
|
||||
Shortcut { sequence: "Ctrl+Return"; onActivated: controller.complete() }
|
||||
Shortcut { sequence: "Ctrl+E"; onActivated: controller.extend(5) }
|
||||
Shortcut { sequence: "Ctrl+R"; onActivated: controller.refreshSources() }
|
||||
Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 }
|
||||
Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 }
|
||||
Shortcut { sequence: "Ctrl+3"; onActivated: window.page = 2 }
|
||||
Shortcut { sequence: "Ctrl+4"; onActivated: window.page = 3 }
|
||||
Shortcut { sequence: "Ctrl+5"; onActivated: window.page = 4 }
|
||||
Shortcut { sequence: "Ctrl+W"; onActivated: window.hide() }
|
||||
|
||||
header: ToolBar {
|
||||
visible: !window.wide
|
||||
Material.background: Material.backgroundColor
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
Label {
|
||||
text: ["Timer", "Tasks", "Blocker", "Report", "Settings"][window.page]
|
||||
font.pixelSize: 18
|
||||
font.weight: Font.Medium
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
StatusPill {
|
||||
running: controller.running
|
||||
paused: controller.status === "paused"
|
||||
connected: controller.connected
|
||||
text: controller.connected ? controller.clockText : "offline"
|
||||
accent: window.kindColor(controller.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// ---- navigation rail ------------------------------------------------
|
||||
Pane {
|
||||
visible: window.wide
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: 212
|
||||
Material.elevation: 0
|
||||
padding: 12
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.bottomMargin: 16
|
||||
Layout.leftMargin: 8
|
||||
spacing: 10
|
||||
Rectangle {
|
||||
width: 36; height: 36; radius: 18
|
||||
color: Material.accent
|
||||
Label { anchors.centerIn: parent; text: "🍅"; font.pixelSize: 18 }
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 0
|
||||
Label { text: "Kadence"; font.pixelSize: 17; font.weight: Font.DemiBold }
|
||||
Label {
|
||||
text: controller.connected ? "connected" : "offline"
|
||||
font.pixelSize: 11
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ label: "Timer", icon: "⏱" },
|
||||
{ label: "Tasks", icon: "✓" },
|
||||
{ label: "Blocker", icon: "🛡" },
|
||||
{ label: "Report", icon: "📊" },
|
||||
{ label: "Settings", icon: "⚙" }
|
||||
]
|
||||
delegate: ItemDelegate {
|
||||
required property int index
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
height: 44
|
||||
highlighted: window.page === index
|
||||
onClicked: window.page = index
|
||||
|
||||
background: Rectangle {
|
||||
radius: 22
|
||||
color: parent.highlighted ? Material.accent
|
||||
: (parent.hovered ? Material.listHighlightColor : "transparent")
|
||||
opacity: parent.highlighted ? 0.18 : 1
|
||||
}
|
||||
contentItem: RowLayout {
|
||||
spacing: 12
|
||||
Label { text: modelData.icon; font.pixelSize: 16 }
|
||||
Label {
|
||||
text: modelData.label
|
||||
font.weight: parent.parent.highlighted ? Font.DemiBold : Font.Normal
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
StatusPill {
|
||||
Layout.fillWidth: true
|
||||
running: controller.running
|
||||
paused: controller.status === "paused"
|
||||
connected: controller.connected
|
||||
text: controller.connected ? (controller.running ? controller.clockText : "idle") : "offline"
|
||||
accent: window.kindColor(controller.kind)
|
||||
}
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 6
|
||||
text: controller.completedToday + "/" + controller.goal + " today"
|
||||
font.pixelSize: 12
|
||||
opacity: 0.7
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
visible: controller.blockerActive
|
||||
text: "🛡 blocking is on"
|
||||
font.pixelSize: 12
|
||||
color: "#e34948"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pages ----------------------------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 0
|
||||
|
||||
Banner {
|
||||
Layout.fillWidth: true
|
||||
visible: !controller.connected
|
||||
text: controller.connectionError !== ""
|
||||
? controller.connectionError
|
||||
: "Cannot reach the backend. Start it with `docker compose up`."
|
||||
actionText: "Settings"
|
||||
onActionClicked: window.page = 4
|
||||
}
|
||||
|
||||
StackLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
currentIndex: window.page
|
||||
|
||||
TimerPage {}
|
||||
TasksPage {}
|
||||
BlockerPage {}
|
||||
ReportPage {}
|
||||
SettingsPage {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- footer: bottom navigation on a narrow window ----------------------
|
||||
footer: TabBar {
|
||||
visible: !window.wide
|
||||
currentIndex: window.page
|
||||
onCurrentIndexChanged: window.page = currentIndex
|
||||
TabButton { text: "⏱" }
|
||||
TabButton { text: "✓" }
|
||||
TabButton { text: "🛡" }
|
||||
TabButton { text: "📊" }
|
||||
TabButton { text: "⚙" }
|
||||
}
|
||||
|
||||
Snackbar { id: snackbar }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// The day, summarised — the same payload the Telegram message is built from.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "components"
|
||||
|
||||
ScrollView {
|
||||
id: page
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
|
||||
readonly property var payload: controller.report.report ? controller.report.report : null
|
||||
readonly property var metrics: payload ? payload.metrics : null
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth
|
||||
spacing: 16
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
spacing: 16
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: page.payload ? "Report for " + page.payload.date : "Report"
|
||||
font.pixelSize: 20
|
||||
font.weight: Font.Medium
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Button { text: "⟳"; flat: true; onClicked: controller.loadReport() }
|
||||
PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: page.width > 760 ? 4 : 2
|
||||
columnSpacing: 12
|
||||
rowSpacing: 12
|
||||
|
||||
StatTile {
|
||||
label: "Pomodoros"
|
||||
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—"
|
||||
caption: page.metrics && page.metrics.goal_met ? "goal met 🎯" : "keep going"
|
||||
accent: window.workColor
|
||||
}
|
||||
StatTile {
|
||||
label: "Focus"
|
||||
value: page.metrics ? page.metrics.focus_minutes + "m" : "—"
|
||||
caption: page.metrics ? page.metrics.break_minutes + "m resting" : ""
|
||||
}
|
||||
StatTile {
|
||||
label: "Score"
|
||||
value: page.payload ? Math.round(page.payload.score) : "—"
|
||||
caption: "out of 100"
|
||||
}
|
||||
StatTile {
|
||||
label: "Streak"
|
||||
value: page.payload ? page.payload.streak.current + "d" : "—"
|
||||
caption: page.payload ? "best " + page.payload.streak.best : ""
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Last 30 days"
|
||||
subtitle: "Goal line is the dashed row; darker bars cleared it."
|
||||
BarRow {
|
||||
Layout.fillWidth: true
|
||||
days: controller.stats.range ? controller.stats.range : []
|
||||
goal: controller.goal
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
visible: page.payload && page.payload.achievements.length > 0
|
||||
title: "Achievements"
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Repeater {
|
||||
model: page.payload ? page.payload.achievements : []
|
||||
delegate: Pane {
|
||||
required property var modelData
|
||||
Material.elevation: 0
|
||||
Material.roundedScale: Material.SmallScale
|
||||
padding: 10
|
||||
background: Rectangle {
|
||||
radius: 10
|
||||
color: Material.accent
|
||||
opacity: 0.14
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 1
|
||||
Label {
|
||||
text: modelData.emoji + " " + modelData.title
|
||||
font.weight: Font.DemiBold
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Label {
|
||||
text: modelData.detail
|
||||
visible: modelData.detail !== ""
|
||||
font.pixelSize: 11
|
||||
opacity: 0.75
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
visible: page.payload && page.payload.fun_facts.length > 0
|
||||
title: "The funny part"
|
||||
Repeater {
|
||||
model: page.payload ? page.payload.fun_facts : []
|
||||
delegate: Label {
|
||||
required property var modelData
|
||||
text: modelData
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
visible: page.metrics && page.metrics.tasks_completed.length > 0
|
||||
title: "Closed today"
|
||||
Repeater {
|
||||
model: page.metrics ? page.metrics.tasks_completed : []
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
Label { text: "✅"; font.pixelSize: 12 }
|
||||
Label { text: modelData.title; Layout.fillWidth: true; elide: Text.ElideRight }
|
||||
Label { text: modelData.source; font.pixelSize: 11; opacity: 0.6 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "All time"
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
StatTile {
|
||||
label: "Pomodoros"
|
||||
value: controller.stats.lifetime ? controller.stats.lifetime.pomodoros : "—"
|
||||
}
|
||||
StatTile {
|
||||
label: "Hours focused"
|
||||
value: controller.stats.lifetime ? controller.stats.lifetime.focus_hours : "—"
|
||||
}
|
||||
StatTile {
|
||||
label: "Tasks closed"
|
||||
value: controller.stats.lifetime ? controller.stats.lifetime.tasks_completed : "—"
|
||||
}
|
||||
StatTile {
|
||||
label: "Blocked"
|
||||
value: controller.stats.lifetime ? controller.stats.lifetime.blocked_attempts : "—"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: page.payload && page.payload.verdict !== ""
|
||||
text: page.payload ? page.payload.verdict : ""
|
||||
font.italic: true
|
||||
font.pixelSize: 14
|
||||
opacity: 0.85
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: controller.loadReport()
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// Settings, split by where they live.
|
||||
//
|
||||
// Anything under "This computer" is local to this machine (and saved in
|
||||
// ~/.config/kadence/desktop.json). Everything else is server-side, shared with
|
||||
// the web app and the Telegram bot.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "components"
|
||||
|
||||
ScrollView {
|
||||
id: page
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
|
||||
readonly property var pomodoro: controller.settings.pomodoro ? controller.settings.pomodoro : null
|
||||
readonly property var telegram: controller.settings.notifications
|
||||
? controller.settings.notifications.telegram : null
|
||||
readonly property var report: controller.settings.notifications
|
||||
? controller.settings.notifications.report : null
|
||||
|
||||
function patchPomodoro(key, value) {
|
||||
var patch = {}; patch[key] = value
|
||||
controller.patchSettings({ pomodoro: patch })
|
||||
}
|
||||
function patchTelegram(key, value) {
|
||||
var patch = {}; patch[key] = value
|
||||
controller.patchSettings({ notifications: { telegram: patch } })
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth
|
||||
spacing: 16
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
spacing: 16
|
||||
|
||||
// ---- connection --------------------------------------------------
|
||||
Card {
|
||||
title: "Backend"
|
||||
subtitle: controller.connected ? "Connected to " + controller.config.server_url
|
||||
: controller.connectionError
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
TextField {
|
||||
id: serverField
|
||||
Layout.fillWidth: true
|
||||
text: controller.config.server_url
|
||||
placeholderText: "http://localhost:8000"
|
||||
}
|
||||
TextField {
|
||||
id: tokenField
|
||||
Layout.preferredWidth: 180
|
||||
text: controller.config.api_token
|
||||
placeholderText: "API token (optional)"
|
||||
echoMode: TextInput.Password
|
||||
}
|
||||
PrimaryButton {
|
||||
text: "Connect"
|
||||
onClicked: {
|
||||
controller.setConfig("server_url", serverField.text.trim())
|
||||
controller.setConfig("api_token", tokenField.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
Label { text: "Poll every"; font.pixelSize: 12 }
|
||||
SpinBox {
|
||||
from: 1; to: 60
|
||||
value: controller.config.poll_seconds
|
||||
onValueModified: controller.setConfig("poll_seconds", value)
|
||||
}
|
||||
Label { text: "seconds"; font.pixelSize: 12 }
|
||||
Item { Layout.fillWidth: true }
|
||||
Button { text: "Open the web app"; flat: true; onClicked: controller.openWebUi() }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- timer -------------------------------------------------------
|
||||
Card {
|
||||
title: "Timer"
|
||||
subtitle: "Shared with the web app and the Telegram bot."
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: page.width > 700 ? 4 : 2
|
||||
columnSpacing: 16
|
||||
rowSpacing: 8
|
||||
|
||||
ColumnLayout {
|
||||
Label { text: "Focus (min)"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: page.pomodoro ? page.pomodoro.work_minutes : 25
|
||||
onValueModified: page.patchPomodoro("work_minutes", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Short break"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 120
|
||||
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
|
||||
onValueModified: page.patchPomodoro("short_break_minutes", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Long break"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 240
|
||||
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
|
||||
onValueModified: page.patchPomodoro("long_break_minutes", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Long break every"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 12
|
||||
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
|
||||
onValueModified: page.patchPomodoro("cycles_before_long_break", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Daily goal"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 1; to: 40
|
||||
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
|
||||
onValueModified: page.patchPomodoro("daily_goal_pomodoros", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
Switch {
|
||||
text: "Auto-start breaks"
|
||||
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
|
||||
onToggled: page.patchPomodoro("auto_start_breaks", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Auto-start next focus"
|
||||
checked: page.pomodoro ? page.pomodoro.auto_start_work : false
|
||||
onToggled: page.patchPomodoro("auto_start_work", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Allow pausing"
|
||||
checked: page.pomodoro ? page.pomodoro.allow_pause : true
|
||||
onToggled: page.patchPomodoro("allow_pause", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Strict mode"
|
||||
checked: page.pomodoro ? page.pomodoro.strict_mode : false
|
||||
onToggled: page.patchPomodoro("strict_mode", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Abandoning a focus interval requires a reason."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- desktop integration ----------------------------------------
|
||||
Card {
|
||||
title: "This computer"
|
||||
subtitle: "Native desktop features. Stored locally, not on the server."
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
Switch {
|
||||
text: "Tray icon"
|
||||
checked: controller.config.tray_icon
|
||||
onToggled: controller.setConfig("tray_icon", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Close to tray"
|
||||
checked: controller.config.close_to_tray
|
||||
onToggled: controller.setConfig("close_to_tray", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Notifications"
|
||||
checked: controller.config.notifications
|
||||
onToggled: controller.setConfig("notifications", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Notification buttons"
|
||||
checked: controller.config.notification_actions
|
||||
onToggled: controller.setConfig("notification_actions", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "“Start break” / “Skip” directly on the notification."
|
||||
}
|
||||
Switch {
|
||||
text: "Keep the screen awake"
|
||||
checked: controller.config.inhibit_screensaver
|
||||
onToggled: controller.setConfig("inhibit_screensaver", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Do Not Disturb while focusing"
|
||||
checked: controller.config.do_not_disturb
|
||||
onToggled: controller.setConfig("do_not_disturb", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Pause music when focus starts"
|
||||
checked: controller.config.pause_media
|
||||
onToggled: controller.setConfig("pause_media", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
|
||||
}
|
||||
Switch {
|
||||
text: "Taskbar progress"
|
||||
checked: controller.config.taskbar_progress
|
||||
onToggled: controller.setConfig("taskbar_progress", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Pause when the screen locks"
|
||||
checked: controller.config.pause_on_lock
|
||||
onToggled: controller.setConfig("pause_on_lock", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "D-Bus control"
|
||||
checked: controller.config.dbus_service
|
||||
onToggled: controller.setConfig("dbus_service", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "org.kadence.Desktop — bind it to a global shortcut."
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label { text: "Pause after"; font.pixelSize: 12 }
|
||||
SpinBox {
|
||||
from: 0; to: 120
|
||||
value: controller.config.pause_on_idle_minutes
|
||||
onValueModified: controller.setConfig("pause_on_idle_minutes", value)
|
||||
}
|
||||
Label { text: "idle minutes (0 = never)"; font.pixelSize: 12 }
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Switch {
|
||||
text: "Start with the session"
|
||||
checked: controller.config.autostart
|
||||
onToggled: controller.setConfig("autostart", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "systemd user service"
|
||||
checked: controller.config.service_enabled
|
||||
onToggled: controller.setConfig("service_enabled", checked)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Restarts the app if it dies; logs to the journal."
|
||||
}
|
||||
Switch {
|
||||
text: "Start minimised"
|
||||
checked: controller.config.start_minimized
|
||||
onToggled: controller.setConfig("start_minimized", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- telegram ----------------------------------------------------
|
||||
Card {
|
||||
title: "Telegram"
|
||||
subtitle: "Daily report, morning briefing and the nudges that get you back to work."
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Switch {
|
||||
text: "Enabled"
|
||||
checked: page.telegram ? page.telegram.enabled : false
|
||||
onToggled: page.patchTelegram("enabled", checked)
|
||||
}
|
||||
TextField {
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Bot token from @BotFather"
|
||||
text: page.telegram ? page.telegram.bot_token : ""
|
||||
onEditingFinished: if (text !== "••••••••") page.patchTelegram("bot_token", text)
|
||||
}
|
||||
TextField {
|
||||
Layout.preferredWidth: 160
|
||||
placeholderText: "Chat id"
|
||||
text: page.telegram ? page.telegram.chat_id : ""
|
||||
onEditingFinished: page.patchTelegram("chat_id", text)
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
Switch {
|
||||
text: "Daily report"
|
||||
checked: page.telegram ? page.telegram.daily_report : true
|
||||
onToggled: page.patchTelegram("daily_report", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Morning briefing"
|
||||
checked: page.telegram ? page.telegram.morning_briefing : true
|
||||
onToggled: page.patchTelegram("morning_briefing", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Weekly review"
|
||||
checked: page.telegram ? page.telegram.weekly_report : true
|
||||
onToggled: page.patchTelegram("weekly_report", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Nudge me when idle"
|
||||
checked: page.telegram ? page.telegram.nudge_enabled : true
|
||||
onToggled: page.patchTelegram("nudge_enabled", checked)
|
||||
}
|
||||
Switch {
|
||||
text: "Chat commands"
|
||||
checked: page.telegram ? page.telegram.commands_enabled : true
|
||||
onToggled: page.patchTelegram("commands_enabled", checked)
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
ColumnLayout {
|
||||
Label { text: "Report at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
|
||||
TextField {
|
||||
text: page.telegram ? page.telegram.daily_report_time : "21:00"
|
||||
onEditingFinished: page.patchTelegram("daily_report_time", text)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Briefing at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
|
||||
TextField {
|
||||
text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
|
||||
onEditingFinished: page.patchTelegram("morning_briefing_time", text)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Nudge after (min idle)"; font.pixelSize: 11; opacity: 0.7 }
|
||||
SpinBox {
|
||||
from: 10; to: 600
|
||||
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
|
||||
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Tone"; font.pixelSize: 11; opacity: 0.7 }
|
||||
ComboBox {
|
||||
model: ["friendly", "coach", "drill_sergeant", "dry"]
|
||||
currentIndex: page.report ? model.indexOf(page.report.tone) : 0
|
||||
onActivated: function (index) {
|
||||
controller.patchSettings({ notifications: { report: { tone: model[index] } } })
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Language"; font.pixelSize: 11; opacity: 0.7 }
|
||||
ComboBox {
|
||||
model: ["en", "ru"]
|
||||
currentIndex: page.report && page.report.language === "ru" ? 1 : 0
|
||||
onActivated: function (index) {
|
||||
controller.patchSettings({ notifications: { report: { language: model[index] } } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Shortcuts and scripting"
|
||||
subtitle: "The app exposes org.kadence.Desktop on the session bus."
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: "Roboto Mono, monospace"
|
||||
font.pixelSize: 11
|
||||
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
|
||||
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
|
||||
+ "kadence-desktop --toggle # same thing from a terminal\n\n"
|
||||
+ "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
|
||||
}
|
||||
Label {
|
||||
text: "Kadence " + appVersion + " · sources, blocking rules and reports live on the backend."
|
||||
font.pixelSize: 11
|
||||
opacity: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: controller.loadSettings()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Everything you owe, from every source, grouped by deadline.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "components"
|
||||
|
||||
Item {
|
||||
id: page
|
||||
|
||||
function bucketTasks(bucket) {
|
||||
var out = []
|
||||
for (var i = 0; i < controller.tasks.length; i++) {
|
||||
var task = controller.tasks[i]
|
||||
if (!task.is_done && task.bucket === bucket) out.push(task)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 12
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Label {
|
||||
text: controller.tasks.length + " open"
|
||||
font.pixelSize: 13
|
||||
opacity: 0.7
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Button { text: "+ Add"; onClicked: addDialog.open() }
|
||||
PrimaryButton { text: "⟳ Refresh sources"; onClicked: controller.refreshSources() }
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.parent.availableWidth
|
||||
spacing: 12
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ key: "overdue", label: "Overdue", mark: "🔴" },
|
||||
{ key: "today", label: "Due today", mark: "🟡" },
|
||||
{ key: "upcoming", label: "Coming up", mark: "🔵" },
|
||||
{ key: "someday", label: "No deadline", mark: "⚪" }
|
||||
]
|
||||
delegate: Card {
|
||||
required property var modelData
|
||||
property var items: page.bucketTasks(modelData.key)
|
||||
visible: items.length > 0
|
||||
title: modelData.mark + " " + modelData.label + " (" + items.length + ")"
|
||||
|
||||
Repeater {
|
||||
model: parent.parent.items
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
CheckBox {
|
||||
checked: modelData.is_done
|
||||
onToggled: controller.setTaskDone(modelData.id, checked)
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
text: modelData.title
|
||||
font.pixelSize: 14
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 6
|
||||
Label {
|
||||
text: modelData.source_label
|
||||
font.pixelSize: 10
|
||||
opacity: 0.65
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.project
|
||||
text: "· " + modelData.project
|
||||
font.pixelSize: 10
|
||||
opacity: 0.65
|
||||
}
|
||||
Label {
|
||||
visible: modelData.days_overdue > 0
|
||||
text: "· " + modelData.days_overdue + "d overdue"
|
||||
font.pixelSize: 10
|
||||
color: "#e34948"
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.due_date && modelData.days_overdue === 0
|
||||
text: "· " + modelData.due_date
|
||||
font.pixelSize: 10
|
||||
opacity: 0.65
|
||||
}
|
||||
Label {
|
||||
visible: !!modelData.priority
|
||||
text: "· " + modelData.priority
|
||||
font.pixelSize: 10
|
||||
opacity: 0.65
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolButton {
|
||||
text: "↗"
|
||||
visible: !!modelData.url
|
||||
onClicked: controller.openUrl(modelData.url)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Open in " + modelData.source_label
|
||||
}
|
||||
ToolButton {
|
||||
text: "▶"
|
||||
onClicked: { controller.attachTask(modelData.id); controller.startWork(); window.page = 0 }
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Start a pomodoro on this"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
visible: controller.tasks.length === 0
|
||||
title: "Nothing on the list"
|
||||
subtitle: "Connect Jira, Todoist or Google Tasks in Settings → Sources, "
|
||||
+ "or add something here yourself."
|
||||
Button {
|
||||
text: "Open settings"
|
||||
onClicked: window.page = 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: addDialog
|
||||
anchors.centerIn: Overlay.overlay
|
||||
width: 380
|
||||
modal: true
|
||||
title: "New task"
|
||||
standardButtons: Dialog.Cancel
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
TextField {
|
||||
id: titleField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "What needs doing?"
|
||||
}
|
||||
TextField {
|
||||
id: dueField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Due date (YYYY-MM-DD, optional)"
|
||||
inputMask: ""
|
||||
}
|
||||
PrimaryButton {
|
||||
text: "Add"
|
||||
enabled: titleField.text.trim() !== ""
|
||||
Layout.alignment: Qt.AlignRight
|
||||
onClicked: {
|
||||
controller.createTask(titleField.text.trim(), dueField.text.trim())
|
||||
titleField.text = ""; dueField.text = ""
|
||||
addDialog.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// The timer page: the dial, the controls, and what you are working on.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
import "components"
|
||||
|
||||
ScrollView {
|
||||
id: page
|
||||
contentWidth: availableWidth
|
||||
clip: true
|
||||
|
||||
property var openTasks: {
|
||||
var list = []
|
||||
for (var i = 0; i < controller.tasks.length; i++)
|
||||
if (!controller.tasks[i].is_done) list.push(controller.tasks[i])
|
||||
return list
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
width: page.availableWidth
|
||||
spacing: 16
|
||||
anchors.margins: 20
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 20
|
||||
columns: page.width > 900 ? 2 : 1
|
||||
columnSpacing: 16
|
||||
rowSpacing: 16
|
||||
|
||||
// ---- dial + controls --------------------------------------------
|
||||
Card {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
|
||||
TimerDial {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.preferredWidth: Math.min(300, page.availableWidth * 0.7)
|
||||
Layout.preferredHeight: Layout.preferredWidth
|
||||
progress: controller.progress
|
||||
clockText: controller.clockText
|
||||
label: controller.running ? controller.kindLabel : "Next: " + controller.nextKindLabel
|
||||
sublabel: controller.running
|
||||
? (controller.taskTitle !== "" ? controller.taskTitle : " ")
|
||||
: "Press start when you are ready"
|
||||
accent: window.kindColor(controller.kind)
|
||||
paused: controller.status === "paused"
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: 8
|
||||
spacing: 8
|
||||
|
||||
PrimaryButton {
|
||||
visible: !controller.running
|
||||
text: "▶ Start focus"
|
||||
onClicked: controller.startWork()
|
||||
}
|
||||
Button {
|
||||
visible: !controller.running
|
||||
text: "☕ " + controller.nextKindLabel
|
||||
onClicked: controller.startBreak()
|
||||
}
|
||||
Button {
|
||||
visible: controller.running && controller.status !== "paused"
|
||||
text: "⏸ Pause"
|
||||
onClicked: controller.pause()
|
||||
}
|
||||
PrimaryButton {
|
||||
visible: controller.running && controller.status === "paused"
|
||||
text: "▶ Resume"
|
||||
onClicked: controller.resume()
|
||||
}
|
||||
PrimaryButton {
|
||||
visible: controller.running
|
||||
text: "✓ Done"
|
||||
onClicked: controller.complete()
|
||||
}
|
||||
Button {
|
||||
visible: controller.running
|
||||
text: "+5"
|
||||
onClicked: controller.extend(5)
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Add five minutes"
|
||||
}
|
||||
Button {
|
||||
visible: controller.running
|
||||
text: "🔔"
|
||||
onClicked: controller.interrupt()
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Count an interruption without stopping"
|
||||
}
|
||||
Button {
|
||||
visible: controller.running
|
||||
text: "⏹"
|
||||
flat: true
|
||||
Material.foreground: "#e34948"
|
||||
onClicked: abandonDialog.open()
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Abandon this interval"
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: controller.interruptions > 0
|
||||
text: controller.interruptions + " interruption(s) this interval"
|
||||
font.pixelSize: 11
|
||||
opacity: 0.6
|
||||
}
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
|
||||
font.pixelSize: 10
|
||||
opacity: 0.45
|
||||
}
|
||||
}
|
||||
|
||||
// ---- today + task picker ----------------------------------------
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: 16
|
||||
|
||||
Card {
|
||||
title: "Today"
|
||||
subtitle: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
|
||||
+ " before a long break"
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Label {
|
||||
text: controller.completedToday
|
||||
font.pixelSize: 34
|
||||
font.weight: Font.Light
|
||||
}
|
||||
Label {
|
||||
text: "/ " + controller.goal + " pomodoros"
|
||||
font.pixelSize: 16
|
||||
opacity: 0.7
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
Layout.bottomMargin: 6
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
ProgressBar {
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: Math.max(controller.goal, 1)
|
||||
value: Math.min(controller.completedToday, controller.goal)
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
Repeater {
|
||||
model: Math.max(controller.goal, controller.completedToday)
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
width: 14; height: 14; radius: 7
|
||||
color: index < controller.completedToday ? window.workColor : "transparent"
|
||||
border.width: index < controller.completedToday ? 0 : 1
|
||||
border.color: Material.hintTextColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "What are you working on?"
|
||||
|
||||
ComboBox {
|
||||
id: taskPicker
|
||||
Layout.fillWidth: true
|
||||
model: {
|
||||
var names = ["No task"]
|
||||
for (var i = 0; i < page.openTasks.length; i++)
|
||||
names.push((page.openTasks[i].pinned ? "📌 " : "")
|
||||
+ page.openTasks[i].title
|
||||
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
|
||||
return names
|
||||
}
|
||||
onActivated: function (index) {
|
||||
controller.attachTask(index === 0 ? -1 : page.openTasks[index - 1].id)
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
font.pixelSize: 12
|
||||
opacity: 0.7
|
||||
text: controller.taskTitle !== ""
|
||||
? "This interval is tracked against “" + controller.taskTitle + "”."
|
||||
: "Pick a task so the daily report knows where the time went."
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Label {
|
||||
text: "🔴 " + (controller.taskCounts.overdue || 0) + " overdue"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Label {
|
||||
text: "🟡 " + (controller.taskCounts.today || 0) + " due today"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Button { text: "Tasks"; flat: true; onClicked: window.page = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the day's numbers ------------------------------------------------
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 20
|
||||
Layout.rightMargin: 20
|
||||
Layout.bottomMargin: 20
|
||||
columns: page.width > 760 ? 4 : 2
|
||||
columnSpacing: 12
|
||||
rowSpacing: 12
|
||||
|
||||
StatTile {
|
||||
label: "Focus today"
|
||||
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—"
|
||||
caption: controller.completedToday + " pomodoros"
|
||||
}
|
||||
StatTile {
|
||||
label: "Longest stretch"
|
||||
value: controller.stats.today ? controller.stats.today.longest_stretch_minutes + "m" : "—"
|
||||
caption: "unbroken focus"
|
||||
}
|
||||
StatTile {
|
||||
label: "Streak"
|
||||
value: controller.stats.streak ? controller.stats.streak.current + "d" : "—"
|
||||
caption: controller.stats.streak ? "best " + controller.stats.streak.best : ""
|
||||
}
|
||||
StatTile {
|
||||
label: "Blocked"
|
||||
value: controller.stats.today ? controller.stats.today.blocked_attempts : "—"
|
||||
caption: "temptations turned away"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog {
|
||||
id: abandonDialog
|
||||
anchors.centerIn: Overlay.overlay
|
||||
width: 380
|
||||
modal: true
|
||||
title: "Abandon this interval?"
|
||||
standardButtons: Dialog.Cancel
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
Label {
|
||||
text: controller.clockText + " left. The interval is recorded as abandoned."
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
TextField {
|
||||
id: reasonField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "What happened? (optional)"
|
||||
}
|
||||
Button {
|
||||
text: "Abandon"
|
||||
Material.background: "#e34948"
|
||||
Material.foreground: "white"
|
||||
Layout.alignment: Qt.AlignRight
|
||||
onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// An inline warning strip — used for "the backend is gone" and "the helper is
|
||||
// not installed", the two states where the app cannot do its job.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
Rectangle {
|
||||
id: banner
|
||||
property string text: ""
|
||||
property string actionText: ""
|
||||
signal actionClicked()
|
||||
|
||||
implicitHeight: visible ? row.implicitHeight + 20 : 0
|
||||
color: "#e34948"
|
||||
opacity: 0.92
|
||||
|
||||
RowLayout {
|
||||
id: row
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 12
|
||||
Label {
|
||||
text: banner.text
|
||||
color: "white"
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Button {
|
||||
visible: banner.actionText !== ""
|
||||
text: banner.actionText
|
||||
flat: true
|
||||
Material.foreground: "white"
|
||||
onClicked: banner.actionClicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// A tiny bar chart: magnitude over time, one hue, baseline at zero.
|
||||
//
|
||||
// The ramp step says "did this day clear the goal" without adding a second
|
||||
// colour, and every bar carries its own tooltip, so no bar needs a label.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
Item {
|
||||
id: chart
|
||||
property var days: [] // [{date, pomodoros, focus_minutes, goal_met}]
|
||||
property int goal: 8
|
||||
property int barHeight: 110
|
||||
|
||||
implicitHeight: barHeight + 18
|
||||
|
||||
readonly property int maxValue: {
|
||||
var top = goal
|
||||
for (var i = 0; i < days.length; i++) top = Math.max(top, days[i].pomodoros)
|
||||
return Math.max(top, 1)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
|
||||
height: 1
|
||||
color: Material.hintTextColor
|
||||
opacity: 0.45
|
||||
visible: chart.goal > 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: chart.barHeight
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: chart.days
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: Math.max(modelData.pomodoros > 0
|
||||
? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2)
|
||||
radius: 4
|
||||
color: modelData.pomodoros === 0 ? Material.dividerColor
|
||||
: (modelData.goal_met ? "#256abf" : "#6da7ec")
|
||||
|
||||
ToolTip.visible: hover.hovered
|
||||
ToolTip.text: modelData.date + ": " + modelData.pomodoros
|
||||
+ " pomodoros · " + modelData.focus_minutes + " min"
|
||||
HoverHandler { id: hover }
|
||||
Behavior on height { NumberAnimation { duration: 250 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
Label {
|
||||
text: chart.days.length ? chart.days[0].date.substring(5) : ""
|
||||
font.pixelSize: 10; opacity: 0.6
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
|
||||
font.pixelSize: 10; opacity: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// A Material surface with a title. Used everywhere instead of bare Panes so the
|
||||
// pages share one radius, one padding and one elevation.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
Pane {
|
||||
id: card
|
||||
property string title: ""
|
||||
property string subtitle: ""
|
||||
default property alias content: inner.data
|
||||
|
||||
Material.elevation: 1
|
||||
Material.roundedScale: Material.LargeScale
|
||||
padding: 18
|
||||
Layout.fillWidth: true
|
||||
|
||||
background: Rectangle {
|
||||
radius: 18
|
||||
color: Material.dialogColor
|
||||
border.width: 1
|
||||
border.color: Material.dividerColor
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
visible: card.title !== ""
|
||||
Layout.fillWidth: true
|
||||
Label { text: card.title; font.pixelSize: 16; font.weight: Font.DemiBold }
|
||||
Label {
|
||||
text: card.subtitle
|
||||
visible: card.subtitle !== ""
|
||||
font.pixelSize: 12
|
||||
opacity: 0.7
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: inner
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// The filled Material button, painted explicitly.
|
||||
//
|
||||
// Relying on `highlighted: true` leaves the fill to the style, and under some
|
||||
// renderers that fill never appears — white text on a white surface. The
|
||||
// primary action on a page is exactly the thing that must never be invisible,
|
||||
// so it draws its own pill.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
|
||||
Button {
|
||||
id: control
|
||||
|
||||
Material.foreground: "white"
|
||||
font.weight: Font.Medium
|
||||
leftPadding: 20
|
||||
rightPadding: 20
|
||||
implicitHeight: 40
|
||||
|
||||
background: Rectangle {
|
||||
radius: height / 2
|
||||
color: control.enabled ? Material.accent : Material.buttonDisabledColor
|
||||
opacity: control.pressed ? 0.82 : (control.hovered ? 0.92 : 1)
|
||||
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||
}
|
||||
|
||||
contentItem: Label {
|
||||
text: control.text
|
||||
font: control.font
|
||||
color: control.enabled ? "white" : Material.hintTextColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Transient messages. Material's own popup, anchored to the bottom.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
|
||||
Popup {
|
||||
id: snackbar
|
||||
property string message: ""
|
||||
|
||||
function show(text) {
|
||||
message = text
|
||||
open()
|
||||
timer.restart()
|
||||
}
|
||||
|
||||
x: Math.round((parent.width - width) / 2)
|
||||
y: parent.height - height - 72
|
||||
width: Math.min(parent.width - 48, Math.max(260, label.implicitWidth + 48))
|
||||
padding: 16
|
||||
modal: false
|
||||
focus: false
|
||||
closePolicy: Popup.NoAutoClose
|
||||
Material.elevation: 6
|
||||
Material.roundedScale: Material.SmallScale
|
||||
|
||||
Label {
|
||||
id: label
|
||||
anchors.fill: parent
|
||||
text: snackbar.message
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Timer { id: timer; interval: 5000; onTriggered: snackbar.close() }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// One number, read at a glance. Not a chart.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
Pane {
|
||||
property string label: ""
|
||||
property string value: "—"
|
||||
property string caption: ""
|
||||
property color accent: Material.foreground
|
||||
|
||||
Material.elevation: 1
|
||||
Material.roundedScale: Material.LargeScale
|
||||
padding: 16
|
||||
Layout.fillWidth: true
|
||||
|
||||
background: Rectangle {
|
||||
radius: 16
|
||||
color: Material.dialogColor
|
||||
border.width: 1
|
||||
border.color: Material.dividerColor
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 2
|
||||
Label { text: label.toUpperCase(); font.pixelSize: 10; font.letterSpacing: 1; opacity: 0.6 }
|
||||
Label { text: value; font.pixelSize: 26; font.weight: Font.Light; color: accent }
|
||||
Label {
|
||||
text: caption
|
||||
visible: caption !== ""
|
||||
font.pixelSize: 11
|
||||
opacity: 0.7
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// The little "what is the timer doing" chip, shown in the rail and the toolbar.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
|
||||
Rectangle {
|
||||
property bool running: false
|
||||
property bool paused: false
|
||||
property bool connected: true
|
||||
property string text: ""
|
||||
property color accent: "#2a78d6"
|
||||
|
||||
implicitWidth: Math.max(78, label.implicitWidth + 28)
|
||||
implicitHeight: 32
|
||||
radius: 16
|
||||
color: !connected ? "#e34948" : (running ? accent : "transparent")
|
||||
opacity: paused ? 0.6 : 1
|
||||
border.width: running || !connected ? 0 : 1
|
||||
border.color: Material.hintTextColor
|
||||
|
||||
Label {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: parent.text
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
font.family: "Roboto Mono, monospace"
|
||||
color: (parent.running || !parent.connected) ? "white" : Material.foreground
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// The countdown ring, drawn with Canvas.
|
||||
//
|
||||
// A hero number inside a progress arc: the number is what you read, the arc is
|
||||
// the glance. The arc colour is the only thing that says focus vs break.
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Material
|
||||
|
||||
Item {
|
||||
id: dial
|
||||
property real progress: 0 // 0..1 elapsed
|
||||
property string clockText: "00:00"
|
||||
property string label: ""
|
||||
property string sublabel: ""
|
||||
property color accent: "#2a78d6"
|
||||
property bool paused: false
|
||||
|
||||
implicitWidth: 260
|
||||
implicitHeight: 260
|
||||
|
||||
onProgressChanged: canvas.requestPaint()
|
||||
onAccentChanged: canvas.requestPaint()
|
||||
onPausedChanged: canvas.requestPaint()
|
||||
|
||||
Canvas {
|
||||
id: canvas
|
||||
anchors.fill: parent
|
||||
antialiasing: true
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
var centre = Math.min(width, height) / 2
|
||||
var stroke = Math.max(10, centre * 0.11)
|
||||
var radius = centre - stroke
|
||||
|
||||
ctx.lineCap = "round"
|
||||
ctx.lineWidth = stroke
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.18)
|
||||
ctx.arc(centre, centre, radius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
|
||||
if (remaining > 0) {
|
||||
ctx.beginPath()
|
||||
ctx.globalAlpha = dial.paused ? 0.45 : 1
|
||||
ctx.strokeStyle = dial.accent
|
||||
ctx.arc(centre, centre, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
width: parent.width * 0.72
|
||||
|
||||
Label {
|
||||
width: parent.width
|
||||
text: dial.label.toUpperCase()
|
||||
font.pixelSize: 11
|
||||
font.letterSpacing: 1.5
|
||||
opacity: 0.65
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Label {
|
||||
width: parent.width
|
||||
text: dial.clockText
|
||||
font.pixelSize: Math.round(dial.width * 0.2)
|
||||
font.weight: Font.Light
|
||||
font.family: "Roboto Mono, monospace"
|
||||
opacity: dial.paused ? 0.6 : 1
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
Label {
|
||||
width: parent.width
|
||||
text: dial.sublabel
|
||||
visible: dial.sublabel !== ""
|
||||
font.pixelSize: 13
|
||||
opacity: 0.75
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user