From 319ece56266ac5dbc0a7c3798dfd349abf334be6 Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Tue, 26 May 2026 07:14:02 +0700 Subject: [PATCH 01/10] feat: add timer to bar clock Adds a timer accessible from the bar. Hovering over the clock slides out a popout where you can set hours, minutes, seconds and start the countdown. While running, a small indicator appears under the clock showing the time left. When the timer finishes, the popout reopens automatically with a bongo cat animation and a dismiss button. An optional sound plays on finish - configurable via bar.clock.timer.soundFile in shell.json, defaults to the included timer-done.wav. The timer persists across shell reloads (uses PersistentProperties). Also adds an optional "Timer" status icon (bar.status.showTimer) - when enabled, a dedicated icon appears in the status icons column instead of the clock interaction. Shows the timer icon when idle and remaining time when running. Toggled from the Taskbar settings pane. New config options: - bar.clock.timer.enabled (bool, default true) - enable/disable the feature - bar.clock.timer.soundFile (string) - path to sound file played on finish - bar.status.showTimer (bool, default false) - show as status icon instead --- assets/timer-done.wav | Bin 0 -> 67076 bytes modules/bar/Bar.qml | 48 +++ modules/bar/components/Clock.qml | 42 +++ modules/bar/components/StatusIcons.qml | 54 +++ modules/bar/popouts/Content.qml | 7 + modules/bar/popouts/PopoutState.qml | 1 + modules/bar/popouts/TimerPopout.qml | 350 ++++++++++++++++++ modules/bar/popouts/Wrapper.qml | 7 +- modules/controlcenter/taskbar/TaskbarPane.qml | 10 + modules/drawers/Interactions.qml | 4 +- plugin/src/Caelestia/Config/barconfig.hpp | 17 +- services/TimerService.qml | 120 ++++++ 12 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 assets/timer-done.wav create mode 100644 modules/bar/popouts/TimerPopout.qml create mode 100644 services/TimerService.qml diff --git a/assets/timer-done.wav b/assets/timer-done.wav new file mode 100644 index 0000000000000000000000000000000000000000..ed2df4dbabd2103eb8be9e938823afdee9f3667f GIT binary patch literal 67076 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 literal 0 HcmV?d00001 diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml index a2ed060e7..d99d3f8ed 100644 --- a/modules/bar/Bar.qml +++ b/modules/bar/Bar.qml @@ -34,6 +34,9 @@ ColumnLayout { function checkPopout(y: real): void { const ch = childAt(width / 2, y) as WrappedLoader; + if (popouts.locked && ch?.id !== "clock") + popouts.locked = false; + if (ch?.id !== "tray") closeTray(); @@ -73,6 +76,10 @@ ColumnLayout { popouts.currentName = id.toLowerCase(); popouts.currentCenter = (ch.item as Item).mapToItem(root, 0, (ch.item as Item).implicitHeight / 2).y ?? 0; popouts.hasCurrent = true; + } else if (id === "clock" && (Config.bar.clock.timer?.enabled ?? true) && !(Config.bar.status.showTimer ?? false)) { + popouts.currentName = "timer"; + popouts.currentCenter = ch.y + ch.height / 2; + popouts.hasCurrent = true; } } @@ -104,6 +111,47 @@ ColumnLayout { spacing: Tokens.spacing.normal + Connections { + target: TimerService + + function onFinished(): void { + if (Config.bar.status.showTimer ?? false) { + for (let i = 0; i < repeater.count; i++) { + const loader = repeater.itemAt(i) as WrappedLoader; + if (loader?.enabled && loader.id === "statusIcons") { + const si = loader.item as StatusIcons; + const ti = si?.timerItem; + if (ti) { + root.popouts.currentName = "timer"; + root.popouts.currentCenter = Qt.binding(() => ti.mapToItem(root, 0, ti.implicitHeight / 2).y); + root.popouts.hasCurrent = true; + root.popouts.locked = true; + } + break; + } + } + } else { + for (let i = 0; i < repeater.count; i++) { + const loader = repeater.itemAt(i) as WrappedLoader; + if (loader?.enabled && loader.id === "clock") { + root.popouts.currentName = "timer"; + root.popouts.currentCenter = loader.y + loader.height / 2; + root.popouts.hasCurrent = true; + root.popouts.locked = true; + break; + } + } + } + } + + function onTimerDoneChanged(): void { + if (!TimerService.timerDone) { + root.popouts.locked = false; + root.popouts.hasCurrent = false; + } + } + } + Repeater { id: repeater diff --git a/modules/bar/components/Clock.qml b/modules/bar/components/Clock.qml index ffe599afd..72b5b7a35 100644 --- a/modules/bar/components/Clock.qml +++ b/modules/bar/components/Clock.qml @@ -67,5 +67,47 @@ StyledRect { font.family: Tokens.font.family.mono color: root.colour } + + Loader { + active: TimerService.active && !(Config.bar.status.showTimer ?? false) + visible: active + anchors.horizontalCenter: parent.horizontalCenter + + sourceComponent: Column { + spacing: Tokens.spacing.small + + Rectangle { + width: 30 + height: 1 + color: root.colour + opacity: 0.2 + anchors.horizontalCenter: parent.horizontalCenter + } + + MaterialIcon { + text: "timer" + font.pointSize: Tokens.font.size.small + color: TimerService.running ? root.colour : Qt.alpha(root.colour, 0.5) + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + horizontalAlignment: StyledText.AlignHCenter + text: { + const s = TimerService.remainingSeconds; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) + return h + "\n" + String(m).padStart(2, "0") + "\n" + String(sec).padStart(2, "0"); + return String(m).padStart(2, "0") + "\n" + String(sec).padStart(2, "0"); + } + font.pointSize: Tokens.font.size.smaller + font.family: Tokens.font.family.mono + color: root.colour + anchors.horizontalCenter: parent.horizontalCenter + } + } + } } } diff --git a/modules/bar/components/StatusIcons.qml b/modules/bar/components/StatusIcons.qml index 900e55747..33e7189c4 100644 --- a/modules/bar/components/StatusIcons.qml +++ b/modules/bar/components/StatusIcons.qml @@ -15,6 +15,7 @@ StyledRect { property color colour: Colours.palette.m3secondary readonly property alias items: iconColumn + readonly property alias timerItem: timerLoader color: Colours.tPalette.m3surfaceContainer radius: Tokens.rounding.full @@ -33,6 +34,59 @@ StyledRect { spacing: Tokens.spacing.smaller / 2 + // Timer status icon + WrappedLoader { + id: timerLoader + name: "timer" + active: Config.bar.status.showTimer + + sourceComponent: Item { + implicitWidth: Tokens.sizes.bar.innerWidth + implicitHeight: TimerService.active ? timerText.implicitHeight : timerIcon.implicitHeight + + MaterialIcon { + id: timerIcon + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + text: "timer" + color: root.colour + opacity: TimerService.active ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + StyledText { + id: timerText + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + text: { + const s = TimerService.remainingSeconds; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) + return h + "\n" + String(m).padStart(2, "0") + "\n" + String(sec).padStart(2, "0"); + return String(m).padStart(2, "0") + "\n" + String(sec).padStart(2, "0"); + } + font.pointSize: Tokens.font.size.smaller + font.family: Tokens.font.family.mono + color: TimerService.running ? root.colour : Qt.alpha(root.colour, 0.5) + horizontalAlignment: Text.AlignHCenter + opacity: TimerService.active ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + Behavior on implicitHeight { + Anim {} + } + } + } + // Lock keys status WrappedLoader { name: "lockstatus" diff --git a/modules/bar/popouts/Content.qml b/modules/bar/popouts/Content.qml index 42c5a7667..50e2c2619 100644 --- a/modules/bar/popouts/Content.qml +++ b/modules/bar/popouts/Content.qml @@ -121,6 +121,13 @@ Item { sourceComponent: KbLayout {} } + Popout { + name: "timer" + sourceComponent: TimerPopout { + popouts: root.popouts + } + } + Popout { name: "lockstatus" sourceComponent: LockStatus {} diff --git a/modules/bar/popouts/PopoutState.qml b/modules/bar/popouts/PopoutState.qml index 6be8169b4..e631d4483 100644 --- a/modules/bar/popouts/PopoutState.qml +++ b/modules/bar/popouts/PopoutState.qml @@ -3,6 +3,7 @@ import QtQuick QtObject { property string currentName property bool hasCurrent + property bool locked: false signal detachRequested(mode: string) } diff --git a/modules/bar/popouts/TimerPopout.qml b/modules/bar/popouts/TimerPopout.qml new file mode 100644 index 000000000..b1a22d082 --- /dev/null +++ b/modules/bar/popouts/TimerPopout.qml @@ -0,0 +1,350 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.utils + +Item { + id: root + + required property PopoutState popouts + + property int hours: 0 + property int minutes: 0 + property int seconds: 0 + + implicitWidth: layout.implicitWidth + implicitHeight: layout.implicitHeight + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Tokens.spacing.small + + RowLayout { + Layout.topMargin: Tokens.padding.normal + Layout.rightMargin: Tokens.padding.small + + MaterialIcon { + text: "timer" + color: Colours.palette.m3tertiary + } + + StyledText { + Layout.fillWidth: true + text: TimerService.timerDone ? qsTr("Your time is up!") : qsTr("Timer") + font.weight: 500 + color: Colours.palette.m3onSurface + } + } + + // Timer done: Bongo Cat + ColumnLayout { + visible: TimerService.timerDone + Layout.fillWidth: true + spacing: Tokens.spacing.small + + AnimatedImage { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 180 + Layout.preferredHeight: 140 + + source: Paths.absolutePath(GlobalConfig.paths.mediaGif) + speed: 2.0 + playing: TimerService.timerDone + fillMode: AnimatedImage.PreserveAspectFit + asynchronous: true + } + + IconTextButton { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.small + Layout.rightMargin: Tokens.padding.small + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Dismiss") + icon: "close" + onClicked: { + TimerService.timerDone = false; + } + } + } + + // Idle: set timer + ColumnLayout { + visible: !TimerService.active && !TimerService.timerDone + Layout.fillWidth: true + spacing: Tokens.spacing.small + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + SpinGroup { + label: qsTr("H") + max: 23 + value: root.hours + onValueModified: v => { + root.hours = v; + } + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("M") + max: 59 + value: root.minutes + onValueModified: v => { + root.minutes = v; + } + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("S") + max: 59 + value: root.seconds + onValueModified: v => { + root.seconds = v; + } + } + } + + IconTextButton { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.small + Layout.rightMargin: Tokens.padding.small + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Start") + icon: "play_arrow" + onClicked: TimerService.start(root.hours, root.minutes, root.seconds) + } + } + + // Active: running/paused + ColumnLayout { + visible: TimerService.active && !TimerService.timerDone + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Tokens.padding.small + text: TimerService.remainingFormatted + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + font.weight: 500 + horizontalAlignment: Text.AlignHCenter + } + + Item { + Layout.fillWidth: true + Layout.rightMargin: Tokens.padding.small + implicitHeight: 4 + + StyledRect { + width: parent.width + height: parent.height + radius: 2 + color: Colours.tPalette.m3surfaceContainerHigh + } + + StyledRect { + width: Math.max(radius * 2, TimerService.progress * parent.width) + height: parent.height + radius: 2 + color: Colours.palette.m3primary + + Behavior on width { + Anim {} + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.rightMargin: Tokens.padding.small + Layout.topMargin: Tokens.spacing.small + spacing: Tokens.spacing.small + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + verticalPadding: Tokens.padding.small + text: TimerService.running ? qsTr("Pause") : qsTr("Resume") + icon: TimerService.running ? "pause" : "play_arrow" + onClicked: { + if (TimerService.running) + TimerService.pause(); + else + TimerService.resume(); + } + } + + IconTextButton { + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + verticalPadding: Tokens.padding.small + text: qsTr("Cancel") + icon: "close" + onClicked: TimerService.cancel() + } + } + } + } + + component SpinGroup: ColumnLayout { + id: spinGroup + + property int value: 0 + property int min: 0 + property int max: 99 + property string label: "" + + signal valueModified(int v) + + spacing: 2 + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: spinGroup.label + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.alignment: Qt.AlignHCenter + color: "transparent" + radius: Tokens.rounding.small + implicitWidth: numBox.implicitWidth + implicitHeight: arrowIcon.implicitHeight + Tokens.padding.small * 2 + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + const v = Math.min(spinGroup.max, spinGroup.value + 1); + spinGroup.value = v; + spinGroup.valueModified(v); + numInput.text = String(v).padStart(2, "0"); + } + } + + MaterialIcon { + id: arrowIcon + anchors.centerIn: parent + text: "keyboard_arrow_up" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + + StyledRect { + id: numBox + Layout.alignment: Qt.AlignHCenter + color: Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.small + implicitWidth: numText.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: numText.implicitHeight + Tokens.padding.small * 2 + + StyledText { + id: numText + visible: false + text: "00" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + } + + TextInput { + id: numInput + anchors.centerIn: parent + width: numText.implicitWidth + + text: String(spinGroup.value).padStart(2, "0") + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurface + selectionColor: Colours.palette.m3primary + selectedTextColor: Colours.palette.m3onPrimary + horizontalAlignment: TextInput.AlignHCenter + inputMethodHints: Qt.ImhDigitsOnly + maximumLength: 2 + selectByMouse: true + + onTextEdited: { + const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); + spinGroup.value = v; + spinGroup.valueModified(v); + } + + function commit(): void { + const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); + spinGroup.value = v; + spinGroup.valueModified(v); + text = String(v).padStart(2, "0"); + } + + onEditingFinished: commit() + onActiveFocusChanged: { + if (activeFocus) + selectAll(); + else + commit(); + } + Keys.onEscapePressed: { + text = String(spinGroup.value).padStart(2, "0"); + focus = false; + } + } + } + + StyledRect { + Layout.alignment: Qt.AlignHCenter + color: "transparent" + radius: Tokens.rounding.small + implicitWidth: numBox.implicitWidth + implicitHeight: arrowIcon.implicitHeight + Tokens.padding.small * 2 + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + const v = Math.max(spinGroup.min, spinGroup.value - 1); + spinGroup.value = v; + spinGroup.valueModified(v); + numInput.text = String(v).padStart(2, "0"); + } + } + + MaterialIcon { + anchors.centerIn: parent + text: "keyboard_arrow_down" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + } +} diff --git a/modules/bar/popouts/Wrapper.qml b/modules/bar/popouts/Wrapper.qml index 7a66d3b97..391a51f3d 100644 --- a/modules/bar/popouts/Wrapper.qml +++ b/modules/bar/popouts/Wrapper.qml @@ -27,6 +27,7 @@ Item { property alias currentName: popoutState.currentName property alias hasCurrent: popoutState.hasCurrent + property alias locked: popoutState.locked property real currentCenter property string detachedMode @@ -78,8 +79,8 @@ Item { } Keys.onPressed: event => { - // Don't intercept keys when password popout is active - let it handle them - if (currentName === "wirelesspassword") { + // Don't intercept keys when these popouts are active - let them handle input + if (currentName === "wirelesspassword" || currentName === "timer") { event.accepted = false; } } @@ -97,7 +98,7 @@ Item { } Binding { - when: root.isDetached || (root.hasCurrent && root.currentName === "wirelesspassword") + when: root.isDetached || (root.hasCurrent && (root.currentName === "wirelesspassword" || root.currentName === "timer")) target: QsWindow.window property: "WlrLayershell.keyboardFocus" diff --git a/modules/controlcenter/taskbar/TaskbarPane.qml b/modules/controlcenter/taskbar/TaskbarPane.qml index 601fc09fe..e32d7ea1d 100644 --- a/modules/controlcenter/taskbar/TaskbarPane.qml +++ b/modules/controlcenter/taskbar/TaskbarPane.qml @@ -35,6 +35,7 @@ Item { property bool showBluetooth: Config.bar.status.showBluetooth ?? true property bool showBattery: Config.bar.status.showBattery ?? true property bool showLockStatus: Config.bar.status.showLockStatus ?? true + property bool showTimer: Config.bar.status.showTimer ?? false property bool trayBackground: Config.bar.tray.background ?? false property bool trayCompact: Config.bar.tray.compact ?? false property bool trayRecolour: Config.bar.tray.recolour ?? false @@ -70,6 +71,7 @@ Item { GlobalConfig.bar.status.showBluetooth = root.showBluetooth; GlobalConfig.bar.status.showBattery = root.showBattery; GlobalConfig.bar.status.showLockStatus = root.showLockStatus; + GlobalConfig.bar.status.showTimer = root.showTimer; GlobalConfig.bar.tray.background = root.trayBackground; GlobalConfig.bar.tray.compact = root.trayCompact; GlobalConfig.bar.tray.recolour = root.trayRecolour; @@ -260,6 +262,14 @@ Item { root.showLockStatus = checked; root.saveConfig(); } + }, + { + label: qsTr("Timer"), + propertyName: "showTimer", + onToggled: function (checked) { + root.showTimer = checked; + root.saveConfig(); + } } ] } diff --git a/modules/drawers/Interactions.qml b/modules/drawers/Interactions.qml index b89cb0077..e492c9b17 100644 --- a/modules/drawers/Interactions.qml +++ b/modules/drawers/Interactions.qml @@ -78,7 +78,7 @@ CustomMouseArea { if (!utilitiesShortcutActive) visibilities.utilities = false; - if (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) { + if (!popouts.locked && (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1)) { popouts.hasCurrent = false; bar.closeTray(); } @@ -216,7 +216,7 @@ CustomMouseArea { // Show popouts on hover if (x < bar.implicitWidth) { bar.checkPopout(y); - } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) { + } else if (!popouts.locked && (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) { popouts.hasCurrent = false; bar.closeTray(); } diff --git a/plugin/src/Caelestia/Config/barconfig.hpp b/plugin/src/Caelestia/Config/barconfig.hpp index 43d43205a..30dc27ad7 100644 --- a/plugin/src/Caelestia/Config/barconfig.hpp +++ b/plugin/src/Caelestia/Config/barconfig.hpp @@ -104,12 +104,25 @@ class BarStatus : public ConfigObject { CONFIG_PROPERTY(bool, showBluetooth, true) CONFIG_PROPERTY(bool, showBattery, true) CONFIG_PROPERTY(bool, showLockStatus, true) + CONFIG_PROPERTY(bool, showTimer, false) public: explicit BarStatus(QObject* parent = nullptr) : ConfigObject(parent) {} }; +class BarTimerConfig : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_GLOBAL_PROPERTY(QString, soundFile, u"/etc/xdg/quickshell/caelestia/assets/timer-done.wav"_s) + +public: + explicit BarTimerConfig(QObject* parent = nullptr) + : ConfigObject(parent) {} +}; + class BarClock : public ConfigObject { Q_OBJECT QML_ANONYMOUS @@ -117,10 +130,12 @@ class BarClock : public ConfigObject { CONFIG_PROPERTY(bool, background, false) CONFIG_PROPERTY(bool, showDate, false) CONFIG_PROPERTY(bool, showIcon, true) + CONFIG_SUBOBJECT(BarTimerConfig, timer) public: explicit BarClock(QObject* parent = nullptr) - : ConfigObject(parent) {} + : ConfigObject(parent) + , m_timer(new BarTimerConfig(this)) {} }; class BarConfig : public ConfigObject { diff --git a/services/TimerService.qml b/services/TimerService.qml new file mode 100644 index 000000000..de6a14273 --- /dev/null +++ b/services/TimerService.qml @@ -0,0 +1,120 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Caelestia.Config + +Singleton { + id: root + + property bool active: false + property bool running: false + property int totalSeconds: 0 + property int remainingSeconds: 0 + readonly property real progress: totalSeconds > 0 ? Math.max(0, Math.min(1, 1 - remainingSeconds / totalSeconds)) : 0 + + readonly property string remainingFormatted: { + const s = remainingSeconds; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) + return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; + return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; + } + + property bool timerDone: false + + signal finished() + + function start(h: int, m: int, s: int): void { + const total = h * 3600 + m * 60 + s; + if (total <= 0) + return; + timerDone = false; + totalSeconds = total; + remainingSeconds = total; + endTimeStore.endTime = Date.now() + total * 1000; + endTimeStore.total = total; + running = true; + active = true; + countdownTimer.start(); + } + + function pause(): void { + if (!running) + return; + running = false; + countdownTimer.stop(); + endTimeStore.endTime = Date.now() + remainingSeconds * 1000; + } + + function resume(): void { + if (running || !active) + return; + endTimeStore.endTime = Date.now() + remainingSeconds * 1000; + running = true; + countdownTimer.start(); + } + + function cancel(): void { + countdownTimer.stop(); + running = false; + active = false; + totalSeconds = 0; + remainingSeconds = 0; + endTimeStore.endTime = 0; + endTimeStore.total = 0; + } + + onFinished: { + const sf = GlobalConfig.bar.clock.timer?.soundFile ?? ""; + if (sf.length > 0) + Quickshell.execDetached(["paplay", sf]); + } + + Component.onCompleted: { + if (endTimeStore.endTime > 0 && endTimeStore.total > 0) { + const now = Date.now(); + if (endTimeStore.endTime > now) { + root.totalSeconds = endTimeStore.total; + root.remainingSeconds = Math.round((endTimeStore.endTime - now) / 1000); + root.running = true; + root.active = true; + countdownTimer.start(); + } else { + endTimeStore.endTime = 0; + endTimeStore.total = 0; + } + } + } + + Timer { + id: countdownTimer + + interval: 500 + repeat: true + + onTriggered: { + const remaining = Math.max(0, Math.round((endTimeStore.endTime - Date.now()) / 1000)); + root.remainingSeconds = remaining; + if (remaining <= 0) { + countdownTimer.stop(); + root.running = false; + root.active = false; + root.timerDone = true; + root.finished(); + } + } + } + + PersistentProperties { + id: endTimeStore + + property real endTime: 0 + property int total: 0 + + reloadableId: "timer" + } +} From 49805b759eecce18fb40e4343058226e422b61da Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Tue, 26 May 2026 17:58:17 +0700 Subject: [PATCH 02/10] feat: timer/alarm/reminder panel in dashboard with animated overlay --- components/DashboardState.qml | 3 + components/DrawerVisibilities.qml | 1 + modules/bar/Bar.qml | 46 +- modules/bar/components/Clock.qml | 2 +- modules/bar/components/StatusIcons.qml | 54 -- modules/bar/popouts/TimerPopout.qml | 460 ++++++++++++------ modules/controlcenter/taskbar/TaskbarPane.qml | 21 +- modules/dashboard/Dash.qml | 162 +++--- modules/dashboard/FiringOverlay.qml | 62 +++ modules/dashboard/Wrapper.qml | 35 +- modules/dashboard/dash/Calendar.qml | 429 ++++++++++++++-- modules/dashboard/dash/DashTimerPanel.qml | 395 +++++++++++++++ modules/dashboard/dash/DateTime.qml | 91 ++++ plugin/src/Caelestia/Config/barconfig.hpp | 2 +- services/AlarmService.qml | 87 ++++ services/ReminderService.qml | 106 ++++ 16 files changed, 1590 insertions(+), 366 deletions(-) create mode 100644 modules/dashboard/FiringOverlay.qml create mode 100644 modules/dashboard/dash/DashTimerPanel.qml create mode 100644 services/AlarmService.qml create mode 100644 services/ReminderService.qml diff --git a/components/DashboardState.qml b/components/DashboardState.qml index b4355cd7b..faa57c056 100644 --- a/components/DashboardState.qml +++ b/components/DashboardState.qml @@ -3,4 +3,7 @@ import Quickshell PersistentProperties { property int currentTab property date currentDate: new Date() + property bool timerPanelOpen: false + property int timerPanelTab: 0 + property string reminderPickedDate: "" } diff --git a/components/DrawerVisibilities.qml b/components/DrawerVisibilities.qml index 3286e319f..47f3fb6df 100644 --- a/components/DrawerVisibilities.qml +++ b/components/DrawerVisibilities.qml @@ -8,4 +8,5 @@ PersistentProperties { property bool dashboard property bool utilities property bool sidebar + property bool fireOverlay } diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml index d99d3f8ed..073b48814 100644 --- a/modules/bar/Bar.qml +++ b/modules/bar/Bar.qml @@ -76,7 +76,7 @@ ColumnLayout { popouts.currentName = id.toLowerCase(); popouts.currentCenter = (ch.item as Item).mapToItem(root, 0, (ch.item as Item).implicitHeight / 2).y ?? 0; popouts.hasCurrent = true; - } else if (id === "clock" && (Config.bar.clock.timer?.enabled ?? true) && !(Config.bar.status.showTimer ?? false)) { + } else if (id === "clock" && (Config.bar.clock.timer?.enabled ?? true)) { popouts.currentName = "timer"; popouts.currentCenter = ch.y + ch.height / 2; popouts.hasCurrent = true; @@ -113,42 +113,22 @@ ColumnLayout { Connections { target: TimerService + function onFinished(): void { + root.visibilities.dashboard = true; + } + } + Connections { + target: AlarmService function onFinished(): void { - if (Config.bar.status.showTimer ?? false) { - for (let i = 0; i < repeater.count; i++) { - const loader = repeater.itemAt(i) as WrappedLoader; - if (loader?.enabled && loader.id === "statusIcons") { - const si = loader.item as StatusIcons; - const ti = si?.timerItem; - if (ti) { - root.popouts.currentName = "timer"; - root.popouts.currentCenter = Qt.binding(() => ti.mapToItem(root, 0, ti.implicitHeight / 2).y); - root.popouts.hasCurrent = true; - root.popouts.locked = true; - } - break; - } - } - } else { - for (let i = 0; i < repeater.count; i++) { - const loader = repeater.itemAt(i) as WrappedLoader; - if (loader?.enabled && loader.id === "clock") { - root.popouts.currentName = "timer"; - root.popouts.currentCenter = loader.y + loader.height / 2; - root.popouts.hasCurrent = true; - root.popouts.locked = true; - break; - } - } - } + root.visibilities.dashboard = true; } + } - function onTimerDoneChanged(): void { - if (!TimerService.timerDone) { - root.popouts.locked = false; - root.popouts.hasCurrent = false; - } + Connections { + target: ReminderService + function onFired(text: string): void { + root.visibilities.dashboard = true; } } diff --git a/modules/bar/components/Clock.qml b/modules/bar/components/Clock.qml index 72b5b7a35..3154f3f4d 100644 --- a/modules/bar/components/Clock.qml +++ b/modules/bar/components/Clock.qml @@ -69,7 +69,7 @@ StyledRect { } Loader { - active: TimerService.active && !(Config.bar.status.showTimer ?? false) + active: TimerService.active && (Config.bar.clock.timer?.enabled ?? true) visible: active anchors.horizontalCenter: parent.horizontalCenter diff --git a/modules/bar/components/StatusIcons.qml b/modules/bar/components/StatusIcons.qml index 33e7189c4..900e55747 100644 --- a/modules/bar/components/StatusIcons.qml +++ b/modules/bar/components/StatusIcons.qml @@ -15,7 +15,6 @@ StyledRect { property color colour: Colours.palette.m3secondary readonly property alias items: iconColumn - readonly property alias timerItem: timerLoader color: Colours.tPalette.m3surfaceContainer radius: Tokens.rounding.full @@ -34,59 +33,6 @@ StyledRect { spacing: Tokens.spacing.smaller / 2 - // Timer status icon - WrappedLoader { - id: timerLoader - name: "timer" - active: Config.bar.status.showTimer - - sourceComponent: Item { - implicitWidth: Tokens.sizes.bar.innerWidth - implicitHeight: TimerService.active ? timerText.implicitHeight : timerIcon.implicitHeight - - MaterialIcon { - id: timerIcon - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - text: "timer" - color: root.colour - opacity: TimerService.active ? 0 : 1 - - Behavior on opacity { - Anim {} - } - } - - StyledText { - id: timerText - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - text: { - const s = TimerService.remainingSeconds; - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - if (h > 0) - return h + "\n" + String(m).padStart(2, "0") + "\n" + String(sec).padStart(2, "0"); - return String(m).padStart(2, "0") + "\n" + String(sec).padStart(2, "0"); - } - font.pointSize: Tokens.font.size.smaller - font.family: Tokens.font.family.mono - color: TimerService.running ? root.colour : Qt.alpha(root.colour, 0.5) - horizontalAlignment: Text.AlignHCenter - opacity: TimerService.active ? 1 : 0 - - Behavior on opacity { - Anim {} - } - } - - Behavior on implicitHeight { - Anim {} - } - } - } - // Lock keys status WrappedLoader { name: "lockstatus" diff --git a/modules/bar/popouts/TimerPopout.qml b/modules/bar/popouts/TimerPopout.qml index b1a22d082..81b8173ca 100644 --- a/modules/bar/popouts/TimerPopout.qml +++ b/modules/bar/popouts/TimerPopout.qml @@ -13,9 +13,14 @@ Item { required property PopoutState popouts - property int hours: 0 - property int minutes: 0 - property int seconds: 0 + property int popoutTab: 0 + + property int timerHours: 0 + property int timerMinutes: 0 + property int timerSeconds: 0 + + property int alarmHours: AlarmService.alarmHour + property int alarmMinutes: AlarmService.alarmMinute implicitWidth: layout.implicitWidth implicitHeight: layout.implicitHeight @@ -26,191 +31,325 @@ Item { anchors.fill: parent spacing: Tokens.spacing.small + // Tab row RowLayout { - Layout.topMargin: Tokens.padding.normal - Layout.rightMargin: Tokens.padding.small + Layout.topMargin: Tokens.padding.small + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.smaller - MaterialIcon { - text: "timer" - color: Colours.palette.m3tertiary - } + component TabChip: StyledRect { + id: chip - StyledText { - Layout.fillWidth: true - text: TimerService.timerDone ? qsTr("Your time is up!") : qsTr("Timer") - font.weight: 500 - color: Colours.palette.m3onSurface - } - } + property bool chipActive: false + property string chipText: "" + property string chipIcon: "" + signal clicked() - // Timer done: Bongo Cat - ColumnLayout { - visible: TimerService.timerDone - Layout.fillWidth: true - spacing: Tokens.spacing.small - - AnimatedImage { - Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: 180 - Layout.preferredHeight: 140 - - source: Paths.absolutePath(GlobalConfig.paths.mediaGif) - speed: 2.0 - playing: TimerService.timerDone - fillMode: AnimatedImage.PreserveAspectFit - asynchronous: true - } + implicitWidth: chipRow.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: chipRow.implicitHeight + Tokens.padding.small * 2 - IconTextButton { - Layout.fillWidth: true - Layout.topMargin: Tokens.spacing.small - Layout.rightMargin: Tokens.padding.small - inactiveColour: Colours.palette.m3primaryContainer - inactiveOnColour: Colours.palette.m3onPrimaryContainer - verticalPadding: Tokens.padding.small - text: qsTr("Dismiss") - icon: "close" - onClicked: { - TimerService.timerDone = false; - } - } - } + color: chipActive ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.full - // Idle: set timer - ColumnLayout { - visible: !TimerService.active && !TimerService.timerDone - Layout.fillWidth: true - spacing: Tokens.spacing.small - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Tokens.spacing.small - - SpinGroup { - label: qsTr("H") - max: 23 - value: root.hours - onValueModified: v => { - root.hours = v; - } + StateLayer { + radius: parent.radius + onClicked: chip.clicked() } - StyledText { - Layout.alignment: Qt.AlignVCenter - text: ":" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurfaceVariant - } + RowLayout { + id: chipRow + anchors.centerIn: parent + spacing: Tokens.spacing.smaller - SpinGroup { - label: qsTr("M") - max: 59 - value: root.minutes - onValueModified: v => { - root.minutes = v; + MaterialIcon { + text: chip.chipIcon + font.pointSize: Tokens.font.size.small + color: chip.chipActive ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant } - } - StyledText { - Layout.alignment: Qt.AlignVCenter - text: ":" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurfaceVariant - } - - SpinGroup { - label: qsTr("S") - max: 59 - value: root.seconds - onValueModified: v => { - root.seconds = v; + StyledText { + id: chipLabel + text: chip.chipText + font.pointSize: Tokens.font.size.small + color: chip.chipActive ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant } } } - IconTextButton { - Layout.fillWidth: true - Layout.topMargin: Tokens.spacing.small - Layout.rightMargin: Tokens.padding.small - inactiveColour: Colours.palette.m3primaryContainer - inactiveOnColour: Colours.palette.m3onPrimaryContainer - verticalPadding: Tokens.padding.small - text: qsTr("Start") - icon: "play_arrow" - onClicked: TimerService.start(root.hours, root.minutes, root.seconds) + TabChip { + chipText: qsTr("Timer") + chipIcon: "timer" + chipActive: root.popoutTab === 0 + onClicked: root.popoutTab = 0 + } + + TabChip { + chipText: qsTr("Alarm") + chipIcon: "alarm" + chipActive: root.popoutTab === 1 + onClicked: root.popoutTab = 1 } } - // Active: running/paused - ColumnLayout { - visible: TimerService.active && !TimerService.timerDone - Layout.fillWidth: true - spacing: Tokens.spacing.small + StackLayout { + currentIndex: root.popoutTab - StyledText { - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Tokens.padding.small - text: TimerService.remainingFormatted - font.pointSize: Tokens.font.size.extraLarge - font.family: Tokens.font.family.mono - font.weight: 500 - horizontalAlignment: Text.AlignHCenter - } + // --- Timer tab --- + ColumnLayout { + spacing: Tokens.spacing.small + + // Timer done: Bongo Cat + ColumnLayout { + visible: TimerService.timerDone + Layout.fillWidth: true + spacing: Tokens.spacing.small + + AnimatedImage { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 180 + Layout.preferredHeight: 140 + + source: Paths.absolutePath(GlobalConfig.paths.mediaGif) + speed: 2.0 + playing: TimerService.timerDone + fillMode: AnimatedImage.PreserveAspectFit + asynchronous: true + } + + IconTextButton { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Dismiss") + icon: "close" + onClicked: { + TimerService.timerDone = false; + } + } + } - Item { - Layout.fillWidth: true - Layout.rightMargin: Tokens.padding.small - implicitHeight: 4 + // Idle: set timer + ColumnLayout { + visible: !TimerService.active && !TimerService.timerDone + Layout.fillWidth: true + spacing: Tokens.spacing.small + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + SpinGroup { + label: qsTr("H") + max: 23 + value: root.timerHours + onValueModified: v => { + root.timerHours = v; + } + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("M") + max: 59 + value: root.timerMinutes + onValueModified: v => { + root.timerMinutes = v; + } + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("S") + max: 59 + value: root.timerSeconds + onValueModified: v => { + root.timerSeconds = v; + } + } + } - StyledRect { - width: parent.width - height: parent.height - radius: 2 - color: Colours.tPalette.m3surfaceContainerHigh + IconTextButton { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Start") + icon: "play_arrow" + onClicked: TimerService.start(root.timerHours, root.timerMinutes, root.timerSeconds) + } } - StyledRect { - width: Math.max(radius * 2, TimerService.progress * parent.width) - height: parent.height - radius: 2 - color: Colours.palette.m3primary + // Active: running/paused + ColumnLayout { + visible: TimerService.active && !TimerService.timerDone + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Tokens.padding.small + text: TimerService.remainingFormatted + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + font.weight: 500 + horizontalAlignment: Text.AlignHCenter + } + + Item { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + implicitHeight: 4 + + StyledRect { + width: parent.width + height: parent.height + radius: 2 + color: Colours.tPalette.m3surfaceContainerHigh + } + + StyledRect { + width: Math.max(radius * 2, TimerService.progress * parent.width) + height: parent.height + radius: 2 + color: Colours.palette.m3primary + + Behavior on width { + Anim {} + } + } + } - Behavior on width { - Anim {} + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Tokens.spacing.small + spacing: Tokens.spacing.small + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + verticalPadding: Tokens.padding.small + text: TimerService.running ? qsTr("Pause") : qsTr("Resume") + icon: TimerService.running ? "pause" : "play_arrow" + onClicked: { + if (TimerService.running) + TimerService.pause(); + else + TimerService.resume(); + } + } + + IconTextButton { + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + verticalPadding: Tokens.padding.small + text: qsTr("Cancel") + icon: "close" + onClicked: TimerService.cancel() + } } } } - RowLayout { - Layout.fillWidth: true - Layout.rightMargin: Tokens.padding.small - Layout.topMargin: Tokens.spacing.small + // --- Alarm tab --- + ColumnLayout { spacing: Tokens.spacing.small - IconTextButton { + // Active alarm indicator + ColumnLayout { + visible: AlarmService.active Layout.fillWidth: true - inactiveColour: Colours.palette.m3secondaryContainer - inactiveOnColour: Colours.palette.m3onSecondaryContainer - verticalPadding: Tokens.padding.small - text: TimerService.running ? qsTr("Pause") : qsTr("Resume") - icon: TimerService.running ? "pause" : "play_arrow" - onClicked: { - if (TimerService.running) - TimerService.pause(); - else - TimerService.resume(); + spacing: Tokens.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: AlarmService.alarmTimeFormatted + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + font.weight: 500 + color: Colours.palette.m3primary + } + + IconTextButton { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + verticalPadding: Tokens.padding.small + text: qsTr("Cancel alarm") + icon: "close" + onClicked: AlarmService.cancelAlarm() } } - IconTextButton { - inactiveColour: Colours.palette.m3errorContainer - inactiveOnColour: Colours.palette.m3onErrorContainer - verticalPadding: Tokens.padding.small - text: qsTr("Cancel") - icon: "close" - onClicked: TimerService.cancel() + // Set alarm + ColumnLayout { + visible: !AlarmService.active + Layout.fillWidth: true + spacing: Tokens.spacing.small + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + SpinGroup { + label: qsTr("H") + max: 23 + value: root.alarmHours + onValueModified: v => { + root.alarmHours = v; + } + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("M") + max: 59 + value: root.alarmMinutes + onValueModified: v => { + root.alarmMinutes = v; + } + } + } + + IconTextButton { + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Set alarm") + icon: "alarm" + onClicked: AlarmService.setAlarm(root.alarmHours, root.alarmMinutes) + } } } } @@ -226,6 +365,11 @@ Item { signal valueModified(int v) + onValueChanged: { + if (!numInput.activeFocus) + numInput.text = String(value).padStart(2, "0"); + } + spacing: 2 StyledText { @@ -283,7 +427,7 @@ Item { anchors.centerIn: parent width: numText.implicitWidth - text: String(spinGroup.value).padStart(2, "0") + Component.onCompleted: text = String(spinGroup.value).padStart(2, "0") font.pointSize: Tokens.font.size.larger font.family: Tokens.font.family.mono color: Colours.palette.m3onSurface @@ -294,12 +438,6 @@ Item { maximumLength: 2 selectByMouse: true - onTextEdited: { - const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); - spinGroup.value = v; - spinGroup.valueModified(v); - } - function commit(): void { const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); spinGroup.value = v; diff --git a/modules/controlcenter/taskbar/TaskbarPane.qml b/modules/controlcenter/taskbar/TaskbarPane.qml index e32d7ea1d..fa342d93e 100644 --- a/modules/controlcenter/taskbar/TaskbarPane.qml +++ b/modules/controlcenter/taskbar/TaskbarPane.qml @@ -35,7 +35,7 @@ Item { property bool showBluetooth: Config.bar.status.showBluetooth ?? true property bool showBattery: Config.bar.status.showBattery ?? true property bool showLockStatus: Config.bar.status.showLockStatus ?? true - property bool showTimer: Config.bar.status.showTimer ?? false + property bool timerEnabled: Config.bar.clock.timer?.enabled ?? true property bool trayBackground: Config.bar.tray.background ?? false property bool trayCompact: Config.bar.tray.compact ?? false property bool trayRecolour: Config.bar.tray.recolour ?? false @@ -71,7 +71,7 @@ Item { GlobalConfig.bar.status.showBluetooth = root.showBluetooth; GlobalConfig.bar.status.showBattery = root.showBattery; GlobalConfig.bar.status.showLockStatus = root.showLockStatus; - GlobalConfig.bar.status.showTimer = root.showTimer; + GlobalConfig.bar.clock.timer.enabled = root.timerEnabled; GlobalConfig.bar.tray.background = root.trayBackground; GlobalConfig.bar.tray.compact = root.trayCompact; GlobalConfig.bar.tray.recolour = root.trayRecolour; @@ -262,14 +262,6 @@ Item { root.showLockStatus = checked; root.saveConfig(); } - }, - { - label: qsTr("Timer"), - propertyName: "showTimer", - onToggled: function (checked) { - root.showTimer = checked; - root.saveConfig(); - } } ] } @@ -590,6 +582,15 @@ Item { root.saveConfig(); } } + + SwitchRow { + label: qsTr("Enable timer & alarm") + checked: root.timerEnabled + onToggled: checked => { + root.timerEnabled = checked; + root.saveConfig(); + } + } } SectionContainer { diff --git a/modules/dashboard/Dash.qml b/modules/dashboard/Dash.qml index 6785ede8a..ba0fadef1 100644 --- a/modules/dashboard/Dash.qml +++ b/modules/dashboard/Dash.qml @@ -1,103 +1,139 @@ import "dash" +import QtQuick import QtQuick.Layouts import Caelestia.Config import qs.components import qs.components.filedialog import qs.services -GridLayout { +Item { id: root required property DrawerVisibilities visibilities required property DashboardState dashState required property FileDialog facePicker - rowSpacing: Tokens.spacing.normal - columnSpacing: Tokens.spacing.normal + implicitWidth: grid.implicitWidth + implicitHeight: grid.implicitHeight - Rect { - Layout.column: 2 - Layout.columnSpan: 3 - Layout.preferredWidth: user.implicitWidth - Layout.preferredHeight: user.implicitHeight + GridLayout { + id: grid + anchors.fill: parent - radius: Tokens.rounding.large + rowSpacing: Tokens.spacing.normal + columnSpacing: Tokens.spacing.normal - User { - id: user + Rect { + Layout.column: 2 + Layout.columnSpan: 3 + Layout.preferredWidth: user.implicitWidth + Layout.preferredHeight: user.implicitHeight - visibilities: root.visibilities - facePicker: root.facePicker - } - } + radius: Tokens.rounding.large - Rect { - Layout.row: 0 - Layout.columnSpan: 2 - Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth - Layout.fillHeight: true + User { + id: user - radius: Tokens.rounding.large * 1.5 + visibilities: root.visibilities + facePicker: root.facePicker + } + } - SmallWeather {} - } + Rect { + Layout.row: 0 + Layout.columnSpan: 2 + Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth + Layout.fillHeight: true - Rect { - Layout.row: 1 - Layout.preferredWidth: dateTime.implicitWidth - Layout.fillHeight: true + radius: Tokens.rounding.large * 1.5 - radius: Tokens.rounding.normal + SmallWeather {} + } - DateTime { - id: dateTime + Rect { + id: dateTimeRect + Layout.row: 1 + Layout.preferredWidth: dateTime.implicitWidth + Layout.fillHeight: true + radius: Tokens.rounding.normal + + DateTime { + id: dateTime + dashState: root.dashState + } } - } - Rect { - Layout.row: 1 - Layout.column: 1 - Layout.columnSpan: 3 - Layout.fillWidth: true - Layout.preferredHeight: calendar.implicitHeight + Rect { + id: calendarRect + Layout.row: 1 + Layout.column: 1 + Layout.columnSpan: 3 + Layout.fillWidth: true + Layout.preferredHeight: calendarLoader.implicitHeight + radius: Tokens.rounding.large + opacity: root.dashState.timerPanelOpen ? 0 : 1 + + Loader { + id: calendarLoader + anchors.fill: parent + sourceComponent: Calendar { + dashState: root.dashState + } + } + } - radius: Tokens.rounding.large + Rect { + Layout.row: 1 + Layout.column: 4 + Layout.preferredWidth: resources.implicitWidth + Layout.fillHeight: true - Calendar { - id: calendar + radius: Tokens.rounding.normal - dashState: root.dashState + Resources { + id: resources + } } - } - Rect { - Layout.row: 1 - Layout.column: 4 - Layout.preferredWidth: resources.implicitWidth - Layout.fillHeight: true + Rect { + Layout.row: 0 + Layout.column: 5 + Layout.rowSpan: 2 + Layout.preferredWidth: media.implicitWidth + Layout.fillHeight: true - radius: Tokens.rounding.normal + radius: Tokens.rounding.large * 2 - Resources { - id: resources + Media { + id: media + } } - } - - Rect { - Layout.row: 0 - Layout.column: 5 - Layout.rowSpan: 2 - Layout.preferredWidth: media.implicitWidth - Layout.fillHeight: true - - radius: Tokens.rounding.large * 2 - Media { - id: media + component Rect: StyledRect { + color: Colours.tPalette.m3surfaceContainer } } - component Rect: StyledRect { + // Overlay: expands rightward starting from the RIGHT edge of the clock block. + // Only covers the calendar area - clock stays fully visible. + StyledRect { + id: timerOverlay color: Colours.tPalette.m3surfaceContainer + radius: Tokens.rounding.large + z: 1 + clip: true + + x: calendarRect.x + y: calendarRect.y + height: calendarRect.height + + width: root.dashState.timerPanelOpen ? calendarRect.width : 0 + + Behavior on width { Anim { type: Anim.DefaultSpatial } } + + DashTimerPanel { + anchors.fill: parent + dashState: root.dashState + } } } diff --git a/modules/dashboard/FiringOverlay.qml b/modules/dashboard/FiringOverlay.qml new file mode 100644 index 000000000..52f5e7d84 --- /dev/null +++ b/modules/dashboard/FiringOverlay.qml @@ -0,0 +1,62 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.utils + +Item { + id: root + + required property DrawerVisibilities visibilities + + readonly property bool isReminder: ReminderService.reminderFired && !TimerService.timerDone && !AlarmService.alarmFired + + function dismiss(): void { + TimerService.timerDone = false; + AlarmService.alarmFired = false; + ReminderService.dismissCurrent(); + root.visibilities.fireOverlay = false; + root.visibilities.dashboard = false; + } + + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.normal + + AnimatedImage { + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 300 + Layout.preferredHeight: 220 + + source: Paths.absolutePath(GlobalConfig.paths.mediaGif) + speed: 2.0 + playing: true + fillMode: AnimatedImage.PreserveAspectFit + asynchronous: true + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.isReminder ? ReminderService.currentReminderText : qsTr("Your time is up!") + font.pointSize: Tokens.font.size.extraLarge + font.weight: 600 + horizontalAlignment: Text.AlignHCenter + color: Colours.palette.m3onSurface + } + + IconTextButton { + Layout.alignment: Qt.AlignHCenter + Layout.minimumWidth: 200 + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.normal + text: qsTr("Dismiss") + icon: "check" + onClicked: root.dismiss() + } + } +} diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml index f7f037426..6b1b376c2 100644 --- a/modules/dashboard/Wrapper.qml +++ b/modules/dashboard/Wrapper.qml @@ -6,13 +6,14 @@ import Caelestia import Caelestia.Config import qs.components import qs.components.filedialog +import qs.services import qs.utils Item { id: root required property DrawerVisibilities visibilities - readonly property bool needsKeyboard: (content.item as Content)?.needsKeyboard ?? false + readonly property bool needsKeyboard: !fireActive && (dashState.timerPanelOpen || ((content.item as Content)?.needsKeyboard ?? false)) readonly property DashboardState dashState: DashboardState { reloadableId: "dashboardState" } @@ -28,14 +29,24 @@ Item { } } - readonly property real nonAnimHeight: state === "visible" ? ((content.item as Content)?.nonAnimHeight ?? 0) : 0 - readonly property bool shouldBeActive: visibilities.dashboard && Config.dashboard.enabled + readonly property bool fireActive: TimerService.timerDone || AlarmService.alarmFired || ReminderService.reminderFired + property bool _wasOpenBeforeFire: false + + onFireActiveChanged: { + if (fireActive && !_wasOpenBeforeFire) { + _wasOpenBeforeFire = visibilities.dashboard; + visibilities.dashboard = true; + } + } + + readonly property real nonAnimHeight: !fireActive ? ((content.item as Content)?.nonAnimHeight ?? 0) : 0 + readonly property bool shouldBeActive: (visibilities.dashboard && Config.dashboard.enabled) || fireActive property real offsetScale: shouldBeActive ? 0 : 1 visible: offsetScale < 1 anchors.topMargin: (-implicitHeight - 5) * offsetScale - implicitHeight: content.implicitHeight - implicitWidth: content.implicitWidth || 854 // Hard coded fallback for first open + implicitHeight: fireActive ? (fireContent.implicitHeight || 400) : (content.implicitHeight) + implicitWidth: fireActive ? (fireContent.implicitWidth || 800) : (content.implicitWidth || 854) opacity: 1 - offsetScale Behavior on offsetScale { @@ -50,7 +61,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - active: root.shouldBeActive || root.visible + active: root.shouldBeActive && !root.fireActive sourceComponent: Content { visibilities: root.visibilities @@ -58,4 +69,16 @@ Item { facePicker: root.facePicker } } + + Loader { + id: fireContent + + anchors.fill: parent + + active: root.fireActive + + sourceComponent: FiringOverlay { + visibilities: root.visibilities + } + } } diff --git a/modules/dashboard/dash/Calendar.qml b/modules/dashboard/dash/Calendar.qml index e2af3c015..7de44df98 100644 --- a/modules/dashboard/dash/Calendar.qml +++ b/modules/dashboard/dash/Calendar.qml @@ -16,6 +16,19 @@ CustomMouseArea { readonly property int currMonth: dashState.currentDate.getMonth() readonly property int currYear: dashState.currentDate.getFullYear() + readonly property bool reminderMode: dashState.timerPanelOpen && dashState.timerPanelTab === 2 + readonly property bool datePickMode: reminderMode && dashState.reminderPickedDate === "" + readonly property bool dateSetMode: reminderMode && dashState.reminderPickedDate !== "" + + property string viewingReminderId: "" + readonly property bool reminderDetailMode: !reminderMode && viewingReminderId !== "" + readonly property var viewingReminder: viewingReminderId !== "" + ? (ReminderService.reminders.find(r => r.id === viewingReminderId) ?? null) + : null + + property int reminderHours: 0 + property int reminderMinutes: 0 + property string reminderText: "" function onWheel(event: WheelEvent): void { if (event.angleDelta.y > 0) @@ -26,10 +39,17 @@ CustomMouseArea { anchors.left: parent.left anchors.right: parent.right - implicitHeight: inner.implicitHeight + inner.anchors.margins * 2 + clip: true + implicitHeight: inner.anchors.margins * 2 + + monthNavigationRow.implicitHeight + inner.spacing + + daysRow.implicitHeight + inner.spacing + + calGridItem.implicitHeight acceptedButtons: Qt.MiddleButton - onClicked: root.dashState.currentDate = new Date() + onClicked: { + if (!reminderMode) + root.dashState.currentDate = new Date(); + } ColumnLayout { id: inner @@ -38,26 +58,28 @@ CustomMouseArea { anchors.margins: Tokens.padding.large spacing: Tokens.spacing.small + // Month navigation row RowLayout { id: monthNavigationRow Layout.fillWidth: true spacing: Tokens.spacing.small + opacity: (root.dateSetMode || root.reminderDetailMode) ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { Anim {} } Item { implicitWidth: implicitHeight implicitHeight: prevMonthText.implicitHeight + Tokens.padding.small * 2 StateLayer { - id: prevMonthStateLayer - radius: Tokens.rounding.full onClicked: root.dashState.currentDate = new Date(root.currYear, root.currMonth - 1, 1) } MaterialIcon { id: prevMonthText - anchors.centerIn: parent text: "chevron_left" color: Colours.palette.m3tertiary @@ -68,30 +90,24 @@ CustomMouseArea { Item { Layout.fillWidth: true - implicitWidth: monthYearDisplay.implicitWidth + Tokens.padding.small * 2 implicitHeight: monthYearDisplay.implicitHeight + Tokens.padding.small * 2 StateLayer { onClicked: { - root.dashState.currentDate = new Date(); + if (!root.reminderMode) + root.dashState.currentDate = new Date(); } - anchors.fill: monthYearDisplay anchors.margins: -Tokens.padding.small anchors.leftMargin: -Tokens.padding.normal anchors.rightMargin: -Tokens.padding.normal - radius: Tokens.rounding.full - disabled: { - const now = new Date(); - return root.currMonth === now.getMonth() && root.currYear === now.getFullYear(); - } + disabled: root.reminderMode || (root.currMonth === new Date().getMonth() && root.currYear === new Date().getFullYear()) } StyledText { id: monthYearDisplay - anchors.centerIn: parent text: grid.title color: Colours.palette.m3primary @@ -106,18 +122,12 @@ CustomMouseArea { implicitHeight: nextMonthText.implicitHeight + Tokens.padding.small * 2 StateLayer { - id: nextMonthStateLayer - - onClicked: { - root.dashState.currentDate = new Date(root.currYear, root.currMonth + 1, 1); - } - + onClicked: root.dashState.currentDate = new Date(root.currYear, root.currMonth + 1, 1) radius: Tokens.rounding.full } MaterialIcon { id: nextMonthText - anchors.centerIn: parent text: "chevron_right" color: Colours.palette.m3tertiary @@ -125,17 +135,200 @@ CustomMouseArea { font.weight: 700 } } + + } + + // Reminder date-set form (after picking a date) + ColumnLayout { + visible: root.dateSetMode + Layout.fillWidth: true + spacing: Tokens.spacing.small + opacity: root.dateSetMode ? 1 : 0 + + Behavior on opacity { Anim {} } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.dashState.reminderPickedDate + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + font.weight: 600 + color: Colours.palette.m3primary + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + SpinGroup { + label: qsTr("H") + max: 23 + value: root.reminderHours + onValueModified: v => root.reminderHours = v + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("M") + max: 59 + value: root.reminderMinutes + onValueModified: v => root.reminderMinutes = v + } + } + + StyledRect { + Layout.fillWidth: true + color: Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.small + implicitHeight: textField.implicitHeight + Tokens.padding.small * 2 + + TextInput { + id: textField + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.normal + + color: Colours.palette.m3onSurface + font.pointSize: Tokens.font.size.normal + selectByMouse: true + + onTextChanged: root.reminderText = text + } + + Text { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.normal + visible: textField.text.length === 0 && !textField.activeFocus + text: qsTr("Reminder text...") + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Set reminder") + icon: "alarm" + onClicked: { + const timeStr = String(root.reminderHours).padStart(2, "0") + ":" + String(root.reminderMinutes).padStart(2, "0"); + ReminderService.addReminder(root.dashState.reminderPickedDate, timeStr, root.reminderText); + root.dashState.reminderPickedDate = ""; + textField.text = ""; + root.reminderText = ""; + root.reminderHours = 0; + root.reminderMinutes = 0; + } + } + + IconTextButton { + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurfaceVariant + verticalPadding: Tokens.padding.small + text: qsTr("Cancel") + icon: "close" + onClicked: { + root.dashState.reminderPickedDate = ""; + textField.text = ""; + root.reminderText = ""; + } + } + } + } + + // Reminder detail view + ColumnLayout { + visible: root.reminderDetailMode + Layout.fillWidth: true + spacing: Tokens.spacing.small + opacity: root.reminderDetailMode ? 1 : 0 + + Behavior on opacity { Anim {} } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.viewingReminder?.date ?? "" + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + font.weight: 600 + color: Colours.palette.m3primary + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.viewingReminder?.time ?? "" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + text: root.viewingReminder?.text ?? "" + font.pointSize: Tokens.font.size.normal + color: Colours.palette.m3onSurface + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + verticalPadding: Tokens.padding.small + text: qsTr("Remove") + icon: "delete" + onClicked: { + ReminderService.removeReminder(root.viewingReminderId); + root.viewingReminderId = ""; + } + } + + IconTextButton { + inactiveColour: Colours.tPalette.m3surfaceContainerHigh + inactiveOnColour: Colours.palette.m3onSurfaceVariant + verticalPadding: Tokens.padding.small + text: qsTr("Close") + icon: "close" + onClicked: root.viewingReminderId = "" + } + } } + // Day-of-week header DayOfWeekRow { id: daysRow Layout.fillWidth: true locale: grid.locale + opacity: (root.dateSetMode || root.reminderDetailMode) ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { Anim {} } delegate: StyledText { required property var model - horizontalAlignment: Text.AlignHCenter text: model.shortName font.weight: 500 @@ -143,9 +336,15 @@ CustomMouseArea { } } + // Calendar grid Item { + id: calGridItem Layout.fillWidth: true implicitHeight: grid.implicitHeight + opacity: (root.dateSetMode || root.reminderDetailMode) ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { Anim {} } MonthGrid { id: grid @@ -164,10 +363,14 @@ CustomMouseArea { required property var model implicitWidth: implicitHeight - implicitHeight: text.implicitHeight + Tokens.padding.small * 2 + implicitHeight: dayText.implicitHeight + Tokens.padding.small * 2 + + readonly property bool hasReminder: ReminderService.reminders.some( + r => r.date === dayItem.model.date.toISOString().slice(0, 10) + ) StyledText { - id: text + id: dayText anchors.centerIn: parent @@ -177,13 +380,43 @@ CustomMouseArea { const dayOfWeek = dayItem.model.date.getUTCDay(); if (dayOfWeek === 0 || dayOfWeek === 6) return Colours.palette.m3secondary; - return Colours.palette.m3onSurfaceVariant; } opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4 font.pointSize: Tokens.font.size.normal font.weight: 500 } + + // Reminder dot + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + width: 4 + height: 4 + radius: 2 + color: Colours.palette.m3primary + visible: dayItem.hasReminder + } + + // Click to pick date for reminder + StateLayer { + radius: Tokens.rounding.small + visible: root.datePickMode && dayItem.model.month === grid.month + onClicked: { + root.dashState.reminderPickedDate = dayItem.model.date.toISOString().slice(0, 10); + } + } + + // Click to view reminder details + StateLayer { + radius: Tokens.rounding.small + visible: !root.reminderMode && dayItem.hasReminder && dayItem.model.month === grid.month + onClicked: { + root.viewingReminderId = ReminderService.reminders.find( + r => r.date === dayItem.model.date.toISOString().slice(0, 10) + )?.id ?? ""; + } + } } } @@ -223,25 +456,147 @@ CustomMouseArea { colorizationColor: Colours.palette.m3onPrimary } - Behavior on opacity { - Anim {} + Behavior on opacity { Anim {} } + Behavior on scale { Anim {} } + + Behavior on x { + Anim { type: Anim.DefaultSpatial } } - Behavior on scale { - Anim {} + Behavior on y { + Anim { type: Anim.DefaultSpatial } } + } + } + } - Behavior on x { - Anim { - type: Anim.DefaultSpatial - } + component SpinGroup: ColumnLayout { + id: spinGroup + + property int value: 0 + property int min: 0 + property int max: 99 + property string label: "" + + signal valueModified(int v) + + onValueChanged: { + if (!spinNum.activeFocus) + spinNum.text = String(value).padStart(2, "0"); + } + + spacing: 2 + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: spinGroup.label + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.alignment: Qt.AlignHCenter + color: "transparent" + radius: Tokens.rounding.small + implicitWidth: spinBox.implicitWidth + implicitHeight: spinUp.implicitHeight + Tokens.padding.small * 2 + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + const v = Math.min(spinGroup.max, spinGroup.value + 1); + spinGroup.value = v; + spinGroup.valueModified(v); + spinNum.text = String(v).padStart(2, "0"); } + } - Behavior on y { - Anim { - type: Anim.DefaultSpatial - } + MaterialIcon { + id: spinUp + anchors.centerIn: parent + text: "keyboard_arrow_up" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + + StyledRect { + id: spinBox + Layout.alignment: Qt.AlignHCenter + color: Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.small + implicitWidth: spinSz.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: spinSz.implicitHeight + Tokens.padding.small * 2 + + StyledText { + id: spinSz + visible: false + text: "00" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + } + + TextInput { + id: spinNum + anchors.centerIn: parent + width: spinSz.implicitWidth + + Component.onCompleted: text = String(spinGroup.value).padStart(2, "0") + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurface + selectionColor: Colours.palette.m3primary + selectedTextColor: Colours.palette.m3onPrimary + horizontalAlignment: TextInput.AlignHCenter + inputMethodHints: Qt.ImhDigitsOnly + maximumLength: 2 + selectByMouse: true + + function commit(): void { + const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); + spinGroup.value = v; + spinGroup.valueModified(v); + text = String(v).padStart(2, "0"); + } + + onEditingFinished: commit() + onActiveFocusChanged: { + if (activeFocus) + selectAll(); + else + commit(); } + Keys.onEscapePressed: { + text = String(spinGroup.value).padStart(2, "0"); + focus = false; + } + } + } + + StyledRect { + Layout.alignment: Qt.AlignHCenter + color: "transparent" + radius: Tokens.rounding.small + implicitWidth: spinBox.implicitWidth + implicitHeight: spinUp.implicitHeight + Tokens.padding.small * 2 + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + const v = Math.max(spinGroup.min, spinGroup.value - 1); + spinGroup.value = v; + spinGroup.valueModified(v); + spinNum.text = String(v).padStart(2, "0"); + } + } + + MaterialIcon { + anchors.centerIn: parent + text: "keyboard_arrow_down" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal } } } diff --git a/modules/dashboard/dash/DashTimerPanel.qml b/modules/dashboard/dash/DashTimerPanel.qml new file mode 100644 index 000000000..297ae3f1e --- /dev/null +++ b/modules/dashboard/dash/DashTimerPanel.qml @@ -0,0 +1,395 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.utils + +Item { + id: root + + required property DashboardState dashState + + property int timerHours: 0 + property int timerMinutes: 0 + property int timerSeconds: 0 + + property int alarmHours: AlarmService.alarmHour + property int alarmMinutes: AlarmService.alarmMinute + + StackLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.normal + currentIndex: root.dashState.timerPanelTab + + // Tab 0: Timer + Item { + ColumnLayout { + anchors.centerIn: parent + width: parent.width + spacing: Tokens.spacing.small + + ColumnLayout { + visible: TimerService.timerDone + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Your time is up!") + font.pointSize: Tokens.font.size.large + font.weight: 600 + color: Colours.palette.m3onSurface + } + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Dismiss") + icon: "close" + onClicked: TimerService.timerDone = false + } + } + + ColumnLayout { + visible: !TimerService.active && !TimerService.timerDone + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + SpinGroup { + label: qsTr("H") + max: 23 + value: root.timerHours + onValueModified: v => root.timerHours = v + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("M") + max: 59 + value: root.timerMinutes + onValueModified: v => root.timerMinutes = v + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("S") + max: 59 + value: root.timerSeconds + onValueModified: v => root.timerSeconds = v + } + } + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Start") + icon: "play_arrow" + onClicked: TimerService.start(root.timerHours, root.timerMinutes, root.timerSeconds) + } + } + + ColumnLayout { + visible: TimerService.active && !TimerService.timerDone + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: TimerService.remainingFormatted + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + font.weight: 500 + horizontalAlignment: Text.AlignHCenter + } + + Item { + Layout.fillWidth: true + implicitHeight: 4 + + StyledRect { + width: parent.width + height: parent.height + radius: 2 + color: Colours.tPalette.m3surfaceContainerHigh + } + + StyledRect { + width: Math.max(radius * 2, TimerService.progress * parent.width) + height: parent.height + radius: 2 + color: Colours.palette.m3primary + Behavior on width { Anim {} } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + verticalPadding: Tokens.padding.small + text: TimerService.running ? qsTr("Pause") : qsTr("Resume") + icon: TimerService.running ? "pause" : "play_arrow" + onClicked: TimerService.running ? TimerService.pause() : TimerService.resume() + } + + IconTextButton { + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + verticalPadding: Tokens.padding.small + text: qsTr("Cancel") + icon: "close" + onClicked: TimerService.cancel() + } + } + } + } + } + + // Tab 1: Alarm + Item { + ColumnLayout { + anchors.centerIn: parent + width: parent.width + spacing: Tokens.spacing.small + + ColumnLayout { + visible: AlarmService.active + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: AlarmService.alarmTimeFormatted + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + font.weight: 500 + color: Colours.palette.m3primary + } + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + verticalPadding: Tokens.padding.small + text: qsTr("Cancel alarm") + icon: "close" + onClicked: AlarmService.cancelAlarm() + } + } + + ColumnLayout { + visible: !AlarmService.active + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + SpinGroup { + label: qsTr("H") + max: 23 + value: root.alarmHours + onValueModified: v => root.alarmHours = v + } + + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + label: qsTr("M") + max: 59 + value: root.alarmMinutes + onValueModified: v => root.alarmMinutes = v + } + } + + IconTextButton { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + verticalPadding: Tokens.padding.small + text: qsTr("Set alarm") + icon: "alarm" + onClicked: AlarmService.setAlarm(root.alarmHours, root.alarmMinutes) + } + } + } + } + + // Tab 2: Reminder - embedded calendar + Item { + clip: true + Calendar { + anchors.fill: parent + dashState: root.dashState + } + } + } + + component SpinGroup: ColumnLayout { + id: spinGroup + + property int value: 0 + property int min: 0 + property int max: 99 + property string label: "" + + signal valueModified(int v) + + onValueChanged: { + if (!numInput.activeFocus) + numInput.text = String(value).padStart(2, "0"); + } + + spacing: 2 + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: spinGroup.label + font.pointSize: Tokens.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + StyledRect { + Layout.alignment: Qt.AlignHCenter + color: "transparent" + radius: Tokens.rounding.small + implicitWidth: numBox.implicitWidth + implicitHeight: upArrow.implicitHeight + Tokens.padding.small * 2 + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + const v = Math.min(spinGroup.max, spinGroup.value + 1); + spinGroup.value = v; + spinGroup.valueModified(v); + numInput.text = String(v).padStart(2, "0"); + } + } + + MaterialIcon { + id: upArrow + anchors.centerIn: parent + text: "keyboard_arrow_up" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + + StyledRect { + id: numBox + Layout.alignment: Qt.AlignHCenter + color: Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.small + implicitWidth: numSizer.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: numSizer.implicitHeight + Tokens.padding.small * 2 + + StyledText { + id: numSizer + visible: false + text: "00" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + } + + TextInput { + id: numInput + anchors.centerIn: parent + width: numSizer.implicitWidth + + Component.onCompleted: text = String(spinGroup.value).padStart(2, "0") + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurface + selectionColor: Colours.palette.m3primary + selectedTextColor: Colours.palette.m3onPrimary + horizontalAlignment: TextInput.AlignHCenter + inputMethodHints: Qt.ImhDigitsOnly + maximumLength: 2 + selectByMouse: true + + function commit(): void { + const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); + spinGroup.value = v; + spinGroup.valueModified(v); + text = String(v).padStart(2, "0"); + } + + onEditingFinished: commit() + onActiveFocusChanged: { + if (activeFocus) + selectAll(); + else + commit(); + } + Keys.onEscapePressed: { + text = String(spinGroup.value).padStart(2, "0"); + focus = false; + } + } + } + + StyledRect { + Layout.alignment: Qt.AlignHCenter + color: "transparent" + radius: Tokens.rounding.small + implicitWidth: numBox.implicitWidth + implicitHeight: upArrow.implicitHeight + Tokens.padding.small * 2 + + StateLayer { + radius: parent.radius + color: Colours.palette.m3onSurface + onClicked: { + const v = Math.max(spinGroup.min, spinGroup.value - 1); + spinGroup.value = v; + spinGroup.valueModified(v); + numInput.text = String(v).padStart(2, "0"); + } + } + + MaterialIcon { + anchors.centerIn: parent + text: "keyboard_arrow_down" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + } +} diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml index 82bee151c..1bc3110df 100644 --- a/modules/dashboard/dash/DateTime.qml +++ b/modules/dashboard/dash/DateTime.qml @@ -13,6 +13,65 @@ Item { anchors.bottom: parent.bottom implicitWidth: Tokens.sizes.dashboard.dateTimeWidth + required property DashboardState dashState + + // Vertical tabs above the clock - only when timer panel is open + ColumnLayout { + anchors.top: parent.top + anchors.topMargin: Tokens.padding.normal + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.smaller + visible: root.dashState.timerPanelOpen + + component TabChip: StyledRect { + id: chip + + property bool chipActive: false + property string chipText: "" + signal clicked() + + Layout.alignment: Qt.AlignHCenter + implicitWidth: chipLabel.implicitWidth + Tokens.padding.small * 4 + implicitHeight: chipLabel.implicitHeight + Tokens.padding.small + + color: chipActive ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.full + + StateLayer { + radius: parent.radius + onClicked: chip.clicked() + } + + StyledText { + id: chipLabel + anchors.centerIn: parent + text: chip.chipText + font.pointSize: Tokens.font.size.small + color: chip.chipActive ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant + } + } + + TabChip { + chipText: qsTr("Timer") + chipActive: root.dashState.timerPanelTab === 0 + onClicked: root.dashState.timerPanelTab = 0 + } + + TabChip { + chipText: qsTr("Alarm") + chipActive: root.dashState.timerPanelTab === 1 + onClicked: root.dashState.timerPanelTab = 1 + } + + TabChip { + chipText: qsTr("Reminder") + chipActive: root.dashState.timerPanelTab === 2 + onClicked: root.dashState.timerPanelTab = 2 + } + } + + // Clock centered in the block ColumnLayout { anchors.left: parent.left anchors.right: parent.right @@ -63,4 +122,36 @@ Item { } } } + + // Button at bottom: alarm_add or arrow_back + StyledRect { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Tokens.padding.large * 2 + implicitWidth: implicitHeight + implicitHeight: actionIcon.implicitHeight + Tokens.padding.normal * 2 + radius: Tokens.rounding.full + color: "#e464d6" + + StateLayer { + radius: parent.radius + onClicked: { + if (root.dashState.timerPanelOpen) { + root.dashState.timerPanelOpen = false; + root.dashState.timerPanelTab = 0; + root.dashState.reminderPickedDate = ""; + } else { + root.dashState.timerPanelOpen = true; + } + } + } + + MaterialIcon { + id: actionIcon + anchors.centerIn: parent + text: root.dashState.timerPanelOpen ? "arrow_back" : "alarm_add" + color: Colours.palette.m3onPrimary + font.pointSize: Tokens.font.size.normal + } + } } diff --git a/plugin/src/Caelestia/Config/barconfig.hpp b/plugin/src/Caelestia/Config/barconfig.hpp index 30dc27ad7..2e08631f7 100644 --- a/plugin/src/Caelestia/Config/barconfig.hpp +++ b/plugin/src/Caelestia/Config/barconfig.hpp @@ -104,7 +104,6 @@ class BarStatus : public ConfigObject { CONFIG_PROPERTY(bool, showBluetooth, true) CONFIG_PROPERTY(bool, showBattery, true) CONFIG_PROPERTY(bool, showLockStatus, true) - CONFIG_PROPERTY(bool, showTimer, false) public: explicit BarStatus(QObject* parent = nullptr) @@ -117,6 +116,7 @@ class BarTimerConfig : public ConfigObject { CONFIG_PROPERTY(bool, enabled, true) CONFIG_GLOBAL_PROPERTY(QString, soundFile, u"/etc/xdg/quickshell/caelestia/assets/timer-done.wav"_s) + CONFIG_GLOBAL_PROPERTY(int, reminderLeadMinutes, 15) public: explicit BarTimerConfig(QObject* parent = nullptr) diff --git a/services/AlarmService.qml b/services/AlarmService.qml new file mode 100644 index 000000000..72985aa5d --- /dev/null +++ b/services/AlarmService.qml @@ -0,0 +1,87 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Caelestia.Config + +Singleton { + id: root + + property bool active: false + property int alarmHour: 0 + property int alarmMinute: 0 + property bool alarmFired: false + + readonly property string alarmTimeFormatted: { + if (!active) + return ""; + if (GlobalConfig.services.useTwelveHourClock) { + const h = alarmHour % 12 || 12; + const suffix = alarmHour < 12 ? "AM" : "PM"; + return `${String(h).padStart(2, "0")}:${String(alarmMinute).padStart(2, "0")} ${suffix}`; + } + return `${String(alarmHour).padStart(2, "0")}:${String(alarmMinute).padStart(2, "0")}`; + } + + signal finished() + + function setAlarm(h: int, m: int): void { + const now = new Date(); + const target = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0); + if (target.getTime() <= now.getTime()) + target.setDate(target.getDate() + 1); + alarmHour = h; + alarmMinute = m; + _store.targetMs = target.getTime(); + alarmFired = false; + active = true; + } + + function cancelAlarm(): void { + active = false; + alarmFired = false; + _store.targetMs = 0; + } + + onFinished: { + const sf = GlobalConfig.bar.clock.timer?.soundFile ?? ""; + if (sf.length > 0) + Quickshell.execDetached(["paplay", sf]); + } + + Component.onCompleted: { + if (_store.targetMs > 0 && _store.targetMs > Date.now()) { + const d = new Date(_store.targetMs); + alarmHour = d.getHours(); + alarmMinute = d.getMinutes(); + active = true; + } else { + _store.targetMs = 0; + } + } + + Timer { + interval: 15000 + repeat: true + running: root.active + + onTriggered: { + if (!root.active || root.alarmFired) + return; + const now = Date.now(); + if (now >= root._store.targetMs) { + root.alarmFired = true; + root.active = false; + root._store.targetMs = 0; + root.finished(); + } + } + } + + PersistentProperties { + id: _store + reloadableId: "alarm" + property real targetMs: 0 + } +} diff --git a/services/ReminderService.qml b/services/ReminderService.qml new file mode 100644 index 000000000..98994841a --- /dev/null +++ b/services/ReminderService.qml @@ -0,0 +1,106 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Caelestia +import Caelestia.Config + +Singleton { + id: root + + readonly property var reminders: { + try { + return JSON.parse(_store.remindersJson); + } catch (e) { + return []; + } + } + + property bool reminderFired: false + property string currentReminderText: "" + property string currentReminderId: "" + + signal preNotify(string text, int minutesBefore) + signal fired(string text) + + function addReminder(dateStr: string, timeStr: string, text: string): void { + const list = _parseList(); + list.push({ + id: Date.now().toString(), + date: dateStr, + time: timeStr, + text: text, + preNotified: false, + fired: false + }); + _store.remindersJson = JSON.stringify(list); + } + + function removeReminder(id: string): void { + const list = _parseList().filter(r => r.id !== id); + _store.remindersJson = JSON.stringify(list); + } + + function dismissCurrent(): void { + reminderFired = false; + currentReminderText = ""; + currentReminderId = ""; + } + + function _parseList(): var { + try { + return JSON.parse(_store.remindersJson); + } catch (e) { + return []; + } + } + + function _check(): void { + const now = new Date(); + const list = _parseList(); + let changed = false; + const leadMs = (GlobalConfig.bar.clock.timer?.reminderLeadMinutes ?? 15) * 60 * 1000; + + for (let i = 0; i < list.length; i++) { + const r = list[i]; + if (r.fired) + continue; + const target = new Date(`${r.date}T${r.time}`); + const diff = target.getTime() - now.getTime(); + + if (diff <= 0) { + list[i].fired = true; + changed = true; + root.reminderFired = true; + root.currentReminderText = r.text; + root.currentReminderId = r.id; + root.fired(r.text); + const sf = GlobalConfig.bar.clock.timer?.soundFile ?? ""; + if (sf.length > 0) + Quickshell.execDetached(["paplay", sf]); + } else if (!r.preNotified && diff <= leadMs) { + list[i].preNotified = true; + changed = true; + root.preNotify(r.text, Math.round(diff / 60000)); + Toaster.toast(qsTr("Reminder"), r.text, "alarm"); + } + } + + if (changed) + _store.remindersJson = JSON.stringify(list); + } + + Timer { + interval: 60000 + repeat: true + running: true + onTriggered: root._check() + } + + PersistentProperties { + id: _store + reloadableId: "reminders" + property string remindersJson: "[]" + } +} From 26720fe7a45e13a153745b8c46e58f9b551bc347 Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Wed, 27 May 2026 04:40:37 +0700 Subject: [PATCH 03/10] feat: timer panel fills full menu, tab buttons with icons+labels, centered content --- modules/dashboard/Dash.qml | 96 ++++++++++--------- modules/dashboard/dash/DashTimerPanel.qml | 111 ++++++++++++++++++++-- modules/dashboard/dash/DateTime.qml | 93 +++++------------- 3 files changed, 176 insertions(+), 124 deletions(-) diff --git a/modules/dashboard/Dash.qml b/modules/dashboard/Dash.qml index ba0fadef1..bf5dba1a0 100644 --- a/modules/dashboard/Dash.qml +++ b/modules/dashboard/Dash.qml @@ -50,33 +50,62 @@ Item { SmallWeather {} } - Rect { - id: dateTimeRect + // Clock + calendar merged zone. Clock rect expands rightward - no stacking. + Item { + id: clockCalendarZone Layout.row: 1 - Layout.preferredWidth: dateTime.implicitWidth - Layout.fillHeight: true - radius: Tokens.rounding.normal + Layout.column: 0 + Layout.columnSpan: 4 + Layout.fillWidth: true + Layout.preferredHeight: calendarLoader.implicitHeight - DateTime { - id: dateTime - dashState: root.dashState + // Calendar background - disappears instantly when timer opens + StyledRect { + id: calBg + color: Colours.tPalette.m3surfaceContainer + radius: Tokens.rounding.large + x: Tokens.sizes.dashboard.dateTimeWidth + Tokens.spacing.normal + y: 0 + width: parent.width - x + height: parent.height + opacity: root.dashState.timerPanelOpen ? 0 : 1 + + Loader { + id: calendarLoader + anchors.fill: parent + sourceComponent: Calendar { + dashState: root.dashState + } + } } - } - Rect { - id: calendarRect - Layout.row: 1 - Layout.column: 1 - Layout.columnSpan: 3 - Layout.fillWidth: true - Layout.preferredHeight: calendarLoader.implicitHeight - radius: Tokens.rounding.large - opacity: root.dashState.timerPanelOpen ? 0 : 1 + // Clock rect - single rect, expands its right edge to fill the calendar area + StyledRect { + id: clockBg + color: Colours.tPalette.m3surfaceContainer + radius: Tokens.rounding.normal + x: 0 + y: 0 + height: parent.height + clip: true + + width: root.dashState.timerPanelOpen + ? parent.width + : Tokens.sizes.dashboard.dateTimeWidth + + Behavior on width { Anim { type: Anim.DefaultSpatial } } + + // Panel fills the full clockBg so content centers in the whole menu + DashTimerPanel { + anchors.fill: parent + visible: root.dashState.timerPanelOpen + dashState: root.dashState + } - Loader { - id: calendarLoader - anchors.fill: parent - sourceComponent: Calendar { + // DateTime on top (z:1) so the back button stays visible over the panel + DateTime { + id: dateTime + z: 1 dashState: root.dashState } } @@ -113,27 +142,4 @@ Item { color: Colours.tPalette.m3surfaceContainer } } - - // Overlay: expands rightward starting from the RIGHT edge of the clock block. - // Only covers the calendar area - clock stays fully visible. - StyledRect { - id: timerOverlay - color: Colours.tPalette.m3surfaceContainer - radius: Tokens.rounding.large - z: 1 - clip: true - - x: calendarRect.x - y: calendarRect.y - height: calendarRect.height - - width: root.dashState.timerPanelOpen ? calendarRect.width : 0 - - Behavior on width { Anim { type: Anim.DefaultSpatial } } - - DashTimerPanel { - anchors.fill: parent - dashState: root.dashState - } - } } diff --git a/modules/dashboard/dash/DashTimerPanel.qml b/modules/dashboard/dash/DashTimerPanel.qml index 297ae3f1e..6365191d4 100644 --- a/modules/dashboard/dash/DashTimerPanel.qml +++ b/modules/dashboard/dash/DashTimerPanel.qml @@ -20,6 +20,41 @@ Item { property int alarmHours: AlarmService.alarmHour property int alarmMinutes: AlarmService.alarmMinute + // Tab column: square buttons (width = each button's height = total height / 3) + ColumnLayout { + id: tabCol + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + width: height / 3 + spacing: 0 + z: 1 + + TabBtn { + btnIcon: "timer" + btnText: qsTr("Timer") + btnActive: root.dashState.timerPanelTab === 0 + topRightR: Tokens.rounding.normal + onClicked: root.dashState.timerPanelTab = 0 + } + + TabBtn { + btnIcon: "alarm" + btnText: qsTr("Alarm") + btnActive: root.dashState.timerPanelTab === 1 + onClicked: root.dashState.timerPanelTab = 1 + } + + TabBtn { + btnIcon: "calendar_month" + btnText: qsTr("Reminder") + btnActive: root.dashState.timerPanelTab === 2 + bottomRightR: Tokens.rounding.normal + onClicked: root.dashState.timerPanelTab = 2 + } + } + + // Content centered between left edge and start of tab column StackLayout { anchors.fill: parent anchors.margins: Tokens.padding.normal @@ -28,8 +63,9 @@ Item { // Tab 0: Timer Item { ColumnLayout { - anchors.centerIn: parent - width: parent.width + anchors.verticalCenter: parent.verticalCenter + x: (parent.width - tabCol.width - width) / 2 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal spacing: Tokens.spacing.small ColumnLayout { @@ -178,8 +214,9 @@ Item { // Tab 1: Alarm Item { ColumnLayout { - anchors.centerIn: parent - width: parent.width + anchors.verticalCenter: parent.verticalCenter + x: (parent.width - tabCol.width - width) / 2 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal spacing: Tokens.spacing.small ColumnLayout { @@ -252,12 +289,70 @@ Item { } } - // Tab 2: Reminder - embedded calendar + // Tab 2: Calendar centered with limited width Item { clip: true - Calendar { - anchors.fill: parent - dashState: root.dashState + Item { + anchors.verticalCenter: parent.verticalCenter + x: (parent.width - tabCol.width - width) / 2 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal + height: parent.height + + Calendar { + anchors.fill: parent + dashState: root.dashState + } + } + } + } + + component TabBtn: StyledRect { + id: btn + + property bool btnActive: false + property string btnIcon: "" + property string btnText: "" + property real topRightR: 0 + property real bottomRightR: 0 + + signal clicked() + + Layout.fillHeight: true + Layout.fillWidth: true + + radius: 0 + topRightRadius: topRightR + bottomRightRadius: bottomRightR + + color: btnActive + ? Colours.palette.m3primaryContainer + : Colours.tPalette.m3surfaceContainerHigh + + StateLayer { + anchors.fill: parent + onClicked: btn.clicked() + } + + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.smaller + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: btn.btnIcon + font.pointSize: Tokens.font.size.normal + color: btn.btnActive + ? Colours.palette.m3onPrimaryContainer + : Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: btn.btnText + font.pointSize: Tokens.font.size.small + color: btn.btnActive + ? Colours.palette.m3onPrimaryContainer + : Colours.palette.m3onSurfaceVariant } } } diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml index 1bc3110df..8ee3f8021 100644 --- a/modules/dashboard/dash/DateTime.qml +++ b/modules/dashboard/dash/DateTime.qml @@ -15,68 +15,21 @@ Item { required property DashboardState dashState - // Vertical tabs above the clock - only when timer panel is open - ColumnLayout { - anchors.top: parent.top - anchors.topMargin: Tokens.padding.normal - anchors.left: parent.left - anchors.right: parent.right - spacing: Tokens.spacing.smaller - visible: root.dashState.timerPanelOpen - - component TabChip: StyledRect { - id: chip - - property bool chipActive: false - property string chipText: "" - signal clicked() - - Layout.alignment: Qt.AlignHCenter - implicitWidth: chipLabel.implicitWidth + Tokens.padding.small * 4 - implicitHeight: chipLabel.implicitHeight + Tokens.padding.small - - color: chipActive ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh - radius: Tokens.rounding.full - - StateLayer { - radius: parent.radius - onClicked: chip.clicked() - } - - StyledText { - id: chipLabel - anchors.centerIn: parent - text: chip.chipText - font.pointSize: Tokens.font.size.small - color: chip.chipActive ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant - } - } - - TabChip { - chipText: qsTr("Timer") - chipActive: root.dashState.timerPanelTab === 0 - onClicked: root.dashState.timerPanelTab = 0 - } - - TabChip { - chipText: qsTr("Alarm") - chipActive: root.dashState.timerPanelTab === 1 - onClicked: root.dashState.timerPanelTab = 1 - } - - TabChip { - chipText: qsTr("Reminder") - chipActive: root.dashState.timerPanelTab === 2 - onClicked: root.dashState.timerPanelTab = 2 - } + // Only clickable when panel is closed - opens the timer panel + StateLayer { + anchors.fill: parent + radius: Tokens.rounding.normal + enabled: !root.dashState.timerPanelOpen + onClicked: root.dashState.timerPanelOpen = true } - // Clock centered in the block + // Clock - visible when panel is closed ColumnLayout { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 0 + visible: !root.dashState.timerPanelOpen StyledText { Layout.bottomMargin: -(font.pointSize * 0.4) @@ -123,34 +76,32 @@ Item { } } - // Button at bottom: alarm_add or arrow_back + // Back button at top-left - visible when panel is open StyledRect { - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom - anchors.bottomMargin: Tokens.padding.large * 2 + visible: root.dashState.timerPanelOpen + anchors.top: parent.top + anchors.left: parent.left + anchors.topMargin: Tokens.padding.normal + anchors.leftMargin: Tokens.padding.normal implicitWidth: implicitHeight - implicitHeight: actionIcon.implicitHeight + Tokens.padding.normal * 2 + implicitHeight: backIcon.implicitHeight + Tokens.padding.small * 2 radius: Tokens.rounding.full - color: "#e464d6" + color: Colours.tPalette.m3surfaceContainerHigh StateLayer { radius: parent.radius onClicked: { - if (root.dashState.timerPanelOpen) { - root.dashState.timerPanelOpen = false; - root.dashState.timerPanelTab = 0; - root.dashState.reminderPickedDate = ""; - } else { - root.dashState.timerPanelOpen = true; - } + root.dashState.timerPanelOpen = false; + root.dashState.timerPanelTab = 0; + root.dashState.reminderPickedDate = ""; } } MaterialIcon { - id: actionIcon + id: backIcon anchors.centerIn: parent - text: root.dashState.timerPanelOpen ? "arrow_back" : "alarm_add" - color: Colours.palette.m3onPrimary + text: "arrow_back" + color: Colours.palette.m3onSurfaceVariant font.pointSize: Tokens.font.size.normal } } From abb8eda81a51399dd5b6c29bb43ee714f7ddd4fc Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Wed, 27 May 2026 04:58:37 +0700 Subject: [PATCH 04/10] timer panel: vertical tab slide animation, back button slides with tabs - Replaced StackLayout with animated Items using y-offset bindings - Each tab uses y: (n - timerPanelTab) * parent.height with Anim.DefaultSpatial - Moved back button into each tab as BackBtn component so it slides with content - Removed static back button from DateTime.qml --- modules/dashboard/dash/Calendar.qml | 3 +- modules/dashboard/dash/DashTimerPanel.qml | 80 +++++++++++++++++++++-- modules/dashboard/dash/DateTime.qml | 28 -------- 3 files changed, 76 insertions(+), 35 deletions(-) diff --git a/modules/dashboard/dash/Calendar.qml b/modules/dashboard/dash/Calendar.qml index 7de44df98..d19e85a4b 100644 --- a/modules/dashboard/dash/Calendar.qml +++ b/modules/dashboard/dash/Calendar.qml @@ -142,7 +142,7 @@ CustomMouseArea { ColumnLayout { visible: root.dateSetMode Layout.fillWidth: true - spacing: Tokens.spacing.small + spacing: Tokens.spacing.normal opacity: root.dateSetMode ? 1 : 0 Behavior on opacity { Anim {} } @@ -217,6 +217,7 @@ CustomMouseArea { RowLayout { Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.small spacing: Tokens.spacing.small IconTextButton { diff --git a/modules/dashboard/dash/DashTimerPanel.qml b/modules/dashboard/dash/DashTimerPanel.qml index 6365191d4..16b4fc6da 100644 --- a/modules/dashboard/dash/DashTimerPanel.qml +++ b/modules/dashboard/dash/DashTimerPanel.qml @@ -26,7 +26,7 @@ Item { anchors.top: parent.top anchors.bottom: parent.bottom anchors.right: parent.right - width: height / 3 + width: height / 3 * 0.8 spacing: 0 z: 1 @@ -34,6 +34,7 @@ Item { btnIcon: "timer" btnText: qsTr("Timer") btnActive: root.dashState.timerPanelTab === 0 + topLeftR: Tokens.rounding.normal topRightR: Tokens.rounding.normal onClicked: root.dashState.timerPanelTab = 0 } @@ -49,19 +50,27 @@ Item { btnIcon: "calendar_month" btnText: qsTr("Reminder") btnActive: root.dashState.timerPanelTab === 2 + bottomLeftR: Tokens.rounding.normal bottomRightR: Tokens.rounding.normal onClicked: root.dashState.timerPanelTab = 2 } } - // Content centered between left edge and start of tab column - StackLayout { + // Content with vertical slide animation between tabs + Item { anchors.fill: parent anchors.margins: Tokens.padding.normal - currentIndex: root.dashState.timerPanelTab + clip: true // Tab 0: Timer Item { + y: (0 - root.dashState.timerPanelTab) * parent.height + width: parent.width + height: parent.height + Behavior on y { Anim { type: Anim.DefaultSpatial } } + + BackBtn { dashState: root.dashState } + ColumnLayout { anchors.verticalCenter: parent.verticalCenter x: (parent.width - tabCol.width - width) / 2 @@ -213,6 +222,13 @@ Item { // Tab 1: Alarm Item { + y: (1 - root.dashState.timerPanelTab) * parent.height + width: parent.width + height: parent.height + Behavior on y { Anim { type: Anim.DefaultSpatial } } + + BackBtn { dashState: root.dashState } + ColumnLayout { anchors.verticalCenter: parent.verticalCenter x: (parent.width - tabCol.width - width) / 2 @@ -291,7 +307,14 @@ Item { // Tab 2: Calendar centered with limited width Item { + y: (2 - root.dashState.timerPanelTab) * parent.height + width: parent.width + height: parent.height clip: true + Behavior on y { Anim { type: Anim.DefaultSpatial } } + + BackBtn { dashState: root.dashState } + Item { anchors.verticalCenter: parent.verticalCenter x: (parent.width - tabCol.width - width) / 2 @@ -312,7 +335,9 @@ Item { property bool btnActive: false property string btnIcon: "" property string btnText: "" + property real topLeftR: 0 property real topRightR: 0 + property real bottomLeftR: 0 property real bottomRightR: 0 signal clicked() @@ -321,16 +346,33 @@ Item { Layout.fillWidth: true radius: 0 + topLeftRadius: topLeftR topRightRadius: topRightR + bottomLeftRadius: bottomLeftR bottomRightRadius: bottomRightR color: btnActive ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh - StateLayer { + Rectangle { anchors.fill: parent - onClicked: btn.clicked() + radius: 0 + topLeftRadius: btn.topLeftR + topRightRadius: btn.topRightR + bottomLeftRadius: btn.bottomLeftR + bottomRightRadius: btn.bottomRightR + color: Colours.palette.m3onSurface + opacity: btnMouse.containsPress ? 0.15 : btnMouse.containsMouse ? 0.08 : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } + + MouseArea { + id: btnMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: btn.clicked() + } } ColumnLayout { @@ -357,6 +399,32 @@ Item { } } + component BackBtn: StyledRect { + required property DashboardState dashState + + implicitWidth: implicitHeight + implicitHeight: _backIcon.implicitHeight + Tokens.padding.small * 2 + radius: Tokens.rounding.full + color: Colours.tPalette.m3surfaceContainerHigh + + StateLayer { + radius: parent.radius + onClicked: { + dashState.timerPanelOpen = false; + dashState.timerPanelTab = 0; + dashState.reminderPickedDate = ""; + } + } + + MaterialIcon { + id: _backIcon + anchors.centerIn: parent + text: "arrow_back" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + component SpinGroup: ColumnLayout { id: spinGroup diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml index 8ee3f8021..611e65617 100644 --- a/modules/dashboard/dash/DateTime.qml +++ b/modules/dashboard/dash/DateTime.qml @@ -76,33 +76,5 @@ Item { } } - // Back button at top-left - visible when panel is open - StyledRect { - visible: root.dashState.timerPanelOpen - anchors.top: parent.top - anchors.left: parent.left - anchors.topMargin: Tokens.padding.normal - anchors.leftMargin: Tokens.padding.normal - implicitWidth: implicitHeight - implicitHeight: backIcon.implicitHeight + Tokens.padding.small * 2 - radius: Tokens.rounding.full - color: Colours.tPalette.m3surfaceContainerHigh - - StateLayer { - radius: parent.radius - onClicked: { - root.dashState.timerPanelOpen = false; - root.dashState.timerPanelTab = 0; - root.dashState.reminderPickedDate = ""; - } - } - MaterialIcon { - id: backIcon - anchors.centerIn: parent - text: "arrow_back" - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - } - } } From 1676fdaf1ca4836be9182a3096b448d8737e8d36 Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Wed, 27 May 2026 05:08:12 +0700 Subject: [PATCH 05/10] timer panel: remove reminder tab and ReminderService Reminder functionality was already implemented in a separate commit by another contributor. Removed all reminder-specific code: - deleted ReminderService.qml - removed Reminder tab from DashTimerPanel (now 2 tabs: Timer + Alarm) - stripped reminder properties and views from Calendar.qml - removed reminderPickedDate from DashboardState - cleaned up FiringOverlay, Wrapper, Bar references to ReminderService --- components/DashboardState.qml | 1 - modules/bar/Bar.qml | 7 - modules/dashboard/FiringOverlay.qml | 5 +- modules/dashboard/Wrapper.qml | 2 +- modules/dashboard/dash/Calendar.qml | 378 +--------------------- modules/dashboard/dash/DashTimerPanel.qml | 32 +- services/ReminderService.qml | 106 ------ 7 files changed, 6 insertions(+), 525 deletions(-) delete mode 100644 services/ReminderService.qml diff --git a/components/DashboardState.qml b/components/DashboardState.qml index faa57c056..b26e8370a 100644 --- a/components/DashboardState.qml +++ b/components/DashboardState.qml @@ -5,5 +5,4 @@ PersistentProperties { property date currentDate: new Date() property bool timerPanelOpen: false property int timerPanelTab: 0 - property string reminderPickedDate: "" } diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml index 073b48814..39c556b71 100644 --- a/modules/bar/Bar.qml +++ b/modules/bar/Bar.qml @@ -125,13 +125,6 @@ ColumnLayout { } } - Connections { - target: ReminderService - function onFired(text: string): void { - root.visibilities.dashboard = true; - } - } - Repeater { id: repeater diff --git a/modules/dashboard/FiringOverlay.qml b/modules/dashboard/FiringOverlay.qml index 52f5e7d84..8aa2f5e74 100644 --- a/modules/dashboard/FiringOverlay.qml +++ b/modules/dashboard/FiringOverlay.qml @@ -13,12 +13,9 @@ Item { required property DrawerVisibilities visibilities - readonly property bool isReminder: ReminderService.reminderFired && !TimerService.timerDone && !AlarmService.alarmFired - function dismiss(): void { TimerService.timerDone = false; AlarmService.alarmFired = false; - ReminderService.dismissCurrent(); root.visibilities.fireOverlay = false; root.visibilities.dashboard = false; } @@ -41,7 +38,7 @@ Item { StyledText { Layout.alignment: Qt.AlignHCenter - text: root.isReminder ? ReminderService.currentReminderText : qsTr("Your time is up!") + text: qsTr("Your time is up!") font.pointSize: Tokens.font.size.extraLarge font.weight: 600 horizontalAlignment: Text.AlignHCenter diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml index 6b1b376c2..48154c4c2 100644 --- a/modules/dashboard/Wrapper.qml +++ b/modules/dashboard/Wrapper.qml @@ -29,7 +29,7 @@ Item { } } - readonly property bool fireActive: TimerService.timerDone || AlarmService.alarmFired || ReminderService.reminderFired + readonly property bool fireActive: TimerService.timerDone || AlarmService.alarmFired property bool _wasOpenBeforeFire: false onFireActiveChanged: { diff --git a/modules/dashboard/dash/Calendar.qml b/modules/dashboard/dash/Calendar.qml index d19e85a4b..fdbcc7fa9 100644 --- a/modules/dashboard/dash/Calendar.qml +++ b/modules/dashboard/dash/Calendar.qml @@ -16,19 +16,6 @@ CustomMouseArea { readonly property int currMonth: dashState.currentDate.getMonth() readonly property int currYear: dashState.currentDate.getFullYear() - readonly property bool reminderMode: dashState.timerPanelOpen && dashState.timerPanelTab === 2 - readonly property bool datePickMode: reminderMode && dashState.reminderPickedDate === "" - readonly property bool dateSetMode: reminderMode && dashState.reminderPickedDate !== "" - - property string viewingReminderId: "" - readonly property bool reminderDetailMode: !reminderMode && viewingReminderId !== "" - readonly property var viewingReminder: viewingReminderId !== "" - ? (ReminderService.reminders.find(r => r.id === viewingReminderId) ?? null) - : null - - property int reminderHours: 0 - property int reminderMinutes: 0 - property string reminderText: "" function onWheel(event: WheelEvent): void { if (event.angleDelta.y > 0) @@ -47,8 +34,7 @@ CustomMouseArea { acceptedButtons: Qt.MiddleButton onClicked: { - if (!reminderMode) - root.dashState.currentDate = new Date(); + root.dashState.currentDate = new Date(); } ColumnLayout { @@ -64,10 +50,6 @@ CustomMouseArea { Layout.fillWidth: true spacing: Tokens.spacing.small - opacity: (root.dateSetMode || root.reminderDetailMode) ? 0 : 1 - visible: opacity > 0 - - Behavior on opacity { Anim {} } Item { implicitWidth: implicitHeight @@ -94,16 +76,13 @@ CustomMouseArea { implicitHeight: monthYearDisplay.implicitHeight + Tokens.padding.small * 2 StateLayer { - onClicked: { - if (!root.reminderMode) - root.dashState.currentDate = new Date(); - } + onClicked: root.dashState.currentDate = new Date() anchors.fill: monthYearDisplay anchors.margins: -Tokens.padding.small anchors.leftMargin: -Tokens.padding.normal anchors.rightMargin: -Tokens.padding.normal radius: Tokens.rounding.full - disabled: root.reminderMode || (root.currMonth === new Date().getMonth() && root.currYear === new Date().getFullYear()) + disabled: root.currMonth === new Date().getMonth() && root.currYear === new Date().getFullYear() } StyledText { @@ -138,195 +117,12 @@ CustomMouseArea { } - // Reminder date-set form (after picking a date) - ColumnLayout { - visible: root.dateSetMode - Layout.fillWidth: true - spacing: Tokens.spacing.normal - opacity: root.dateSetMode ? 1 : 0 - - Behavior on opacity { Anim {} } - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: root.dashState.reminderPickedDate - font.pointSize: Tokens.font.size.extraLarge - font.family: Tokens.font.family.mono - font.weight: 600 - color: Colours.palette.m3primary - } - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Tokens.spacing.small - - SpinGroup { - label: qsTr("H") - max: 23 - value: root.reminderHours - onValueModified: v => root.reminderHours = v - } - - StyledText { - Layout.alignment: Qt.AlignVCenter - text: ":" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurfaceVariant - } - - SpinGroup { - label: qsTr("M") - max: 59 - value: root.reminderMinutes - onValueModified: v => root.reminderMinutes = v - } - } - - StyledRect { - Layout.fillWidth: true - color: Colours.tPalette.m3surfaceContainerHigh - radius: Tokens.rounding.small - implicitHeight: textField.implicitHeight + Tokens.padding.small * 2 - - TextInput { - id: textField - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.margins: Tokens.padding.normal - - color: Colours.palette.m3onSurface - font.pointSize: Tokens.font.size.normal - selectByMouse: true - - onTextChanged: root.reminderText = text - } - - Text { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.margins: Tokens.padding.normal - visible: textField.text.length === 0 && !textField.activeFocus - text: qsTr("Reminder text...") - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - } - } - - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Tokens.spacing.small - spacing: Tokens.spacing.small - - IconTextButton { - Layout.fillWidth: true - inactiveColour: Colours.palette.m3primaryContainer - inactiveOnColour: Colours.palette.m3onPrimaryContainer - verticalPadding: Tokens.padding.small - text: qsTr("Set reminder") - icon: "alarm" - onClicked: { - const timeStr = String(root.reminderHours).padStart(2, "0") + ":" + String(root.reminderMinutes).padStart(2, "0"); - ReminderService.addReminder(root.dashState.reminderPickedDate, timeStr, root.reminderText); - root.dashState.reminderPickedDate = ""; - textField.text = ""; - root.reminderText = ""; - root.reminderHours = 0; - root.reminderMinutes = 0; - } - } - - IconTextButton { - inactiveColour: Colours.tPalette.m3surfaceContainerHigh - inactiveOnColour: Colours.palette.m3onSurfaceVariant - verticalPadding: Tokens.padding.small - text: qsTr("Cancel") - icon: "close" - onClicked: { - root.dashState.reminderPickedDate = ""; - textField.text = ""; - root.reminderText = ""; - } - } - } - } - - // Reminder detail view - ColumnLayout { - visible: root.reminderDetailMode - Layout.fillWidth: true - spacing: Tokens.spacing.small - opacity: root.reminderDetailMode ? 1 : 0 - - Behavior on opacity { Anim {} } - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: root.viewingReminder?.date ?? "" - font.pointSize: Tokens.font.size.extraLarge - font.family: Tokens.font.family.mono - font.weight: 600 - color: Colours.palette.m3primary - } - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: root.viewingReminder?.time ?? "" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurfaceVariant - } - - StyledText { - Layout.alignment: Qt.AlignHCenter - Layout.fillWidth: true - text: root.viewingReminder?.text ?? "" - font.pointSize: Tokens.font.size.normal - color: Colours.palette.m3onSurface - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - } - - RowLayout { - Layout.fillWidth: true - spacing: Tokens.spacing.small - - IconTextButton { - Layout.fillWidth: true - inactiveColour: Colours.palette.m3errorContainer - inactiveOnColour: Colours.palette.m3onErrorContainer - verticalPadding: Tokens.padding.small - text: qsTr("Remove") - icon: "delete" - onClicked: { - ReminderService.removeReminder(root.viewingReminderId); - root.viewingReminderId = ""; - } - } - - IconTextButton { - inactiveColour: Colours.tPalette.m3surfaceContainerHigh - inactiveOnColour: Colours.palette.m3onSurfaceVariant - verticalPadding: Tokens.padding.small - text: qsTr("Close") - icon: "close" - onClicked: root.viewingReminderId = "" - } - } - } - // Day-of-week header DayOfWeekRow { id: daysRow Layout.fillWidth: true locale: grid.locale - opacity: (root.dateSetMode || root.reminderDetailMode) ? 0 : 1 - visible: opacity > 0 - - Behavior on opacity { Anim {} } delegate: StyledText { required property var model @@ -342,10 +138,6 @@ CustomMouseArea { id: calGridItem Layout.fillWidth: true implicitHeight: grid.implicitHeight - opacity: (root.dateSetMode || root.reminderDetailMode) ? 0 : 1 - visible: opacity > 0 - - Behavior on opacity { Anim {} } MonthGrid { id: grid @@ -366,10 +158,6 @@ CustomMouseArea { implicitWidth: implicitHeight implicitHeight: dayText.implicitHeight + Tokens.padding.small * 2 - readonly property bool hasReminder: ReminderService.reminders.some( - r => r.date === dayItem.model.date.toISOString().slice(0, 10) - ) - StyledText { id: dayText @@ -388,36 +176,6 @@ CustomMouseArea { font.weight: 500 } - // Reminder dot - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom - width: 4 - height: 4 - radius: 2 - color: Colours.palette.m3primary - visible: dayItem.hasReminder - } - - // Click to pick date for reminder - StateLayer { - radius: Tokens.rounding.small - visible: root.datePickMode && dayItem.model.month === grid.month - onClicked: { - root.dashState.reminderPickedDate = dayItem.model.date.toISOString().slice(0, 10); - } - } - - // Click to view reminder details - StateLayer { - radius: Tokens.rounding.small - visible: !root.reminderMode && dayItem.hasReminder && dayItem.model.month === grid.month - onClicked: { - root.viewingReminderId = ReminderService.reminders.find( - r => r.date === dayItem.model.date.toISOString().slice(0, 10) - )?.id ?? ""; - } - } } } @@ -471,134 +229,4 @@ CustomMouseArea { } } - component SpinGroup: ColumnLayout { - id: spinGroup - - property int value: 0 - property int min: 0 - property int max: 99 - property string label: "" - - signal valueModified(int v) - - onValueChanged: { - if (!spinNum.activeFocus) - spinNum.text = String(value).padStart(2, "0"); - } - - spacing: 2 - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: spinGroup.label - font.pointSize: Tokens.font.size.small - color: Colours.palette.m3onSurfaceVariant - } - - StyledRect { - Layout.alignment: Qt.AlignHCenter - color: "transparent" - radius: Tokens.rounding.small - implicitWidth: spinBox.implicitWidth - implicitHeight: spinUp.implicitHeight + Tokens.padding.small * 2 - - StateLayer { - radius: parent.radius - color: Colours.palette.m3onSurface - onClicked: { - const v = Math.min(spinGroup.max, spinGroup.value + 1); - spinGroup.value = v; - spinGroup.valueModified(v); - spinNum.text = String(v).padStart(2, "0"); - } - } - - MaterialIcon { - id: spinUp - anchors.centerIn: parent - text: "keyboard_arrow_up" - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - } - } - - StyledRect { - id: spinBox - Layout.alignment: Qt.AlignHCenter - color: Colours.tPalette.m3surfaceContainerHigh - radius: Tokens.rounding.small - implicitWidth: spinSz.implicitWidth + Tokens.padding.normal * 2 - implicitHeight: spinSz.implicitHeight + Tokens.padding.small * 2 - - StyledText { - id: spinSz - visible: false - text: "00" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - } - - TextInput { - id: spinNum - anchors.centerIn: parent - width: spinSz.implicitWidth - - Component.onCompleted: text = String(spinGroup.value).padStart(2, "0") - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurface - selectionColor: Colours.palette.m3primary - selectedTextColor: Colours.palette.m3onPrimary - horizontalAlignment: TextInput.AlignHCenter - inputMethodHints: Qt.ImhDigitsOnly - maximumLength: 2 - selectByMouse: true - - function commit(): void { - const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); - spinGroup.value = v; - spinGroup.valueModified(v); - text = String(v).padStart(2, "0"); - } - - onEditingFinished: commit() - onActiveFocusChanged: { - if (activeFocus) - selectAll(); - else - commit(); - } - Keys.onEscapePressed: { - text = String(spinGroup.value).padStart(2, "0"); - focus = false; - } - } - } - - StyledRect { - Layout.alignment: Qt.AlignHCenter - color: "transparent" - radius: Tokens.rounding.small - implicitWidth: spinBox.implicitWidth - implicitHeight: spinUp.implicitHeight + Tokens.padding.small * 2 - - StateLayer { - radius: parent.radius - color: Colours.palette.m3onSurface - onClicked: { - const v = Math.max(spinGroup.min, spinGroup.value - 1); - spinGroup.value = v; - spinGroup.valueModified(v); - spinNum.text = String(v).padStart(2, "0"); - } - } - - MaterialIcon { - anchors.centerIn: parent - text: "keyboard_arrow_down" - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - } - } - } } diff --git a/modules/dashboard/dash/DashTimerPanel.qml b/modules/dashboard/dash/DashTimerPanel.qml index 16b4fc6da..fe74fac29 100644 --- a/modules/dashboard/dash/DashTimerPanel.qml +++ b/modules/dashboard/dash/DashTimerPanel.qml @@ -43,16 +43,9 @@ Item { btnIcon: "alarm" btnText: qsTr("Alarm") btnActive: root.dashState.timerPanelTab === 1 - onClicked: root.dashState.timerPanelTab = 1 - } - - TabBtn { - btnIcon: "calendar_month" - btnText: qsTr("Reminder") - btnActive: root.dashState.timerPanelTab === 2 bottomLeftR: Tokens.rounding.normal bottomRightR: Tokens.rounding.normal - onClicked: root.dashState.timerPanelTab = 2 + onClicked: root.dashState.timerPanelTab = 1 } } @@ -305,28 +298,6 @@ Item { } } - // Tab 2: Calendar centered with limited width - Item { - y: (2 - root.dashState.timerPanelTab) * parent.height - width: parent.width - height: parent.height - clip: true - Behavior on y { Anim { type: Anim.DefaultSpatial } } - - BackBtn { dashState: root.dashState } - - Item { - anchors.verticalCenter: parent.verticalCenter - x: (parent.width - tabCol.width - width) / 2 - width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal - height: parent.height - - Calendar { - anchors.fill: parent - dashState: root.dashState - } - } - } } component TabBtn: StyledRect { @@ -412,7 +383,6 @@ Item { onClicked: { dashState.timerPanelOpen = false; dashState.timerPanelTab = 0; - dashState.reminderPickedDate = ""; } } diff --git a/services/ReminderService.qml b/services/ReminderService.qml deleted file mode 100644 index 98994841a..000000000 --- a/services/ReminderService.qml +++ /dev/null @@ -1,106 +0,0 @@ -pragma Singleton -pragma ComponentBehavior: Bound - -import QtQuick -import Quickshell -import Caelestia -import Caelestia.Config - -Singleton { - id: root - - readonly property var reminders: { - try { - return JSON.parse(_store.remindersJson); - } catch (e) { - return []; - } - } - - property bool reminderFired: false - property string currentReminderText: "" - property string currentReminderId: "" - - signal preNotify(string text, int minutesBefore) - signal fired(string text) - - function addReminder(dateStr: string, timeStr: string, text: string): void { - const list = _parseList(); - list.push({ - id: Date.now().toString(), - date: dateStr, - time: timeStr, - text: text, - preNotified: false, - fired: false - }); - _store.remindersJson = JSON.stringify(list); - } - - function removeReminder(id: string): void { - const list = _parseList().filter(r => r.id !== id); - _store.remindersJson = JSON.stringify(list); - } - - function dismissCurrent(): void { - reminderFired = false; - currentReminderText = ""; - currentReminderId = ""; - } - - function _parseList(): var { - try { - return JSON.parse(_store.remindersJson); - } catch (e) { - return []; - } - } - - function _check(): void { - const now = new Date(); - const list = _parseList(); - let changed = false; - const leadMs = (GlobalConfig.bar.clock.timer?.reminderLeadMinutes ?? 15) * 60 * 1000; - - for (let i = 0; i < list.length; i++) { - const r = list[i]; - if (r.fired) - continue; - const target = new Date(`${r.date}T${r.time}`); - const diff = target.getTime() - now.getTime(); - - if (diff <= 0) { - list[i].fired = true; - changed = true; - root.reminderFired = true; - root.currentReminderText = r.text; - root.currentReminderId = r.id; - root.fired(r.text); - const sf = GlobalConfig.bar.clock.timer?.soundFile ?? ""; - if (sf.length > 0) - Quickshell.execDetached(["paplay", sf]); - } else if (!r.preNotified && diff <= leadMs) { - list[i].preNotified = true; - changed = true; - root.preNotify(r.text, Math.round(diff / 60000)); - Toaster.toast(qsTr("Reminder"), r.text, "alarm"); - } - } - - if (changed) - _store.remindersJson = JSON.stringify(list); - } - - Timer { - interval: 60000 - repeat: true - running: true - onTriggered: root._check() - } - - PersistentProperties { - id: _store - reloadableId: "reminders" - property string remindersJson: "[]" - } -} From 3c19e069b91dea83703ef70510da15ca94875f5d Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Wed, 27 May 2026 06:27:59 +0700 Subject: [PATCH 06/10] fix: prevent timer overlay crashing hyprlock LockState singleton propagates WlSessionLock state. Wrapper.qml checks LockState.locked before opening fire overlay - defers until session unlocks. --- modules/dashboard/Wrapper.qml | 11 ++++++++++- modules/lock/Lock.qml | 3 +++ services/LockState.qml | 7 +++++++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 services/LockState.qml diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml index 48154c4c2..64a621b46 100644 --- a/modules/dashboard/Wrapper.qml +++ b/modules/dashboard/Wrapper.qml @@ -33,12 +33,21 @@ Item { property bool _wasOpenBeforeFire: false onFireActiveChanged: { - if (fireActive && !_wasOpenBeforeFire) { + if (fireActive && !_wasOpenBeforeFire && !LockState.locked) { _wasOpenBeforeFire = visibilities.dashboard; visibilities.dashboard = true; } } + Connections { + target: LockState + function onLockedChanged(): void { + if (!LockState.locked && root.fireActive && !root.visibilities.dashboard) { + root.visibilities.dashboard = true; + } + } + } + readonly property real nonAnimHeight: !fireActive ? ((content.item as Content)?.nonAnimHeight ?? 0) : 0 readonly property bool shouldBeActive: (visibilities.dashboard && Config.dashboard.enabled) || fireActive property real offsetScale: shouldBeActive ? 0 : 1 diff --git a/modules/lock/Lock.qml b/modules/lock/Lock.qml index f852cb7ff..3fe8d9f64 100644 --- a/modules/lock/Lock.qml +++ b/modules/lock/Lock.qml @@ -5,6 +5,7 @@ import Quickshell import Quickshell.Io import Quickshell.Wayland import qs.components.misc +import qs.services Scope { property alias lock: lock @@ -12,6 +13,8 @@ Scope { WlSessionLock { id: lock + onLockedChanged: LockState.locked = locked + signal unlock LockSurface { diff --git a/services/LockState.qml b/services/LockState.qml new file mode 100644 index 000000000..c9106aac7 --- /dev/null +++ b/services/LockState.qml @@ -0,0 +1,7 @@ +pragma Singleton + +import Quickshell + +Singleton { + property bool locked: false +} From d3d6f54099c1df736b038550ef51dd3e5ef66c00 Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Wed, 27 May 2026 06:50:58 +0700 Subject: [PATCH 07/10] timer panel: fix time not ticking in active state SpinGroup was breaking its value binding via internal assignment (spinGroup.value = v). When the timer started, the binding to TimerService.remainingSeconds was already broken from previous user input. Now SpinGroup only emits valueModified - value is always controlled by the external binding, which ticks correctly. --- modules/dashboard/dash/DashTimerPanel.qml | 499 ++++++++++------------ 1 file changed, 221 insertions(+), 278 deletions(-) diff --git a/modules/dashboard/dash/DashTimerPanel.qml b/modules/dashboard/dash/DashTimerPanel.qml index fe74fac29..195fd0441 100644 --- a/modules/dashboard/dash/DashTimerPanel.qml +++ b/modules/dashboard/dash/DashTimerPanel.qml @@ -70,145 +70,113 @@ Item { width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal spacing: Tokens.spacing.small - ColumnLayout { - visible: TimerService.timerDone + RowLayout { Layout.alignment: Qt.AlignHCenter spacing: Tokens.spacing.small - StyledText { - Layout.alignment: Qt.AlignHCenter - text: qsTr("Your time is up!") - font.pointSize: Tokens.font.size.large - font.weight: 600 - color: Colours.palette.m3onSurface + SpinGroup { + readOnly: TimerService.active + max: 23 + value: TimerService.active + ? Math.floor(TimerService.remainingSeconds / 3600) + : root.timerHours + onValueModified: v => root.timerHours = v } - IconTextButton { - Layout.fillWidth: true - inactiveColour: Colours.palette.m3primaryContainer - inactiveOnColour: Colours.palette.m3onPrimaryContainer - verticalPadding: Tokens.padding.small - text: qsTr("Dismiss") - icon: "close" - onClicked: TimerService.timerDone = false + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant } - } - ColumnLayout { - visible: !TimerService.active && !TimerService.timerDone - Layout.alignment: Qt.AlignHCenter - spacing: Tokens.spacing.small + SpinGroup { + readOnly: TimerService.active + max: 59 + value: TimerService.active + ? Math.floor((TimerService.remainingSeconds % 3600) / 60) + : root.timerMinutes + onValueModified: v => root.timerMinutes = v + } - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Tokens.spacing.small - - SpinGroup { - label: qsTr("H") - max: 23 - value: root.timerHours - onValueModified: v => root.timerHours = v - } - - StyledText { - Layout.alignment: Qt.AlignVCenter - text: ":" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurfaceVariant - } - - SpinGroup { - label: qsTr("M") - max: 59 - value: root.timerMinutes - onValueModified: v => root.timerMinutes = v - } - - StyledText { - Layout.alignment: Qt.AlignVCenter - text: ":" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurfaceVariant - } - - SpinGroup { - label: qsTr("S") - max: 59 - value: root.timerSeconds - onValueModified: v => root.timerSeconds = v - } + StyledText { + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant } - IconTextButton { - Layout.fillWidth: true - inactiveColour: Colours.palette.m3primaryContainer - inactiveOnColour: Colours.palette.m3onPrimaryContainer - verticalPadding: Tokens.padding.small - text: qsTr("Start") - icon: "play_arrow" - onClicked: TimerService.start(root.timerHours, root.timerMinutes, root.timerSeconds) + SpinGroup { + readOnly: TimerService.active + max: 59 + value: TimerService.active + ? (TimerService.remainingSeconds % 60) + : root.timerSeconds + onValueModified: v => root.timerSeconds = v } } - ColumnLayout { - visible: TimerService.active && !TimerService.timerDone - Layout.alignment: Qt.AlignHCenter - spacing: Tokens.spacing.small - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: TimerService.remainingFormatted - font.pointSize: Tokens.font.size.extraLarge - font.family: Tokens.font.family.mono - font.weight: 500 - horizontalAlignment: Text.AlignHCenter + Item { + Layout.fillWidth: true + implicitHeight: 4 + opacity: TimerService.active ? 1 : 0 + Behavior on opacity { Anim {} } + + StyledRect { + width: parent.width + height: parent.height + radius: 2 + color: Colours.tPalette.m3surfaceContainerHigh } - Item { - Layout.fillWidth: true - implicitHeight: 4 - - StyledRect { - width: parent.width - height: parent.height - radius: 2 - color: Colours.tPalette.m3surfaceContainerHigh - } - - StyledRect { - width: Math.max(radius * 2, TimerService.progress * parent.width) - height: parent.height - radius: 2 - color: Colours.palette.m3primary - Behavior on width { Anim {} } - } + StyledRect { + width: Math.max(radius * 2, TimerService.progress * parent.width) + height: parent.height + radius: 2 + color: Colours.palette.m3primary + Behavior on width { Anim {} } } + } + } - RowLayout { - Layout.fillWidth: true - spacing: Tokens.spacing.small - - IconTextButton { - Layout.fillWidth: true - inactiveColour: Colours.palette.m3secondaryContainer - inactiveOnColour: Colours.palette.m3onSecondaryContainer - verticalPadding: Tokens.padding.small - text: TimerService.running ? qsTr("Pause") : qsTr("Resume") - icon: TimerService.running ? "pause" : "play_arrow" - onClicked: TimerService.running ? TimerService.pause() : TimerService.resume() - } - - IconTextButton { - inactiveColour: Colours.palette.m3errorContainer - inactiveOnColour: Colours.palette.m3onErrorContainer - verticalPadding: Tokens.padding.small - text: qsTr("Cancel") - icon: "close" - onClicked: TimerService.cancel() - } - } + ActionBtn { + visible: !TimerService.active + x: (parent.width - tabCol.width - width) / 2 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal + anchors.bottom: parent.bottom + anchors.bottomMargin: Tokens.padding.normal + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + text: qsTr("Start") + icon: "play_arrow" + onClicked: TimerService.start(root.timerHours, root.timerMinutes, root.timerSeconds) + } + + RowLayout { + visible: TimerService.active + x: (parent.width - tabCol.width - width) / 2 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal + anchors.bottom: parent.bottom + anchors.bottomMargin: Tokens.padding.normal + spacing: Tokens.spacing.small + + ActionBtn { + Layout.fillWidth: true + inactiveColour: Colours.palette.m3secondaryContainer + inactiveOnColour: Colours.palette.m3onSecondaryContainer + text: TimerService.running ? qsTr("Pause") : qsTr("Resume") + icon: TimerService.running ? "pause" : "play_arrow" + onClicked: TimerService.running ? TimerService.pause() : TimerService.resume() + } + + ActionBtn { + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + text: qsTr("Cancel") + icon: "close" + onClicked: TimerService.cancel() } } } @@ -228,73 +196,58 @@ Item { width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal spacing: Tokens.spacing.small - ColumnLayout { - visible: AlarmService.active + RowLayout { Layout.alignment: Qt.AlignHCenter spacing: Tokens.spacing.small + SpinGroup { + readOnly: AlarmService.active + max: 23 + value: AlarmService.active ? AlarmService.alarmHour : root.alarmHours + onValueModified: v => root.alarmHours = v + } + StyledText { - Layout.alignment: Qt.AlignHCenter - text: AlarmService.alarmTimeFormatted - font.pointSize: Tokens.font.size.extraLarge + Layout.alignment: Qt.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger font.family: Tokens.font.family.mono - font.weight: 500 - color: Colours.palette.m3primary + color: Colours.palette.m3onSurfaceVariant } - IconTextButton { - Layout.fillWidth: true - inactiveColour: Colours.palette.m3errorContainer - inactiveOnColour: Colours.palette.m3onErrorContainer - verticalPadding: Tokens.padding.small - text: qsTr("Cancel alarm") - icon: "close" - onClicked: AlarmService.cancelAlarm() + SpinGroup { + readOnly: AlarmService.active + max: 59 + value: AlarmService.active ? AlarmService.alarmMinute : root.alarmMinutes + onValueModified: v => root.alarmMinutes = v } } + } - ColumnLayout { - visible: !AlarmService.active - Layout.alignment: Qt.AlignHCenter - spacing: Tokens.spacing.small - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Tokens.spacing.small - - SpinGroup { - label: qsTr("H") - max: 23 - value: root.alarmHours - onValueModified: v => root.alarmHours = v - } - - StyledText { - Layout.alignment: Qt.AlignVCenter - text: ":" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurfaceVariant - } - - SpinGroup { - label: qsTr("M") - max: 59 - value: root.alarmMinutes - onValueModified: v => root.alarmMinutes = v - } - } + ActionBtn { + visible: !AlarmService.active + x: (parent.width - tabCol.width - width) / 2 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal + anchors.bottom: parent.bottom + anchors.bottomMargin: Tokens.padding.normal + inactiveColour: Colours.palette.m3primaryContainer + inactiveOnColour: Colours.palette.m3onPrimaryContainer + text: qsTr("Set alarm") + icon: "alarm" + onClicked: AlarmService.setAlarm(root.alarmHours, root.alarmMinutes) + } - IconTextButton { - Layout.fillWidth: true - inactiveColour: Colours.palette.m3primaryContainer - inactiveOnColour: Colours.palette.m3onPrimaryContainer - verticalPadding: Tokens.padding.small - text: qsTr("Set alarm") - icon: "alarm" - onClicked: AlarmService.setAlarm(root.alarmHours, root.alarmMinutes) - } - } + ActionBtn { + visible: AlarmService.active + x: (parent.width - tabCol.width - width) / 2 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal + anchors.bottom: parent.bottom + anchors.bottomMargin: Tokens.padding.normal + inactiveColour: Colours.palette.m3errorContainer + inactiveOnColour: Colours.palette.m3onErrorContainer + text: qsTr("Cancel alarm") + icon: "close" + onClicked: AlarmService.cancelAlarm() } } @@ -395,134 +348,124 @@ Item { } } - component SpinGroup: ColumnLayout { + component SpinGroup: StyledRect { id: spinGroup property int value: 0 property int min: 0 property int max: 99 - property string label: "" + property bool readOnly: false signal valueModified(int v) onValueChanged: { - if (!numInput.activeFocus) + if (!readOnly && !numInput.activeFocus) numInput.text = String(value).padStart(2, "0"); } - spacing: 2 + color: readOnly ? "transparent" : Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.normal + implicitWidth: numSizer.implicitWidth + Tokens.padding.large * 2 + implicitHeight: numSizer.implicitHeight + Tokens.padding.large * 2 + + Behavior on color { ColorAnimation { duration: 150 } } StyledText { - Layout.alignment: Qt.AlignHCenter - text: spinGroup.label - font.pointSize: Tokens.font.size.small - color: Colours.palette.m3onSurfaceVariant + id: numSizer + visible: false + text: "00" + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono } - StyledRect { - Layout.alignment: Qt.AlignHCenter - color: "transparent" - radius: Tokens.rounding.small - implicitWidth: numBox.implicitWidth - implicitHeight: upArrow.implicitHeight + Tokens.padding.small * 2 - - StateLayer { - radius: parent.radius - color: Colours.palette.m3onSurface - onClicked: { - const v = Math.min(spinGroup.max, spinGroup.value + 1); - spinGroup.value = v; - spinGroup.valueModified(v); - numInput.text = String(v).padStart(2, "0"); + MouseArea { + id: spinMouse + anchors.fill: parent + enabled: !spinGroup.readOnly + hoverEnabled: true + cursorShape: Qt.SizeVerCursor + onClicked: numInput.forceActiveFocus() + onWheel: wheel => { + if (wheel.angleDelta.y > 0) { + spinGroup.valueModified(Math.min(spinGroup.max, spinGroup.value + 1)); + } else if (wheel.angleDelta.y < 0) { + spinGroup.valueModified(Math.max(spinGroup.min, spinGroup.value - 1)); } + wheel.accepted = true; } + } - MaterialIcon { - id: upArrow - anchors.centerIn: parent - text: "keyboard_arrow_up" - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - } + Rectangle { + anchors.fill: parent + radius: spinGroup.radius + color: Colours.palette.m3onSurface + opacity: !spinGroup.readOnly && spinMouse.containsMouse ? 0.08 : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } } - StyledRect { - id: numBox - Layout.alignment: Qt.AlignHCenter - color: Colours.tPalette.m3surfaceContainerHigh - radius: Tokens.rounding.small - implicitWidth: numSizer.implicitWidth + Tokens.padding.normal * 2 - implicitHeight: numSizer.implicitHeight + Tokens.padding.small * 2 + TextInput { + id: numInput + anchors.centerIn: parent + width: numSizer.implicitWidth + visible: !spinGroup.readOnly - StyledText { - id: numSizer - visible: false - text: "00" - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono + Component.onCompleted: text = String(spinGroup.value).padStart(2, "0") + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurface + selectionColor: Colours.palette.m3primary + selectedTextColor: Colours.palette.m3onPrimary + horizontalAlignment: TextInput.AlignHCenter + inputMethodHints: Qt.ImhDigitsOnly + maximumLength: 2 + selectByMouse: true + + function commit(): void { + const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); + spinGroup.valueModified(v); + text = String(v).padStart(2, "0"); } - TextInput { - id: numInput - anchors.centerIn: parent - width: numSizer.implicitWidth - - Component.onCompleted: text = String(spinGroup.value).padStart(2, "0") - font.pointSize: Tokens.font.size.larger - font.family: Tokens.font.family.mono - color: Colours.palette.m3onSurface - selectionColor: Colours.palette.m3primary - selectedTextColor: Colours.palette.m3onPrimary - horizontalAlignment: TextInput.AlignHCenter - inputMethodHints: Qt.ImhDigitsOnly - maximumLength: 2 - selectByMouse: true - - function commit(): void { - const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); - spinGroup.value = v; - spinGroup.valueModified(v); - text = String(v).padStart(2, "0"); - } - - onEditingFinished: commit() - onActiveFocusChanged: { - if (activeFocus) - selectAll(); - else - commit(); - } - Keys.onEscapePressed: { - text = String(spinGroup.value).padStart(2, "0"); - focus = false; - } + onTextEdited: { + const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0)); + spinGroup.valueModified(v); + } + onEditingFinished: commit() + onActiveFocusChanged: { + if (activeFocus) + selectAll(); + else + commit(); + } + Keys.onEscapePressed: { + text = String(spinGroup.value).padStart(2, "0"); + focus = false; } } - StyledRect { - Layout.alignment: Qt.AlignHCenter - color: "transparent" - radius: Tokens.rounding.small - implicitWidth: numBox.implicitWidth - implicitHeight: upArrow.implicitHeight + Tokens.padding.small * 2 - - StateLayer { - radius: parent.radius - color: Colours.palette.m3onSurface - onClicked: { - const v = Math.max(spinGroup.min, spinGroup.value - 1); - spinGroup.value = v; - spinGroup.valueModified(v); - numInput.text = String(v).padStart(2, "0"); - } - } + StyledText { + anchors.centerIn: parent + visible: spinGroup.readOnly + text: String(spinGroup.value).padStart(2, "0") + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurface + horizontalAlignment: Text.AlignHCenter + } + } - MaterialIcon { - anchors.centerIn: parent - text: "keyboard_arrow_down" - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - } + component ActionBtn: IconTextButton { + verticalPadding: Tokens.padding.small + radius: stateLayer.pressed + ? Tokens.rounding.small / 2 + : implicitHeight / 2 * Math.min(1, Tokens.rounding.scale) + scale: stateLayer.pressed ? 1.06 : 1.0 + + Behavior on radius { + Anim { type: Anim.FastSpatial } + } + Behavior on scale { + Anim { type: Anim.FastSpatial } } } } From 044b3f0b2b74433343d69d820e0f9bcd4f5c888a Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Wed, 27 May 2026 06:51:00 +0700 Subject: [PATCH 08/10] readme: note that PR will be rebased after caelestia 2.0 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 13b5ec8a2..83bfb5419 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@

caelestia-shell

+> [!NOTE] +> This is a fork adding a timer and alarm panel to the dashboard (`feat/timer-v3` branch). +> The PR to upstream will be rebased after caelestia 2.0 is released. +
![GitHub last commit](https://img.shields.io/github/last-commit/caelestia-dots/shell?style=for-the-badge&labelColor=101418&color=9ccbfb) From 9cde97938976e630e350e0023c2d7c873dfbfc73 Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Wed, 27 May 2026 09:43:52 +0700 Subject: [PATCH 09/10] fix: alarm never fires due to wrong scope access in timer handler root._store is not a valid property reference - _store is a child object accessible by ID directly, not as a property of root. --- services/AlarmService.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/AlarmService.qml b/services/AlarmService.qml index 72985aa5d..666ea3265 100644 --- a/services/AlarmService.qml +++ b/services/AlarmService.qml @@ -70,10 +70,10 @@ Singleton { if (!root.active || root.alarmFired) return; const now = Date.now(); - if (now >= root._store.targetMs) { + if (now >= _store.targetMs) { root.alarmFired = true; root.active = false; - root._store.targetMs = 0; + _store.targetMs = 0; root.finished(); } } From af0e3ddfa9b693735aed8bb424dee89286d40b46 Mon Sep 17 00:00:00 2001 From: FoxlikeCreature Date: Wed, 27 May 2026 10:03:02 +0700 Subject: [PATCH 10/10] fix: vertically center active timer/alarm content in bar popout --- modules/bar/popouts/TimerPopout.qml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/bar/popouts/TimerPopout.qml b/modules/bar/popouts/TimerPopout.qml index 81b8173ca..f520348c6 100644 --- a/modules/bar/popouts/TimerPopout.qml +++ b/modules/bar/popouts/TimerPopout.qml @@ -98,6 +98,8 @@ Item { ColumnLayout { spacing: Tokens.spacing.small + Item { Layout.fillHeight: true } + // Timer done: Bongo Cat ColumnLayout { visible: TimerService.timerDone @@ -206,7 +208,6 @@ Item { StyledText { Layout.alignment: Qt.AlignHCenter - Layout.topMargin: Tokens.padding.small text: TimerService.remainingFormatted font.pointSize: Tokens.font.size.extraLarge font.family: Tokens.font.family.mono @@ -269,12 +270,16 @@ Item { } } } + + Item { Layout.fillHeight: true } } // --- Alarm tab --- ColumnLayout { spacing: Tokens.spacing.small + Item { Layout.fillHeight: true } + // Active alarm indicator ColumnLayout { visible: AlarmService.active @@ -351,6 +356,8 @@ Item { onClicked: AlarmService.setAlarm(root.alarmHours, root.alarmMinutes) } } + + Item { Layout.fillHeight: true } } } }