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) diff --git a/assets/timer-done.wav b/assets/timer-done.wav new file mode 100644 index 000000000..ed2df4dba Binary files /dev/null and b/assets/timer-done.wav differ diff --git a/components/DashboardState.qml b/components/DashboardState.qml index b4355cd7b..b26e8370a 100644 --- a/components/DashboardState.qml +++ b/components/DashboardState.qml @@ -3,4 +3,6 @@ import Quickshell PersistentProperties { property int currentTab property date currentDate: new Date() + property bool timerPanelOpen: false + property int timerPanelTab: 0 } 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 a2ed060e7..39c556b71 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)) { + popouts.currentName = "timer"; + popouts.currentCenter = ch.y + ch.height / 2; + popouts.hasCurrent = true; } } @@ -104,6 +111,20 @@ ColumnLayout { spacing: Tokens.spacing.normal + Connections { + target: TimerService + function onFinished(): void { + root.visibilities.dashboard = true; + } + } + + Connections { + target: AlarmService + function onFinished(): void { + root.visibilities.dashboard = true; + } + } + Repeater { id: repeater diff --git a/modules/bar/components/Clock.qml b/modules/bar/components/Clock.qml index ffe599afd..3154f3f4d 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.clock.timer?.enabled ?? true) + 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/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..f520348c6 --- /dev/null +++ b/modules/bar/popouts/TimerPopout.qml @@ -0,0 +1,495 @@ +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 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 + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Tokens.spacing.small + + // Tab row + RowLayout { + Layout.topMargin: Tokens.padding.small + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.smaller + + component TabChip: StyledRect { + id: chip + + property bool chipActive: false + property string chipText: "" + property string chipIcon: "" + signal clicked() + + implicitWidth: chipRow.implicitWidth + Tokens.padding.normal * 2 + implicitHeight: chipRow.implicitHeight + Tokens.padding.small * 2 + + color: chipActive ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.full + + StateLayer { + radius: parent.radius + onClicked: chip.clicked() + } + + RowLayout { + id: chipRow + anchors.centerIn: parent + spacing: Tokens.spacing.smaller + + MaterialIcon { + text: chip.chipIcon + font.pointSize: Tokens.font.size.small + color: chip.chipActive ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant + } + + StyledText { + id: chipLabel + text: chip.chipText + font.pointSize: Tokens.font.size.small + color: chip.chipActive ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant + } + } + } + + 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 + } + } + + StackLayout { + currentIndex: root.popoutTab + + // --- Timer tab --- + ColumnLayout { + spacing: Tokens.spacing.small + + Item { Layout.fillHeight: true } + + // 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; + } + } + } + + // 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; + } + } + } + + 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) + } + } + + // Active: running/paused + ColumnLayout { + visible: TimerService.active && !TimerService.timerDone + Layout.fillWidth: true + 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 + 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 {} + } + } + } + + 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() + } + } + } + + Item { Layout.fillHeight: true } + } + + // --- Alarm tab --- + ColumnLayout { + spacing: Tokens.spacing.small + + Item { Layout.fillHeight: true } + + // Active alarm indicator + ColumnLayout { + visible: AlarmService.active + Layout.fillWidth: true + 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() + } + } + + // 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) + } + } + + Item { Layout.fillHeight: true } + } + } + } + + 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: 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 + + 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: 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..fa342d93e 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 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 @@ -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.clock.timer.enabled = root.timerEnabled; GlobalConfig.bar.tray.background = root.trayBackground; GlobalConfig.bar.tray.compact = root.trayCompact; GlobalConfig.bar.tray.recolour = root.trayRecolour; @@ -580,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..bf5dba1a0 100644 --- a/modules/dashboard/Dash.qml +++ b/modules/dashboard/Dash.qml @@ -1,103 +1,145 @@ 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 - } - } - - Rect { - Layout.row: 0 - Layout.columnSpan: 2 - Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth - Layout.fillHeight: true - - radius: Tokens.rounding.large * 1.5 - - SmallWeather {} - } - - Rect { - Layout.row: 1 - Layout.preferredWidth: dateTime.implicitWidth - Layout.fillHeight: true + radius: Tokens.rounding.large - radius: Tokens.rounding.normal + User { + id: user - DateTime { - id: dateTime + visibilities: root.visibilities + facePicker: root.facePicker + } } - } - Rect { - Layout.row: 1 - Layout.column: 1 - Layout.columnSpan: 3 - Layout.fillWidth: true - Layout.preferredHeight: calendar.implicitHeight + Rect { + Layout.row: 0 + Layout.columnSpan: 2 + Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth + Layout.fillHeight: true - radius: Tokens.rounding.large + radius: Tokens.rounding.large * 1.5 - Calendar { - id: calendar + SmallWeather {} + } - dashState: root.dashState + // Clock + calendar merged zone. Clock rect expands rightward - no stacking. + Item { + id: clockCalendarZone + Layout.row: 1 + Layout.column: 0 + Layout.columnSpan: 4 + Layout.fillWidth: true + Layout.preferredHeight: calendarLoader.implicitHeight + + // 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 + } + } + } + + // 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 + } + + // DateTime on top (z:1) so the back button stays visible over the panel + DateTime { + id: dateTime + z: 1 + dashState: root.dashState + } + } } - } - Rect { - Layout.row: 1 - Layout.column: 4 - Layout.preferredWidth: resources.implicitWidth - Layout.fillHeight: true + Rect { + Layout.row: 1 + Layout.column: 4 + Layout.preferredWidth: resources.implicitWidth + Layout.fillHeight: true - radius: Tokens.rounding.normal + radius: Tokens.rounding.normal - Resources { - id: resources + Resources { + id: resources + } } - } - Rect { - Layout.row: 0 - Layout.column: 5 - Layout.rowSpan: 2 - Layout.preferredWidth: media.implicitWidth - Layout.fillHeight: true + Rect { + Layout.row: 0 + Layout.column: 5 + Layout.rowSpan: 2 + Layout.preferredWidth: media.implicitWidth + Layout.fillHeight: true - radius: Tokens.rounding.large * 2 + radius: Tokens.rounding.large * 2 - Media { - id: media + Media { + id: media + } } - } - component Rect: StyledRect { - color: Colours.tPalette.m3surfaceContainer + component Rect: StyledRect { + color: Colours.tPalette.m3surfaceContainer + } } } diff --git a/modules/dashboard/FiringOverlay.qml b/modules/dashboard/FiringOverlay.qml new file mode 100644 index 000000000..8aa2f5e74 --- /dev/null +++ b/modules/dashboard/FiringOverlay.qml @@ -0,0 +1,59 @@ +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 + + function dismiss(): void { + TimerService.timerDone = false; + AlarmService.alarmFired = false; + 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: 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..64a621b46 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,33 @@ 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 + property bool _wasOpenBeforeFire: false + + onFireActiveChanged: { + 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 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 +70,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 +78,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..fdbcc7fa9 100644 --- a/modules/dashboard/dash/Calendar.qml +++ b/modules/dashboard/dash/Calendar.qml @@ -26,10 +26,16 @@ 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: { + root.dashState.currentDate = new Date(); + } ColumnLayout { id: inner @@ -38,6 +44,7 @@ CustomMouseArea { anchors.margins: Tokens.padding.large spacing: Tokens.spacing.small + // Month navigation row RowLayout { id: monthNavigationRow @@ -49,15 +56,12 @@ CustomMouseArea { 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 +72,21 @@ 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(); - } - + 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: { - const now = new Date(); - return root.currMonth === now.getMonth() && root.currYear === now.getFullYear(); - } + disabled: 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 +101,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,8 +114,10 @@ CustomMouseArea { font.weight: 700 } } + } + // Day-of-week header DayOfWeekRow { id: daysRow @@ -135,7 +126,6 @@ CustomMouseArea { delegate: StyledText { required property var model - horizontalAlignment: Text.AlignHCenter text: model.shortName font.weight: 500 @@ -143,7 +133,9 @@ CustomMouseArea { } } + // Calendar grid Item { + id: calGridItem Layout.fillWidth: true implicitHeight: grid.implicitHeight @@ -164,10 +156,10 @@ CustomMouseArea { required property var model implicitWidth: implicitHeight - implicitHeight: text.implicitHeight + Tokens.padding.small * 2 + implicitHeight: dayText.implicitHeight + Tokens.padding.small * 2 StyledText { - id: text + id: dayText anchors.centerIn: parent @@ -177,13 +169,13 @@ 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 } + } } @@ -223,26 +215,18 @@ CustomMouseArea { colorizationColor: Colours.palette.m3onPrimary } - Behavior on opacity { - Anim {} - } - - Behavior on scale { - Anim {} - } + Behavior on opacity { Anim {} } + Behavior on scale { Anim {} } Behavior on x { - Anim { - type: Anim.DefaultSpatial - } + Anim { type: Anim.DefaultSpatial } } Behavior on y { - Anim { - type: Anim.DefaultSpatial - } + Anim { type: Anim.DefaultSpatial } } } } } + } diff --git a/modules/dashboard/dash/DashTimerPanel.qml b/modules/dashboard/dash/DashTimerPanel.qml new file mode 100644 index 000000000..195fd0441 --- /dev/null +++ b/modules/dashboard/dash/DashTimerPanel.qml @@ -0,0 +1,471 @@ +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 + + // 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 * 0.8 + spacing: 0 + z: 1 + + TabBtn { + btnIcon: "timer" + btnText: qsTr("Timer") + btnActive: root.dashState.timerPanelTab === 0 + topLeftR: Tokens.rounding.normal + topRightR: Tokens.rounding.normal + onClicked: root.dashState.timerPanelTab = 0 + } + + TabBtn { + btnIcon: "alarm" + btnText: qsTr("Alarm") + btnActive: root.dashState.timerPanelTab === 1 + bottomLeftR: Tokens.rounding.normal + bottomRightR: Tokens.rounding.normal + onClicked: root.dashState.timerPanelTab = 1 + } + } + + // Content with vertical slide animation between tabs + Item { + anchors.fill: parent + anchors.margins: Tokens.padding.normal + 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 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal + spacing: Tokens.spacing.small + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Tokens.spacing.small + + SpinGroup { + readOnly: TimerService.active + max: 23 + value: TimerService.active + ? Math.floor(TimerService.remainingSeconds / 3600) + : 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 { + readOnly: TimerService.active + max: 59 + value: TimerService.active + ? Math.floor((TimerService.remainingSeconds % 3600) / 60) + : 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 { + readOnly: TimerService.active + max: 59 + value: TimerService.active + ? (TimerService.remainingSeconds % 60) + : root.timerSeconds + onValueModified: v => root.timerSeconds = v + } + } + + 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 + } + + StyledRect { + width: Math.max(radius * 2, TimerService.progress * parent.width) + height: parent.height + radius: 2 + color: Colours.palette.m3primary + Behavior on width { Anim {} } + } + } + } + + 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() + } + } + } + + // 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 + width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal + spacing: Tokens.spacing.small + + 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.AlignVCenter + text: ":" + font.pointSize: Tokens.font.size.larger + font.family: Tokens.font.family.mono + color: Colours.palette.m3onSurfaceVariant + } + + SpinGroup { + readOnly: AlarmService.active + max: 59 + value: AlarmService.active ? AlarmService.alarmMinute : 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) + } + + 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() + } + } + + } + + component TabBtn: StyledRect { + id: btn + + 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() + + Layout.fillHeight: true + Layout.fillWidth: true + + radius: 0 + topLeftRadius: topLeftR + topRightRadius: topRightR + bottomLeftRadius: bottomLeftR + bottomRightRadius: bottomRightR + + color: btnActive + ? Colours.palette.m3primaryContainer + : Colours.tPalette.m3surfaceContainerHigh + + Rectangle { + anchors.fill: parent + 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 { + 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 + } + } + } + + 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; + } + } + + MaterialIcon { + id: _backIcon + anchors.centerIn: parent + text: "arrow_back" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + + component SpinGroup: StyledRect { + id: spinGroup + + property int value: 0 + property int min: 0 + property int max: 99 + property bool readOnly: false + + signal valueModified(int v) + + onValueChanged: { + if (!readOnly && !numInput.activeFocus) + numInput.text = String(value).padStart(2, "0"); + } + + 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 { + id: numSizer + visible: false + text: "00" + font.pointSize: Tokens.font.size.extraLarge + font.family: Tokens.font.family.mono + } + + 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; + } + } + + 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 } } + } + + TextInput { + id: numInput + anchors.centerIn: parent + width: numSizer.implicitWidth + visible: !spinGroup.readOnly + + 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"); + } + + 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; + } + } + + 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 + } + } + + 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 } + } + } +} diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml index 82bee151c..611e65617 100644 --- a/modules/dashboard/dash/DateTime.qml +++ b/modules/dashboard/dash/DateTime.qml @@ -13,11 +13,23 @@ Item { anchors.bottom: parent.bottom implicitWidth: Tokens.sizes.dashboard.dateTimeWidth + required property DashboardState dashState + + // 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 - 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) @@ -63,4 +75,6 @@ Item { } } } + + } 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/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/plugin/src/Caelestia/Config/barconfig.hpp b/plugin/src/Caelestia/Config/barconfig.hpp index 43d43205a..2e08631f7 100644 --- a/plugin/src/Caelestia/Config/barconfig.hpp +++ b/plugin/src/Caelestia/Config/barconfig.hpp @@ -110,6 +110,19 @@ class BarStatus : public ConfigObject { : 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) + CONFIG_GLOBAL_PROPERTY(int, reminderLeadMinutes, 15) + +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/AlarmService.qml b/services/AlarmService.qml new file mode 100644 index 000000000..666ea3265 --- /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 >= _store.targetMs) { + root.alarmFired = true; + root.active = false; + _store.targetMs = 0; + root.finished(); + } + } + } + + PersistentProperties { + id: _store + reloadableId: "alarm" + property real targetMs: 0 + } +} 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 +} 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" + } +}