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

@@ -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
}
}
}