summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center
diff options
context:
space:
mode:
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml277
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml387
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml311
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCard.qml1073
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml321
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationEmptyState.qml34
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationHeader.qml158
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml556
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml50
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationSettings.qml449
10 files changed, 3616 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml
new file mode 100644
index 0000000..5e033e5
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml
@@ -0,0 +1,277 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ required property var historyItem
+ property bool isSelected: false
+ property bool keyboardNavigationActive: false
+ property bool descriptionExpanded: NotificationService.expandedMessages[historyItem?.id ? (historyItem.id + "_hist") : ""] || false
+ property bool __initialized: false
+
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ if (root)
+ root.__initialized = true;
+ });
+ }
+
+ readonly property bool compactMode: SettingsData.notificationCompactMode
+ readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding
+ readonly property real iconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal
+ readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS
+ readonly property real collapsedContentHeight: iconSize + cardPadding
+ readonly property real baseCardHeight: cardPadding * 2 + collapsedContentHeight
+
+ width: parent ? parent.width : 400
+ height: baseCardHeight + contentItem.extraHeight
+ radius: Theme.cornerRadius
+ clip: false
+ readonly property bool shadowsAllowed: Theme.elevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
+
+ ElevationShadow {
+ id: shadowLayer
+ anchors.fill: parent
+ z: -1
+ level: Theme.elevationLevel1
+ fallbackOffset: 1
+ targetRadius: root.radius
+ targetColor: root.color
+ borderColor: root.border.color
+ borderWidth: root.border.width
+ shadowEnabled: root.shadowsAllowed
+ }
+
+ color: {
+ if (isSelected && keyboardNavigationActive)
+ return Theme.primaryPressed;
+ return Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency);
+ }
+ border.color: {
+ if (isSelected && keyboardNavigationActive)
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5);
+ if (historyItem.urgency === 2)
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3);
+ return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05);
+ }
+ border.width: {
+ if (isSelected && keyboardNavigationActive)
+ return 1.5;
+ if (historyItem.urgency === 2)
+ return 2;
+ return 0;
+ }
+
+ Behavior on border.color {
+ enabled: root.__initialized
+ ColorAnimation {
+ duration: root.__initialized ? Theme.shortDuration : 0
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ visible: historyItem.urgency === 2
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop {
+ position: 0.0
+ color: Theme.primary
+ }
+ GradientStop {
+ position: 0.02
+ color: Theme.primary
+ }
+ GradientStop {
+ position: 0.021
+ color: "transparent"
+ }
+ }
+ }
+
+ Item {
+ id: contentItem
+
+ readonly property real expandedTextHeight: descriptionText.contentHeight
+ readonly property real twoLineHeight: descriptionText.font.pixelSize * 1.2 * 2
+ readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > twoLineHeight + 2) ? (expandedTextHeight - twoLineHeight) : 0
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: cardPadding
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
+ height: collapsedContentHeight + extraHeight
+
+ DankCircularImage {
+ id: iconContainer
+ readonly property string rawImage: historyItem.image || ""
+ readonly property string iconFromImage: {
+ if (rawImage.startsWith("image://icon/"))
+ return rawImage.substring(13);
+ return "";
+ }
+ readonly property bool imageHasSpecialPrefix: {
+ const icon = iconFromImage;
+ return icon.startsWith("material:") || icon.startsWith("svg:") || icon.startsWith("unicode:") || icon.startsWith("image:");
+ }
+ readonly property bool hasNotificationImage: rawImage !== "" && !rawImage.startsWith("image://icon/")
+
+ width: iconSize
+ height: iconSize
+ anchors.left: parent.left
+ anchors.top: parent.top
+
+ imageSource: {
+ if (hasNotificationImage)
+ return historyItem.image;
+ if (imageHasSpecialPrefix)
+ return "";
+ const appIcon = historyItem.appIcon;
+ if (!appIcon)
+ return iconFromImage ? Paths.resolveIconUrl(iconFromImage) : "";
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
+ return appIcon;
+ if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:"))
+ return "";
+ return Paths.resolveIconPath(appIcon);
+ }
+
+ hasImage: hasNotificationImage
+ fallbackIcon: {
+ if (imageHasSpecialPrefix)
+ return iconFromImage;
+ return historyItem.appIcon || iconFromImage || "";
+ }
+ fallbackText: {
+ const appName = historyItem.appName || "?";
+ return appName.charAt(0).toUpperCase();
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -2
+ radius: width / 2
+ color: "transparent"
+ border.color: root.color
+ border.width: 5
+ visible: parent.hasImage
+ antialiasing: true
+ }
+ }
+
+ Rectangle {
+ anchors.left: iconContainer.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: contentSpacing
+ color: "transparent"
+
+ Column {
+ width: parent.width
+ anchors.top: parent.top
+ spacing: Theme.notificationContentSpacing
+
+ Row {
+ width: parent.width
+ spacing: Theme.spacingXS
+ readonly property real reservedTrailingWidth: historySeparator.implicitWidth + Math.max(historyTimeText.implicitWidth, 72) + spacing
+
+ StyledText {
+ id: historyTitleText
+ width: Math.min(implicitWidth, Math.max(0, parent.width - parent.reservedTrailingWidth))
+ text: {
+ let title = historyItem.summary || "";
+ const appName = historyItem.appName || "";
+ const prefix = appName + " • ";
+ if (appName && title.toLowerCase().startsWith(prefix.toLowerCase())) {
+ title = title.substring(prefix.length);
+ }
+ return title;
+ }
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+ StyledText {
+ id: historySeparator
+ text: (historyTitleText.text.length > 0 && historyTimeText.text.length > 0) ? " • " : ""
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Normal
+ }
+ StyledText {
+ id: historyTimeText
+ text: NotificationService.formatHistoryTime(historyItem.timestamp)
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Normal
+ visible: text.length > 0
+ }
+ }
+
+ StyledText {
+ id: descriptionText
+ property bool hasMoreText: truncated
+
+ text: historyItem.htmlBody || historyItem.body || ""
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ width: parent.width
+ elide: descriptionExpanded ? Text.ElideNone : Text.ElideRight
+ maximumLineCount: descriptionExpanded ? -1 : (compactMode ? 1 : 2)
+ wrapMode: Text.WordWrap
+ visible: text.length > 0
+ linkColor: Theme.primary
+ onLinkActivated: link => Qt.openUrlExternally(link)
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (parent.hasMoreText || descriptionExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
+
+ onClicked: mouse => {
+ if (!parent.hoveredLink && (parent.hasMoreText || descriptionExpanded)) {
+ const messageId = historyItem?.id ? (historyItem.id + "_hist") : "";
+ NotificationService.toggleMessageExpansion(messageId);
+ }
+ }
+
+ propagateComposedEvents: true
+ onPressed: mouse => {
+ if (parent.hoveredLink)
+ mouse.accepted = false;
+ }
+ onReleased: mouse => {
+ if (parent.hoveredLink)
+ mouse.accepted = false;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ DankActionButton {
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.topMargin: cardPadding
+ anchors.rightMargin: Theme.spacingL
+ iconName: "close"
+ iconSize: compactMode ? 16 : 18
+ buttonSize: compactMode ? 24 : 28
+ onClicked: NotificationService.removeFromHistory(historyItem.id)
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml
new file mode 100644
index 0000000..69fea41
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml
@@ -0,0 +1,387 @@
+import QtQuick
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ property string selectedFilterKey: "all"
+ property var keyboardController: null
+ property bool keyboardActive: false
+ property int selectedIndex: -1
+ property bool showKeyboardHints: false
+
+ function getStartOfDay(date) {
+ const d = new Date(date);
+ d.setHours(0, 0, 0, 0);
+ return d;
+ }
+
+ function getFilterRange(key) {
+ const now = new Date();
+ const startOfToday = getStartOfDay(now);
+ const startOfYesterday = new Date(startOfToday.getTime() - 86400000);
+
+ switch (key) {
+ case "all":
+ return {
+ start: null,
+ end: null
+ };
+ case "1h":
+ return {
+ start: new Date(now.getTime() - 3600000),
+ end: null
+ };
+ case "today":
+ return {
+ start: startOfToday,
+ end: null
+ };
+ case "yesterday":
+ return {
+ start: startOfYesterday,
+ end: startOfToday
+ };
+ case "older":
+ return {
+ start: null,
+ end: getOlderCutoff()
+ };
+ case "7d":
+ return {
+ start: new Date(now.getTime() - 7 * 86400000),
+ end: null
+ };
+ case "30d":
+ return {
+ start: new Date(now.getTime() - 30 * 86400000),
+ end: null
+ };
+ default:
+ return {
+ start: null,
+ end: null
+ };
+ }
+ }
+
+ function countForFilter(key) {
+ const range = getFilterRange(key);
+ if (!range.start && !range.end)
+ return NotificationService.historyList.length;
+ return NotificationService.historyList.filter(n => {
+ const ts = n.timestamp;
+ if (range.start && ts < range.start.getTime())
+ return false;
+ if (range.end && ts >= range.end.getTime())
+ return false;
+ return true;
+ }).length;
+ }
+
+ readonly property var allFilters: [
+ {
+ label: I18n.tr("All", "notification history filter"),
+ key: "all",
+ maxDays: 0
+ },
+ {
+ label: I18n.tr("Last hour", "notification history filter"),
+ key: "1h",
+ maxDays: 1
+ },
+ {
+ label: I18n.tr("Today", "notification history filter"),
+ key: "today",
+ maxDays: 1
+ },
+ {
+ label: I18n.tr("Yesterday", "notification history filter"),
+ key: "yesterday",
+ maxDays: 2
+ },
+ {
+ label: I18n.tr("7 days", "notification history filter"),
+ key: "7d",
+ maxDays: 7
+ },
+ {
+ label: I18n.tr("30 days", "notification history filter"),
+ key: "30d",
+ maxDays: 30
+ },
+ {
+ label: I18n.tr("Older", "notification history filter for content older than other filters"),
+ key: "older",
+ maxDays: 0
+ }
+ ]
+
+ function filterRelevantForRetention(filter) {
+ const retention = SettingsData.notificationHistoryMaxAgeDays;
+ if (filter.key === "older") {
+ if (retention === 0)
+ return true;
+ return retention > 2 && retention < 7 || retention > 30;
+ }
+ if (retention === 0)
+ return true;
+ if (filter.maxDays === 0)
+ return true;
+ return filter.maxDays <= retention;
+ }
+
+ function getOlderCutoff() {
+ const retention = SettingsData.notificationHistoryMaxAgeDays;
+ const now = new Date();
+ if (retention === 0 || retention > 30)
+ return new Date(now.getTime() - 30 * 86400000);
+ if (retention >= 7)
+ return new Date(now.getTime() - 7 * 86400000);
+ const startOfToday = getStartOfDay(now);
+ return new Date(startOfToday.getTime() - 86400000);
+ }
+
+ readonly property var visibleFilters: {
+ const result = [];
+ const retention = SettingsData.notificationHistoryMaxAgeDays;
+ for (let i = 0; i < allFilters.length; i++) {
+ const f = allFilters[i];
+ if (!filterRelevantForRetention(f))
+ continue;
+ const count = countForFilter(f.key);
+ if (f.key === "all" || count > 0) {
+ result.push({
+ label: f.label,
+ key: f.key,
+ count: count
+ });
+ }
+ }
+ return result;
+ }
+
+ onVisibleFiltersChanged: {
+ let found = false;
+ for (let i = 0; i < visibleFilters.length; i++) {
+ if (visibleFilters[i].key === selectedFilterKey) {
+ found = true;
+ break;
+ }
+ }
+ if (!found)
+ selectedFilterKey = "all";
+ }
+
+ function getFilteredHistory() {
+ const range = getFilterRange(selectedFilterKey);
+ if (!range.start && !range.end)
+ return NotificationService.historyList;
+ return NotificationService.historyList.filter(n => {
+ const ts = n.timestamp;
+ if (range.start && ts < range.start.getTime())
+ return false;
+ if (range.end && ts >= range.end.getTime())
+ return false;
+ return true;
+ });
+ }
+
+ function getChipIndex() {
+ for (let i = 0; i < visibleFilters.length; i++) {
+ if (visibleFilters[i].key === selectedFilterKey)
+ return i;
+ }
+ return 0;
+ }
+
+ function enableAutoScroll() {
+ }
+
+ function removeWithScrollPreserve(itemId) {
+ historyListView.savedY = historyListView.contentY;
+ NotificationService.removeFromHistory(itemId);
+ Qt.callLater(() => {
+ historyListView.forceLayout();
+ });
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: Theme.spacingS
+
+ DankFilterChips {
+ id: filterChips
+ width: parent.width
+ currentIndex: root.getChipIndex()
+ showCounts: true
+ model: root.visibleFilters
+ onSelectionChanged: index => {
+ if (index >= 0 && index < root.visibleFilters.length) {
+ root.selectedFilterKey = root.visibleFilters[index].key;
+ }
+ }
+ }
+
+ DankListView {
+ id: historyListView
+ width: parent.width
+ height: parent.height - filterChips.height - Theme.spacingS
+ clip: true
+ spacing: Theme.spacingS
+ readonly property real horizontalShadowGutter: Theme.snap(Math.max(Theme.spacingXS, 4), 1)
+ readonly property real verticalShadowGutter: Theme.snap(Math.max(Theme.spacingS, 8), 1)
+ readonly property real delegateShadowGutter: Theme.snap(Math.max(Theme.spacingXS, 4), 1)
+ topMargin: verticalShadowGutter
+ bottomMargin: verticalShadowGutter
+
+ model: ScriptModel {
+ id: historyModel
+ values: root.getFilteredHistory()
+ objectProp: "id"
+ }
+
+ NotificationEmptyState {
+ visible: historyListView.count === 0
+ y: Theme.spacingL
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ delegate: Item {
+ id: delegateRoot
+ required property var modelData
+ required property int index
+
+ property real swipeOffset: 0
+ property bool isDismissing: false
+ readonly property real dismissThreshold: width * 0.35
+ property bool __delegateInitialized: false
+
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ if (delegateRoot)
+ delegateRoot.__delegateInitialized = true;
+ });
+ }
+
+ width: ListView.view.width
+ height: historyCard.height + historyListView.delegateShadowGutter
+ clip: false
+
+ HistoryNotificationCard {
+ id: historyCard
+ width: Math.max(0, parent.width - (historyListView.horizontalShadowGutter * 2))
+ y: historyListView.delegateShadowGutter / 2
+ x: historyListView.horizontalShadowGutter + delegateRoot.swipeOffset
+ historyItem: modelData
+ isSelected: root.keyboardActive && root.selectedIndex === index
+ keyboardNavigationActive: root.keyboardActive
+ opacity: 1 - Math.abs(delegateRoot.swipeOffset) / (delegateRoot.width * 0.5)
+
+ Behavior on x {
+ enabled: !swipeDragHandler.active && delegateRoot.__delegateInitialized
+ NumberAnimation {
+ duration: Theme.notificationExitDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on opacity {
+ enabled: delegateRoot.__delegateInitialized
+ NumberAnimation {
+ duration: delegateRoot.__delegateInitialized ? Theme.notificationExitDuration : 0
+ }
+ }
+ }
+
+ DragHandler {
+ id: swipeDragHandler
+ target: null
+ yAxis.enabled: false
+ xAxis.enabled: true
+
+ onActiveChanged: {
+ if (active || delegateRoot.isDismissing)
+ return;
+ if (Math.abs(delegateRoot.swipeOffset) > delegateRoot.dismissThreshold) {
+ delegateRoot.isDismissing = true;
+ root.removeWithScrollPreserve(delegateRoot.modelData?.id || "");
+ } else {
+ delegateRoot.swipeOffset = 0;
+ }
+ }
+
+ onTranslationChanged: {
+ if (delegateRoot.isDismissing)
+ return;
+ delegateRoot.swipeOffset = translation.x;
+ }
+ }
+ }
+ }
+ }
+
+ function selectNext() {
+ if (historyModel.values.length === 0)
+ return;
+ keyboardActive = true;
+ selectedIndex = Math.min(selectedIndex + 1, historyModel.values.length - 1);
+ historyListView.positionViewAtIndex(selectedIndex, ListView.Contain);
+ }
+
+ function selectPrevious() {
+ if (historyModel.values.length === 0)
+ return;
+ if (selectedIndex <= 0) {
+ keyboardActive = false;
+ selectedIndex = -1;
+ return;
+ }
+ selectedIndex = Math.max(selectedIndex - 1, 0);
+ historyListView.positionViewAtIndex(selectedIndex, ListView.Contain);
+ }
+
+ function clearSelected() {
+ if (selectedIndex < 0 || selectedIndex >= historyModel.values.length)
+ return;
+ const item = historyModel.values[selectedIndex];
+ NotificationService.removeFromHistory(item.id);
+ if (historyModel.values.length === 0) {
+ keyboardActive = false;
+ selectedIndex = -1;
+ } else {
+ selectedIndex = Math.min(selectedIndex, historyModel.values.length - 1);
+ }
+ }
+
+ function handleKey(event) {
+ if (event.key === Qt.Key_Down || event.key === 16777237) {
+ if (!keyboardActive) {
+ keyboardActive = true;
+ selectedIndex = 0;
+ } else {
+ selectNext();
+ }
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Up || event.key === 16777235) {
+ if (keyboardActive) {
+ selectPrevious();
+ }
+ event.accepted = true;
+ } else if (keyboardActive && (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace)) {
+ clearSelected();
+ event.accepted = true;
+ } else if ((event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) && (event.modifiers & Qt.ShiftModifier)) {
+ NotificationService.clearHistory();
+ keyboardActive = false;
+ selectedIndex = -1;
+ event.accepted = true;
+ } else if (event.key === Qt.Key_F10) {
+ showKeyboardHints = !showKeyboardHints;
+ event.accepted = true;
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml
new file mode 100644
index 0000000..855163d
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml
@@ -0,0 +1,311 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankListView {
+ id: listView
+
+ property var keyboardController: null
+ property bool keyboardActive: false
+ property bool autoScrollDisabled: false
+ property bool isAnimatingExpansion: false
+ property alias listContentHeight: listView.contentHeight
+ property real stableContentHeight: 0
+ property bool cardAnimateExpansion: true
+ property bool listInitialized: false
+ property int swipingCardIndex: -1
+ property real swipingCardOffset: 0
+ property real __pendingStableHeight: 0
+ property real __heightUpdateThreshold: 20
+ readonly property real shadowBlurPx: Theme.elevationEnabled ? ((Theme.elevationLevel1 && Theme.elevationLevel1.blurPx !== undefined) ? Theme.elevationLevel1.blurPx : 4) : 0
+ readonly property real shadowHorizontalGutter: Theme.snap(Math.max(Theme.spacingS, Math.min(32, shadowBlurPx * 1.5 + 6)), 1)
+ readonly property real shadowVerticalGutter: Theme.snap(Math.max(Theme.spacingXS, 6), 1)
+ readonly property real delegateShadowGutter: Theme.snap(Math.max(Theme.spacingXS, 4), 1)
+
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ if (listView) {
+ listView.listInitialized = true;
+ listView.stableContentHeight = listView.contentHeight;
+ }
+ });
+ }
+
+ Timer {
+ id: heightUpdateDebounce
+ interval: Theme.mediumDuration + 20
+ repeat: false
+ onTriggered: {
+ if (!listView.isAnimatingExpansion && Math.abs(listView.__pendingStableHeight - listView.stableContentHeight) > listView.__heightUpdateThreshold) {
+ listView.stableContentHeight = listView.__pendingStableHeight;
+ }
+ }
+ }
+
+ onContentHeightChanged: {
+ if (!isAnimatingExpansion) {
+ __pendingStableHeight = contentHeight;
+ if (Math.abs(contentHeight - stableContentHeight) > __heightUpdateThreshold) {
+ heightUpdateDebounce.restart();
+ } else {
+ stableContentHeight = contentHeight;
+ }
+ }
+ }
+
+ onIsAnimatingExpansionChanged: {
+ if (isAnimatingExpansion) {
+ heightUpdateDebounce.stop();
+ let delta = 0;
+ for (let i = 0; i < count; i++) {
+ const item = itemAtIndex(i);
+ if (item && item.children[0] && item.children[0].isAnimating) {
+ const targetDelegateHeight = item.children[0].targetHeight + listView.delegateShadowGutter;
+ delta += targetDelegateHeight - item.height;
+ }
+ }
+ const targetHeight = contentHeight + delta;
+ // During expansion, always update immediately without threshold check
+ stableContentHeight = targetHeight;
+ } else {
+ __pendingStableHeight = contentHeight;
+ heightUpdateDebounce.stop();
+ stableContentHeight = __pendingStableHeight;
+ }
+ }
+
+ clip: true
+ model: NotificationService.groupedNotifications
+ spacing: Theme.spacingL
+ topMargin: shadowVerticalGutter
+ bottomMargin: shadowVerticalGutter
+
+ onIsUserScrollingChanged: {
+ if (isUserScrolling && keyboardController && keyboardController.keyboardNavigationActive) {
+ autoScrollDisabled = true;
+ }
+ }
+
+ function enableAutoScroll() {
+ autoScrollDisabled = false;
+ }
+
+ Timer {
+ id: positionPreservationTimer
+ interval: 200
+ running: keyboardController && keyboardController.keyboardNavigationActive && !autoScrollDisabled && !isAnimatingExpansion
+ repeat: true
+ onTriggered: {
+ if (keyboardController && keyboardController.keyboardNavigationActive && !autoScrollDisabled && !isAnimatingExpansion) {
+ keyboardController.ensureVisible();
+ }
+ }
+ }
+
+ Timer {
+ id: expansionEnsureVisibleTimer
+ interval: Theme.mediumDuration + 50
+ repeat: false
+ onTriggered: {
+ if (keyboardController && keyboardController.keyboardNavigationActive && !autoScrollDisabled) {
+ keyboardController.ensureVisible();
+ }
+ }
+ }
+
+ NotificationEmptyState {
+ visible: listView.count === 0
+ y: 20
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ onModelChanged: {
+ if (!keyboardController || !keyboardController.keyboardNavigationActive) {
+ return;
+ }
+ keyboardController.rebuildFlatNavigation();
+ Qt.callLater(() => {
+ if (keyboardController && keyboardController.keyboardNavigationActive && !autoScrollDisabled) {
+ keyboardController.ensureVisible();
+ }
+ });
+ }
+
+ delegate: Item {
+ id: delegateRoot
+ required property var modelData
+ required property int index
+
+ readonly property bool isExpanded: (NotificationService.expandedGroups[modelData && modelData.key] || false)
+ property real swipeOffset: 0
+ property bool isDismissing: false
+ readonly property real dismissThreshold: width * 0.35
+ property bool __delegateInitialized: false
+
+ readonly property bool isAdjacentToSwipe: listView.count >= 2 && listView.swipingCardIndex !== -1 && (index === listView.swipingCardIndex - 1 || index === listView.swipingCardIndex + 1)
+ readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? listView.swipingCardOffset * 0.10 : 0
+ readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(listView.swipingCardOffset) / width * 0.02 : 1.0
+ readonly property real swipeFadeStartOffset: width * 0.75
+ readonly property real swipeFadeDistance: Math.max(1, width - swipeFadeStartOffset)
+
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ if (delegateRoot)
+ delegateRoot.__delegateInitialized = true;
+ });
+ }
+
+ width: ListView.view.width
+ height: notificationCard.height + listView.delegateShadowGutter
+ clip: false
+
+ NotificationCard {
+ id: notificationCard
+ width: Math.max(0, parent.width - (listView.shadowHorizontalGutter * 2))
+ y: listView.delegateShadowGutter / 2
+ x: listView.shadowHorizontalGutter + delegateRoot.swipeOffset + delegateRoot.adjacentSwipeInfluence
+ listLevelAdjacentScaleInfluence: delegateRoot.adjacentScaleInfluence
+ listLevelScaleAnimationsEnabled: listView.swipingCardIndex === -1 || !delegateRoot.isAdjacentToSwipe
+ notificationGroup: modelData
+ keyboardNavigationActive: listView.keyboardActive
+ animateExpansion: listView.cardAnimateExpansion && listView.listInitialized
+ opacity: {
+ const swipeAmount = Math.abs(delegateRoot.swipeOffset);
+ if (swipeAmount <= delegateRoot.swipeFadeStartOffset)
+ return 1;
+ const fadeProgress = (swipeAmount - delegateRoot.swipeFadeStartOffset) / delegateRoot.swipeFadeDistance;
+ return Math.max(0, 1 - fadeProgress);
+ }
+ onIsAnimatingChanged: {
+ if (isAnimating) {
+ listView.isAnimatingExpansion = true;
+ } else {
+ Qt.callLater(() => {
+ if (!notificationCard || !listView)
+ return;
+ let anyAnimating = false;
+ for (let i = 0; i < listView.count; i++) {
+ const item = listView.itemAtIndex(i);
+ if (item && item.children[0] && item.children[0].isAnimating) {
+ anyAnimating = true;
+ break;
+ }
+ }
+ listView.isAnimatingExpansion = anyAnimating;
+ });
+ }
+ }
+
+ isGroupSelected: {
+ if (!keyboardController || !keyboardController.keyboardNavigationActive || !listView.keyboardActive)
+ return false;
+ keyboardController.selectionVersion;
+ const selection = keyboardController.getCurrentSelection();
+ return selection.type === "group" && selection.groupIndex === index;
+ }
+
+ selectedNotificationIndex: {
+ if (!keyboardController || !keyboardController.keyboardNavigationActive || !listView.keyboardActive)
+ return -1;
+ keyboardController.selectionVersion;
+ const selection = keyboardController.getCurrentSelection();
+ return (selection.type === "notification" && selection.groupIndex === index) ? selection.notificationIndex : -1;
+ }
+
+ Behavior on x {
+ enabled: !swipeDragHandler.active && !delegateRoot.isDismissing && (listView.swipingCardIndex === -1 || !delegateRoot.isAdjacentToSwipe) && listView.listInitialized
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on opacity {
+ enabled: listView.listInitialized
+ NumberAnimation {
+ duration: listView.listInitialized ? Theme.shortDuration : 0
+ }
+ }
+ }
+
+ DragHandler {
+ id: swipeDragHandler
+ target: null
+ yAxis.enabled: false
+ xAxis.enabled: true
+
+ onActiveChanged: {
+ if (active) {
+ listView.swipingCardIndex = index;
+ return;
+ }
+ listView.swipingCardIndex = -1;
+ listView.swipingCardOffset = 0;
+ if (delegateRoot.isDismissing)
+ return;
+ if (Math.abs(delegateRoot.swipeOffset) > delegateRoot.dismissThreshold) {
+ delegateRoot.isDismissing = true;
+ swipeDismissAnim.to = delegateRoot.swipeOffset > 0 ? delegateRoot.width : -delegateRoot.width;
+ swipeDismissAnim.start();
+ } else {
+ delegateRoot.swipeOffset = 0;
+ }
+ }
+
+ onTranslationChanged: {
+ if (delegateRoot.isDismissing)
+ return;
+ delegateRoot.swipeOffset = translation.x;
+ listView.swipingCardOffset = translation.x;
+ }
+ }
+
+ NumberAnimation {
+ id: swipeDismissAnim
+ target: delegateRoot
+ property: "swipeOffset"
+ to: 0
+ duration: Theme.notificationExitDuration
+ easing.type: Easing.OutCubic
+ onStopped: NotificationService.dismissGroup(delegateRoot.modelData?.key || "")
+ }
+ }
+
+ Connections {
+ target: NotificationService
+
+ function onGroupedNotificationsChanged() {
+ if (!keyboardController) {
+ return;
+ }
+
+ if (keyboardController.isTogglingGroup) {
+ keyboardController.rebuildFlatNavigation();
+ return;
+ }
+
+ keyboardController.rebuildFlatNavigation();
+
+ if (keyboardController.keyboardNavigationActive) {
+ Qt.callLater(() => {
+ if (!autoScrollDisabled) {
+ keyboardController.ensureVisible();
+ }
+ });
+ }
+ }
+
+ function onExpandedGroupsChanged() {
+ if (!keyboardController || !keyboardController.keyboardNavigationActive)
+ return;
+ expansionEnsureVisibleTimer.restart();
+ }
+
+ function onExpandedMessagesChanged() {
+ if (!keyboardController || !keyboardController.keyboardNavigationActive)
+ return;
+ expansionEnsureVisibleTimer.restart();
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCard.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCard.qml
new file mode 100644
index 0000000..7e404df
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCard.qml
@@ -0,0 +1,1073 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import Quickshell
+import Quickshell.Services.Notifications
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property var notificationGroup
+ property bool expanded: (NotificationService.expandedGroups[notificationGroup && notificationGroup.key] || false)
+ property bool descriptionExpanded: (NotificationService.expandedMessages[(notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.notification && notificationGroup.latestNotification.notification.id) ? (notificationGroup.latestNotification.notification.id + "_desc") : ""] || false)
+ property bool userInitiatedExpansion: false
+ property bool isAnimating: false
+ property bool animateExpansion: true
+
+ property bool isGroupSelected: false
+ property int selectedNotificationIndex: -1
+ property bool keyboardNavigationActive: false
+ property int swipingNotificationIndex: -1
+ property real swipingNotificationOffset: 0
+ property real listLevelAdjacentScaleInfluence: 1.0
+ property bool listLevelScaleAnimationsEnabled: true
+
+ readonly property bool compactMode: SettingsData.notificationCompactMode
+ readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding
+ readonly property real iconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal
+ readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS
+ readonly property real collapsedDismissOffset: 5
+ readonly property real badgeSize: compactMode ? 16 : 18
+ readonly property real actionButtonHeight: compactMode ? 20 : 24
+ readonly property real collapsedContentHeight: Math.max(iconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2))
+ readonly property real baseCardHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing
+
+ width: parent ? parent.width : 400
+ height: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight)
+ readonly property real targetHeight: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight)
+ radius: Theme.cornerRadius
+ scale: (cardHoverHandler.hovered ? 1.004 : 1.0) * listLevelAdjacentScaleInfluence
+ readonly property bool shadowsAllowed: Theme.elevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
+ readonly property var shadowElevation: Theme.elevationLevel1
+ readonly property real baseShadowBlurPx: (shadowElevation && shadowElevation.blurPx !== undefined) ? shadowElevation.blurPx : 4
+ readonly property real hoverShadowBlurBoost: cardHoverHandler.hovered ? Math.min(2, baseShadowBlurPx * 0.25) : 0
+ property real shadowBlurPx: shadowsAllowed ? (baseShadowBlurPx + hoverShadowBlurBoost) : 0
+ property real shadowOffsetXPx: shadowsAllowed ? Theme.elevationOffsetX(shadowElevation) : 0
+ property real shadowOffsetYPx: shadowsAllowed ? (Theme.elevationOffsetY(shadowElevation, 1) + (cardHoverHandler.hovered ? 0.35 : 0)) : 0
+ property bool __initialized: false
+
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ if (root)
+ root.__initialized = true;
+ });
+ }
+
+ Behavior on scale {
+ enabled: listLevelScaleAnimationsEnabled
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on shadowBlurPx {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on shadowOffsetXPx {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on shadowOffsetYPx {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on border.color {
+ enabled: root.__initialized
+ ColorAnimation {
+ duration: root.__initialized ? Theme.shortDuration : 0
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ color: {
+ if (isGroupSelected && keyboardNavigationActive) {
+ return Theme.primaryPressed;
+ }
+ if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
+ return Theme.primaryHoverLight;
+ }
+ return Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency);
+ }
+ border.color: {
+ if (isGroupSelected && keyboardNavigationActive) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5);
+ }
+ if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2);
+ }
+ if (notificationGroup?.latestNotification?.urgency === NotificationUrgency.Critical) {
+ return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3);
+ }
+ return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05);
+ }
+ border.width: {
+ if (isGroupSelected && keyboardNavigationActive) {
+ return 1.5;
+ }
+ if (keyboardNavigationActive && expanded && selectedNotificationIndex >= 0) {
+ return 1;
+ }
+ if (notificationGroup?.latestNotification?.urgency === NotificationUrgency.Critical) {
+ return 2;
+ }
+ return 0;
+ }
+ clip: false
+
+ HoverHandler {
+ id: cardHoverHandler
+ }
+
+ ElevationShadow {
+ id: shadowLayer
+ anchors.fill: parent
+ z: -1
+ level: root.shadowElevation
+ targetRadius: root.radius
+ targetColor: root.color
+ borderColor: root.border.color
+ borderWidth: root.border.width
+ shadowBlurPx: root.shadowBlurPx
+ shadowSpreadPx: 0
+ shadowOffsetX: root.shadowOffsetXPx
+ shadowOffsetY: root.shadowOffsetYPx
+ shadowColor: root.shadowElevation ? Theme.elevationShadowColor(root.shadowElevation) : "transparent"
+ shadowEnabled: root.shadowsAllowed
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ visible: notificationGroup?.latestNotification?.urgency === NotificationUrgency.Critical
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop {
+ position: 0.0
+ color: Theme.primary
+ }
+ GradientStop {
+ position: 0.02
+ color: Theme.primary
+ }
+ GradientStop {
+ position: 0.021
+ color: "transparent"
+ }
+ }
+ opacity: 1.0
+ }
+
+ Item {
+ id: collapsedContent
+
+ readonly property real expandedTextHeight: descriptionText.contentHeight
+ readonly property real collapsedLineCount: compactMode ? 1 : 2
+ readonly property real collapsedLineHeight: Theme.fontSizeSmall * 1.2 * collapsedLineCount
+ readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > collapsedLineHeight + 2) ? (expandedTextHeight - collapsedLineHeight) : 0
+
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: cardPadding
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
+ height: collapsedContentHeight + extraHeight
+ visible: !expanded
+
+ DankCircularImage {
+ id: iconContainer
+ readonly property string rawImage: notificationGroup?.latestNotification?.image || ""
+ readonly property string iconFromImage: {
+ if (rawImage.startsWith("image://icon/"))
+ return rawImage.substring(13);
+ return "";
+ }
+ readonly property bool imageHasSpecialPrefix: {
+ const icon = iconFromImage;
+ return icon.startsWith("material:") || icon.startsWith("svg:") || icon.startsWith("unicode:") || icon.startsWith("image:");
+ }
+ readonly property bool hasNotificationImage: rawImage !== "" && !rawImage.startsWith("image://icon/")
+
+ width: iconSize
+ height: iconSize
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.topMargin: descriptionExpanded ? Math.max(0, Theme.fontSizeSmall * 1.2 + (Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) / 2 - iconSize / 2) : Math.max(0, Theme.fontSizeSmall * 1.2 + (textContainer.height - Theme.fontSizeSmall * 1.2) / 2 - iconSize / 2)
+
+ imageSource: {
+ if (hasNotificationImage)
+ return notificationGroup.latestNotification.cleanImage;
+ if (imageHasSpecialPrefix)
+ return "";
+ const appIcon = notificationGroup?.latestNotification?.appIcon;
+ if (!appIcon)
+ return iconFromImage ? Paths.resolveIconUrl(iconFromImage) : "";
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
+ return appIcon;
+ if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:"))
+ return "";
+ return Paths.resolveIconPath(appIcon);
+ }
+
+ hasImage: hasNotificationImage
+ fallbackIcon: {
+ if (imageHasSpecialPrefix)
+ return iconFromImage;
+ return notificationGroup?.latestNotification?.appIcon || iconFromImage || "";
+ }
+ fallbackText: {
+ const appName = notificationGroup?.appName || "?";
+ return appName.charAt(0).toUpperCase();
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: -2
+ radius: width / 2
+ color: "transparent"
+ border.color: root.color
+ border.width: 5
+ visible: parent.hasImage
+ antialiasing: true
+ }
+
+ Rectangle {
+ width: badgeSize
+ height: badgeSize
+ radius: badgeSize / 2
+ color: Theme.primary
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.topMargin: -2
+ anchors.rightMargin: -2
+ visible: (notificationGroup?.count || 0) > 1
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (notificationGroup?.count || 0) > 99 ? "99+" : (notificationGroup?.count || 0).toString()
+ color: Theme.primaryText
+ font.pixelSize: compactMode ? 8 : 9
+ font.weight: Font.Bold
+ }
+ }
+ }
+
+ Rectangle {
+ id: textContainer
+
+ anchors.left: iconContainer.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: contentSpacing
+ color: "transparent"
+
+ Column {
+ width: parent.width
+ anchors.top: parent.top
+ spacing: Theme.notificationContentSpacing
+
+ Row {
+ id: collapsedHeaderRow
+ width: parent.width
+ spacing: Theme.spacingXS
+ visible: (collapsedHeaderAppNameText.text.length > 0 || collapsedHeaderTimeText.text.length > 0)
+
+ StyledText {
+ id: collapsedHeaderAppNameText
+ text: notificationGroup?.appName || ""
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Normal
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ width: Math.min(implicitWidth, parent.width - collapsedHeaderSeparator.implicitWidth - collapsedHeaderTimeText.implicitWidth - parent.spacing * 2)
+ }
+
+ StyledText {
+ id: collapsedHeaderSeparator
+ text: (collapsedHeaderAppNameText.text.length > 0 && collapsedHeaderTimeText.text.length > 0) ? " • " : ""
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Normal
+ }
+
+ StyledText {
+ id: collapsedHeaderTimeText
+ text: notificationGroup?.latestNotification?.timeStr || ""
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Normal
+ }
+ }
+
+ StyledText {
+ id: collapsedTitleText
+ width: parent.width
+ text: notificationGroup?.latestNotification?.summary || ""
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ StyledText {
+ id: descriptionText
+ property string fullText: (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.htmlBody) || ""
+ property bool hasMoreText: truncated
+
+ text: fullText
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: descriptionExpanded ? -1 : (compactMode ? 1 : 2)
+ wrapMode: Text.WordWrap
+ visible: text.length > 0
+ linkColor: Theme.primary
+ onLinkActivated: link => Qt.openUrlExternally(link)
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (parent.hasMoreText || descriptionExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
+
+ onClicked: mouse => {
+ if (!parent.hoveredLink && (parent.hasMoreText || descriptionExpanded)) {
+ root.userInitiatedExpansion = true;
+ const messageId = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.notification && notificationGroup.latestNotification.notification.id) ? (notificationGroup.latestNotification.notification.id + "_desc") : "";
+ NotificationService.toggleMessageExpansion(messageId);
+ Qt.callLater(() => {
+ if (root && !root.isAnimating)
+ root.userInitiatedExpansion = false;
+ });
+ }
+ }
+
+ propagateComposedEvents: true
+ onPressed: mouse => {
+ if (parent.hoveredLink)
+ mouse.accepted = false;
+ }
+ onReleased: mouse => {
+ if (parent.hoveredLink)
+ mouse.accepted = false;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Column {
+ id: expandedContent
+ objectName: "expandedContent"
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: cardPadding
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ spacing: compactMode ? Theme.spacingXS : Theme.spacingS
+ visible: expanded
+
+ Item {
+ width: parent.width
+ height: compactMode ? 32 : 40
+
+ Row {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ StyledText {
+ text: notificationGroup?.appName || ""
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Bold
+ anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ Rectangle {
+ width: badgeSize
+ height: badgeSize
+ radius: badgeSize / 2
+ color: Theme.primary
+ visible: (notificationGroup?.count || 0) > 1
+ anchors.verticalCenter: parent.verticalCenter
+
+ StyledText {
+ anchors.centerIn: parent
+ text: (notificationGroup?.count || 0) > 99 ? "99+" : (notificationGroup?.count || 0).toString()
+ color: Theme.primaryText
+ font.pixelSize: compactMode ? 8 : 9
+ font.weight: Font.Bold
+ }
+ }
+ }
+ }
+
+ Column {
+ width: parent.width
+ spacing: compactMode ? Theme.spacingS : Theme.spacingL
+
+ Repeater {
+ id: notificationRepeater
+ objectName: "notificationRepeater"
+ model: notificationGroup?.notifications?.slice(0, 10) || []
+
+ delegate: Item {
+ id: expandedDelegateWrapper
+ required property var modelData
+ required property int index
+ readonly property bool messageExpanded: NotificationService.expandedMessages[modelData?.notification?.id] || false
+ readonly property bool isSelected: root.selectedNotificationIndex === index
+ readonly property bool actionsVisible: true
+ readonly property real expandedIconSize: compactMode ? Theme.notificationExpandedIconSizeCompact : Theme.notificationExpandedIconSizeNormal
+
+ HoverHandler {
+ id: expandedDelegateHoverHandler
+ }
+ readonly property real expandedItemPadding: compactMode ? Theme.spacingS : Theme.spacingM
+ readonly property real expandedBaseHeight: expandedItemPadding * 2 + Math.max(expandedIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * 2) + actionButtonHeight + contentSpacing * 2
+ property bool __delegateInitialized: false
+ property real swipeOffset: 0
+ property bool isDismissing: false
+ readonly property real dismissThreshold: width * 0.35
+
+ Component.onCompleted: {
+ Qt.callLater(() => {
+ if (expandedDelegateWrapper)
+ expandedDelegateWrapper.__delegateInitialized = true;
+ });
+ }
+
+ width: parent.width
+ height: delegateRect.height
+ clip: true
+
+ Rectangle {
+ id: delegateRect
+ width: parent.width
+
+ readonly property bool isAdjacentToSwipe: root.swipingNotificationIndex !== -1 && (expandedDelegateWrapper.index === root.swipingNotificationIndex - 1 || expandedDelegateWrapper.index === root.swipingNotificationIndex + 1)
+ readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? root.swipingNotificationOffset * 0.10 : 0
+ readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(root.swipingNotificationOffset) / width * 0.02 : 1.0
+
+ x: expandedDelegateWrapper.swipeOffset + adjacentSwipeInfluence
+ scale: adjacentScaleInfluence
+ transformOrigin: Item.Center
+
+ Behavior on x {
+ enabled: !expandedSwipeHandler.active && !expandedDelegateWrapper.isDismissing
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on scale {
+ enabled: !expandedSwipeHandler.active
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ height: {
+ if (!messageExpanded)
+ return expandedBaseHeight;
+ const twoLineHeight = bodyText.font.pixelSize * 1.2 * 2;
+ if (bodyText.implicitHeight > twoLineHeight + 2)
+ return expandedBaseHeight + bodyText.implicitHeight - twoLineHeight;
+ return expandedBaseHeight;
+ }
+ radius: Theme.cornerRadius
+ color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
+ border.color: isSelected ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
+ border.width: 1
+
+ Behavior on border.color {
+ enabled: __delegateInitialized
+ ColorAnimation {
+ duration: __delegateInitialized ? Theme.shortDuration : 0
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Behavior on height {
+ enabled: false
+ }
+
+ Item {
+ anchors.fill: parent
+ anchors.margins: compactMode ? Theme.spacingS : Theme.spacingM
+ anchors.bottomMargin: contentSpacing
+
+ DankCircularImage {
+ id: messageIcon
+
+ readonly property string rawImage: modelData?.image || ""
+ readonly property string iconFromImage: {
+ if (rawImage.startsWith("image://icon/"))
+ return rawImage.substring(13);
+ return "";
+ }
+ readonly property bool imageHasSpecialPrefix: {
+ const icon = iconFromImage;
+ return icon.startsWith("material:") || icon.startsWith("svg:") || icon.startsWith("unicode:") || icon.startsWith("image:");
+ }
+ readonly property bool hasNotificationImage: rawImage !== "" && !rawImage.startsWith("image://icon/")
+
+ width: expandedIconSize
+ height: expandedIconSize
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.topMargin: Theme.fontSizeSmall * 1.2 + (compactMode ? Theme.spacingXS : Theme.spacingS)
+
+ imageSource: {
+ if (hasNotificationImage)
+ return modelData.cleanImage;
+ if (imageHasSpecialPrefix)
+ return "";
+ const appIcon = modelData?.appIcon;
+ if (!appIcon)
+ return iconFromImage ? Paths.resolveIconUrl(iconFromImage) : "";
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
+ return appIcon;
+ if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:"))
+ return "";
+ return Paths.resolveIconPath(appIcon);
+ }
+
+ fallbackIcon: {
+ if (imageHasSpecialPrefix)
+ return iconFromImage;
+ return modelData?.appIcon || iconFromImage || "";
+ }
+
+ fallbackText: {
+ const appName = modelData?.appName || "?";
+ return appName.charAt(0).toUpperCase();
+ }
+ }
+
+ Item {
+ anchors.left: messageIcon.right
+ anchors.leftMargin: Theme.spacingM
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingM
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+
+ Column {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: buttonArea.top
+ anchors.bottomMargin: contentSpacing
+ spacing: Theme.notificationContentSpacing
+
+ Row {
+ id: expandedDelegateHeaderRow
+ width: parent.width
+ spacing: Theme.spacingXS
+ visible: (expandedDelegateHeaderAppNameText.text.length > 0 || expandedDelegateHeaderTimeText.text.length > 0)
+
+ StyledText {
+ id: expandedDelegateHeaderAppNameText
+ text: modelData?.appName || ""
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Normal
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ width: Math.min(implicitWidth, parent.width - expandedDelegateHeaderSeparator.implicitWidth - expandedDelegateHeaderTimeText.implicitWidth - parent.spacing * 2)
+ }
+
+ StyledText {
+ id: expandedDelegateHeaderSeparator
+ text: (expandedDelegateHeaderAppNameText.text.length > 0 && expandedDelegateHeaderTimeText.text.length > 0) ? " • " : ""
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Normal
+ }
+
+ StyledText {
+ id: expandedDelegateHeaderTimeText
+ text: modelData?.timeStr || ""
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Normal
+ }
+ }
+
+ StyledText {
+ id: expandedDelegateTitleText
+ width: parent.width
+ text: modelData?.summary || ""
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ StyledText {
+ id: bodyText
+ property bool hasMoreText: truncated
+
+ text: modelData?.htmlBody || ""
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ width: parent.width
+ elide: messageExpanded ? Text.ElideNone : Text.ElideRight
+ maximumLineCount: messageExpanded ? -1 : 2
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ visible: text.length > 0
+ linkColor: Theme.primary
+ onLinkActivated: link => Qt.openUrlExternally(link)
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (bodyText.hasMoreText || messageExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
+
+ onClicked: mouse => {
+ if (!parent.hoveredLink && (bodyText.hasMoreText || messageExpanded)) {
+ root.userInitiatedExpansion = true;
+ NotificationService.toggleMessageExpansion(modelData?.notification?.id || "");
+ Qt.callLater(() => {
+ if (root && !root.isAnimating)
+ root.userInitiatedExpansion = false;
+ });
+ }
+ }
+
+ propagateComposedEvents: true
+ onPressed: mouse => {
+ if (parent.hoveredLink) {
+ mouse.accepted = false;
+ }
+ }
+ onReleased: mouse => {
+ if (parent.hoveredLink) {
+ mouse.accepted = false;
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: buttonArea
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: actionButtonHeight + contentSpacing
+
+ Row {
+ visible: expandedDelegateWrapper.actionsVisible
+ opacity: visible ? 1 : 0
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ spacing: contentSpacing
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ Repeater {
+ model: modelData?.actions || []
+
+ Rectangle {
+ property bool isHovered: false
+
+ width: Math.max(expandedActionText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
+ height: actionButtonHeight
+ radius: Theme.notificationButtonCornerRadius
+ color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
+
+ StyledText {
+ id: expandedActionText
+ text: {
+ const baseText = modelData.text || I18n.tr("Open");
+ if (keyboardNavigationActive && (isGroupSelected || selectedNotificationIndex >= 0))
+ return `${baseText} (${index + 1})`;
+ return baseText;
+ }
+ color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: parent.isHovered = true
+ onExited: parent.isHovered = false
+ onClicked: {
+ if (modelData && modelData.invoke)
+ modelData.invoke();
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: expandedDelegateDismissBtn
+ property bool isHovered: false
+
+ visible: expandedDelegateWrapper.actionsVisible
+ opacity: visible ? 1 : 0
+ width: Math.max(expandedClearText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
+ height: actionButtonHeight
+ radius: Theme.notificationButtonCornerRadius
+ color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+
+ StyledText {
+ id: expandedClearText
+ text: I18n.tr("Dismiss")
+ color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: parent.isHovered = true
+ onExited: parent.isHovered = false
+ onClicked: NotificationService.dismissNotification(modelData)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ DragHandler {
+ id: expandedSwipeHandler
+ target: null
+ xAxis.enabled: true
+ yAxis.enabled: false
+ grabPermissions: PointerHandler.CanTakeOverFromItems | PointerHandler.CanTakeOverFromHandlersOfDifferentType
+
+ onActiveChanged: {
+ if (active) {
+ root.swipingNotificationIndex = expandedDelegateWrapper.index;
+ } else {
+ root.swipingNotificationIndex = -1;
+ root.swipingNotificationOffset = 0;
+ }
+ if (active || expandedDelegateWrapper.isDismissing)
+ return;
+ if (Math.abs(expandedDelegateWrapper.swipeOffset) > expandedDelegateWrapper.dismissThreshold) {
+ expandedDelegateWrapper.isDismissing = true;
+ expandedSwipeDismissAnim.start();
+ } else {
+ expandedDelegateWrapper.swipeOffset = 0;
+ }
+ }
+
+ onTranslationChanged: {
+ if (expandedDelegateWrapper.isDismissing)
+ return;
+ expandedDelegateWrapper.swipeOffset = translation.x;
+ root.swipingNotificationOffset = translation.x;
+ }
+ }
+
+ NumberAnimation {
+ id: expandedSwipeDismissAnim
+ target: expandedDelegateWrapper
+ property: "swipeOffset"
+ to: expandedDelegateWrapper.swipeOffset > 0 ? expandedDelegateWrapper.width : -expandedDelegateWrapper.width
+ duration: Theme.notificationExitDuration
+ easing.type: Easing.OutCubic
+ onStopped: NotificationService.dismissNotification(modelData)
+ }
+ }
+ }
+ }
+ }
+
+ Row {
+ visible: !expanded
+ anchors.right: clearButton.visible ? clearButton.left : parent.right
+ anchors.rightMargin: clearButton.visible ? contentSpacing : Theme.spacingL
+ anchors.top: collapsedContent.bottom
+ anchors.topMargin: contentSpacing + collapsedDismissOffset
+ spacing: contentSpacing
+
+ Repeater {
+ model: notificationGroup?.latestNotification?.actions || []
+
+ Rectangle {
+ property bool isHovered: false
+
+ width: Math.max(collapsedActionText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
+ height: actionButtonHeight
+ radius: Theme.notificationButtonCornerRadius
+ color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
+
+ StyledText {
+ id: collapsedActionText
+ text: {
+ const baseText = modelData.text || I18n.tr("Open");
+ if (keyboardNavigationActive && isGroupSelected) {
+ return `${baseText} (${index + 1})`;
+ }
+ return baseText;
+ }
+ color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: parent.isHovered = true
+ onExited: parent.isHovered = false
+ onClicked: {
+ if (modelData && modelData.invoke) {
+ modelData.invoke();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: clearButton
+
+ property bool isHovered: false
+ readonly property int actionCount: (notificationGroup?.latestNotification?.actions || []).length
+
+ visible: !expanded && actionCount < 3
+ anchors.right: parent.right
+ anchors.rightMargin: Theme.spacingL
+ anchors.top: collapsedContent.bottom
+ anchors.topMargin: contentSpacing + collapsedDismissOffset
+ width: Math.max(collapsedClearText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
+ height: actionButtonHeight
+ radius: Theme.notificationButtonCornerRadius
+ color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
+
+ StyledText {
+ id: collapsedClearText
+ text: I18n.tr("Dismiss")
+ color: clearButton.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onEntered: clearButton.isHovered = true
+ onExited: clearButton.isHovered = false
+ onClicked: NotificationService.dismissGroup(notificationGroup?.key || "")
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ visible: !expanded && (notificationGroup?.count || 0) > 1 && !descriptionExpanded
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ root.userInitiatedExpansion = true;
+ NotificationService.toggleGroupExpansion(notificationGroup?.key || "");
+ }
+ z: -1
+ }
+
+ Item {
+ id: fixedControls
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.topMargin: cardPadding
+ anchors.rightMargin: Theme.spacingL
+ width: compactMode ? 52 : 60
+ height: compactMode ? 24 : 28
+
+ DankActionButton {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ visible: (notificationGroup?.count || 0) > 1
+ iconName: expanded ? "expand_less" : "expand_more"
+ iconSize: compactMode ? 16 : 18
+ buttonSize: compactMode ? 24 : 28
+ onClicked: {
+ root.userInitiatedExpansion = true;
+ NotificationService.toggleGroupExpansion(notificationGroup?.key || "");
+ }
+ }
+
+ DankActionButton {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ iconName: "close"
+ iconSize: compactMode ? 16 : 18
+ buttonSize: compactMode ? 24 : 28
+ onClicked: NotificationService.dismissGroup(notificationGroup?.key || "")
+ }
+ }
+
+ Behavior on height {
+ enabled: root.__initialized && root.userInitiatedExpansion && root.animateExpansion
+ NumberAnimation {
+ duration: root.expanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Theme.expressiveCurves.emphasized
+ onRunningChanged: {
+ if (running) {
+ root.isAnimating = true;
+ } else {
+ root.isAnimating = false;
+ root.userInitiatedExpansion = false;
+ }
+ }
+ }
+ }
+
+ Menu {
+ id: notificationCardContextMenu
+ width: 220
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ background: Rectangle {
+ color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
+ radius: Theme.cornerRadius
+ border.width: 0
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
+ }
+
+ MenuItem {
+ id: setNotificationRulesItem
+ text: I18n.tr("Set notification rules")
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ const appName = notificationGroup?.appName || "";
+ const desktopEntry = notificationGroup?.latestNotification?.desktopEntry || "";
+ SettingsData.addNotificationRuleForNotification(appName, desktopEntry);
+ PopoutService.openSettingsWithTab("notifications");
+ }
+ }
+
+ MenuItem {
+ id: muteUnmuteItem
+ readonly property bool isMuted: SettingsData.isAppMuted(notificationGroup?.appName || "", notificationGroup?.latestNotification?.desktopEntry || "")
+ text: isMuted ? I18n.tr("Unmute popups for %1").arg(notificationGroup?.appName || I18n.tr("this app")) : I18n.tr("Mute popups for %1").arg(notificationGroup?.appName || I18n.tr("this app"))
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: {
+ const appName = notificationGroup?.appName || "";
+ const desktopEntry = notificationGroup?.latestNotification?.desktopEntry || "";
+ if (isMuted) {
+ SettingsData.removeMuteRuleForApp(appName, desktopEntry);
+ } else {
+ SettingsData.addMuteRuleForApp(appName, desktopEntry);
+ NotificationService.dismissGroup(notificationGroup?.key || "");
+ }
+ }
+ }
+
+ MenuItem {
+ text: I18n.tr("Dismiss")
+
+ contentItem: StyledText {
+ text: parent.text
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ leftPadding: Theme.spacingS
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
+ radius: Theme.cornerRadius / 2
+ }
+
+ onTriggered: NotificationService.dismissGroup(notificationGroup?.key || "")
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.RightButton
+ z: -2
+ onClicked: mouse => {
+ if (mouse.button === Qt.RightButton && notificationGroup) {
+ notificationCardContextMenu.popup();
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml
new file mode 100644
index 0000000..b7b616b
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml
@@ -0,0 +1,321 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankPopout {
+ id: root
+
+ layerNamespace: "dms:notification-center-popout"
+ fullHeightSurface: true
+
+ property bool notificationHistoryVisible: false
+ property var triggerScreen: null
+ property real stablePopupHeight: 400
+ property real _lastAlignedContentHeight: -1
+ property bool _pendingSizedOpen: false
+
+ function updateStablePopupHeight() {
+ const item = contentLoader.item;
+ if (item && !root.shouldBeVisible) {
+ const notificationList = findChild(item, "notificationList");
+ if (notificationList && typeof notificationList.forceLayout === "function") {
+ notificationList.forceLayout();
+ }
+ }
+ const target = item ? Theme.px(item.implicitHeight, dpr) : 400;
+ if (Math.abs(target - _lastAlignedContentHeight) < 0.5)
+ return;
+ _lastAlignedContentHeight = target;
+ stablePopupHeight = target;
+ }
+
+ NotificationKeyboardController {
+ id: keyboardController
+ listView: null
+ isOpen: root.shouldBeVisible
+ onClose: () => {
+ notificationHistoryVisible = false;
+ }
+ }
+
+ popupWidth: triggerScreen ? Math.min(500, Math.max(380, triggerScreen.width - 48)) : 400
+ popupHeight: stablePopupHeight
+ positioning: ""
+ animationScaleCollapsed: 0.94
+ animationOffset: 0
+ suspendShadowWhileResizing: false
+
+ screen: triggerScreen
+
+ function toggle() {
+ notificationHistoryVisible = !notificationHistoryVisible;
+ }
+
+ function openSized() {
+ if (!notificationHistoryVisible)
+ return;
+
+ primeContent();
+ if (contentLoader.item) {
+ updateStablePopupHeight();
+ _pendingSizedOpen = false;
+ Qt.callLater(() => {
+ if (!notificationHistoryVisible)
+ return;
+ updateStablePopupHeight();
+ open();
+ clearPrimedContent();
+ });
+ return;
+ }
+
+ _pendingSizedOpen = true;
+ }
+
+ onBackgroundClicked: {
+ notificationHistoryVisible = false;
+ }
+
+ onNotificationHistoryVisibleChanged: {
+ if (notificationHistoryVisible) {
+ openSized();
+ } else {
+ _pendingSizedOpen = false;
+ clearPrimedContent();
+ close();
+ }
+ }
+
+ function setupKeyboardNavigation() {
+ if (!contentLoader.item)
+ return;
+ contentLoader.item.externalKeyboardController = keyboardController;
+
+ const notificationList = findChild(contentLoader.item, "notificationList");
+ const notificationHeader = findChild(contentLoader.item, "notificationHeader");
+
+ if (notificationList) {
+ keyboardController.listView = notificationList;
+ notificationList.keyboardController = keyboardController;
+ }
+ if (notificationHeader) {
+ notificationHeader.keyboardController = keyboardController;
+ }
+
+ keyboardController.reset();
+ keyboardController.rebuildFlatNavigation();
+ }
+
+ Connections {
+ target: contentLoader
+ function onLoaded() {
+ root.updateStablePopupHeight();
+ if (root._pendingSizedOpen && root.notificationHistoryVisible) {
+ Qt.callLater(() => {
+ if (!root._pendingSizedOpen || !root.notificationHistoryVisible)
+ return;
+ root.updateStablePopupHeight();
+ root._pendingSizedOpen = false;
+ root.open();
+ root.clearPrimedContent();
+ });
+ return;
+ }
+ if (root.shouldBeVisible)
+ Qt.callLater(root.setupKeyboardNavigation);
+ }
+ }
+
+ Connections {
+ target: contentLoader.item
+ function onImplicitHeightChanged() {
+ root.updateStablePopupHeight();
+ }
+ }
+
+ onDprChanged: updateStablePopupHeight()
+
+ onShouldBeVisibleChanged: {
+ if (shouldBeVisible) {
+ NotificationService.onOverlayOpen();
+ updateStablePopupHeight();
+ if (contentLoader.item)
+ Qt.callLater(setupKeyboardNavigation);
+ } else {
+ NotificationService.onOverlayClose();
+ keyboardController.keyboardNavigationActive = false;
+ NotificationService.expandedGroups = {};
+ NotificationService.expandedMessages = {};
+ }
+ }
+
+ function findChild(parent, objectName) {
+ if (parent.objectName === objectName) {
+ return parent;
+ }
+ for (let i = 0; i < parent.children.length; i++) {
+ const child = parent.children[i];
+ const result = findChild(child, objectName);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ }
+
+ content: Component {
+ Rectangle {
+ id: notificationContent
+
+ LayoutMirroring.enabled: I18n.isRtl
+ LayoutMirroring.childrenInherit: true
+
+ property var externalKeyboardController: null
+ property real cachedHeaderHeight: 32
+ readonly property real settingsMaxHeight: {
+ const screenH = root.screen ? root.screen.height : 1080;
+ const maxPopupH = screenH * 0.8;
+ const overhead = cachedHeaderHeight + Theme.spacingL * 2 + Theme.spacingM * 2;
+ return Math.max(200, maxPopupH - overhead - 150);
+ }
+ implicitHeight: {
+ let baseHeight = Theme.spacingL * 2;
+ baseHeight += cachedHeaderHeight;
+ baseHeight += Theme.spacingM * 2;
+
+ const settingsHeight = notificationSettings.expanded ? Math.min(notificationSettings.naturalContentHeight, settingsMaxHeight) : 0;
+ const currentListHeight = root.shouldBeVisible ? notificationList.stableContentHeight : notificationList.listContentHeight;
+ let listHeight = notificationHeader.currentTab === 0 ? currentListHeight : Math.max(200, NotificationService.historyList.length * 80);
+ if (notificationHeader.currentTab === 0 && NotificationService.groupedNotifications.length === 0) {
+ listHeight = 200;
+ }
+ if (notificationHeader.currentTab === 1 && NotificationService.historyList.length === 0) {
+ listHeight = 200;
+ }
+
+ const maxContentArea = 600;
+ const availableListSpace = Math.max(200, maxContentArea - settingsHeight);
+
+ baseHeight += settingsHeight;
+ baseHeight += Math.min(listHeight, availableListSpace);
+
+ const maxHeight = root.screen ? root.screen.height * 0.8 : Screen.height * 0.8;
+ return Math.max(300, Math.min(baseHeight, maxHeight));
+ }
+
+ color: "transparent"
+ focus: true
+
+ Component.onCompleted: {
+ if (root.shouldBeVisible) {
+ forceActiveFocus();
+ }
+ }
+
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Escape) {
+ notificationHistoryVisible = false;
+ event.accepted = true;
+ return;
+ }
+
+ if (event.key === Qt.Key_Left) {
+ if (notificationHeader.currentTab > 0) {
+ notificationHeader.currentTab = 0;
+ event.accepted = true;
+ }
+ return;
+ }
+
+ if (event.key === Qt.Key_Right) {
+ if (notificationHeader.currentTab === 0 && SettingsData.notificationHistoryEnabled) {
+ notificationHeader.currentTab = 1;
+ event.accepted = true;
+ }
+ return;
+ }
+ if (notificationHeader.currentTab === 1) {
+ historyList.handleKey(event);
+ return;
+ }
+ if (externalKeyboardController) {
+ externalKeyboardController.handleKey(event);
+ }
+ }
+
+ Connections {
+ function onShouldBeVisibleChanged() {
+ if (root.shouldBeVisible) {
+ Qt.callLater(() => {
+ notificationContent.forceActiveFocus();
+ });
+ } else {
+ notificationContent.focus = false;
+ }
+ }
+ target: root
+ }
+
+ FocusScope {
+ id: contentColumn
+
+ anchors.fill: parent
+ anchors.margins: Theme.spacingL
+ focus: true
+
+ Column {
+ id: contentColumnInner
+ anchors.fill: parent
+ spacing: Theme.spacingM
+
+ NotificationHeader {
+ id: notificationHeader
+ objectName: "notificationHeader"
+ onHeightChanged: notificationContent.cachedHeaderHeight = height
+ }
+
+ NotificationSettings {
+ id: notificationSettings
+ expanded: notificationHeader.showSettings
+ maxAllowedHeight: notificationContent.settingsMaxHeight
+ }
+
+ Item {
+ visible: notificationHeader.currentTab === 0
+ width: parent.width
+ height: parent.height - notificationContent.cachedHeaderHeight - notificationSettings.height - contentColumnInner.spacing * 2
+
+ KeyboardNavigatedNotificationList {
+ id: notificationList
+ objectName: "notificationList"
+ anchors.fill: parent
+ anchors.leftMargin: -shadowHorizontalGutter
+ anchors.rightMargin: -shadowHorizontalGutter
+ anchors.topMargin: -(shadowVerticalGutter + delegateShadowGutter / 2)
+ anchors.bottomMargin: -(shadowVerticalGutter + delegateShadowGutter / 2)
+ cardAnimateExpansion: true
+ }
+ }
+
+ HistoryNotificationList {
+ id: historyList
+ visible: notificationHeader.currentTab === 1
+ width: parent.width
+ height: parent.height - notificationContent.cachedHeaderHeight - notificationSettings.height - contentColumnInner.spacing * 2
+ }
+ }
+ }
+
+ NotificationKeyboardHints {
+ id: keyboardHints
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ showHints: notificationHeader.currentTab === 0 ? (externalKeyboardController && externalKeyboardController.showKeyboardHints) || false : historyList.showKeyboardHints
+ z: 200
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationEmptyState.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationEmptyState.qml
new file mode 100644
index 0000000..da8746e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationEmptyState.qml
@@ -0,0 +1,34 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ width: parent.width
+ height: 200
+ visible: NotificationService.notifications.length === 0
+
+ Column {
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+ width: parent.width * 0.8
+
+ DankIcon {
+ anchors.horizontalCenter: parent.horizontalCenter
+ name: "notifications_none"
+ size: Theme.iconSizeLarge + 16
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
+ }
+
+ StyledText {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: I18n.tr("Nothing to see here")
+ font.pixelSize: Theme.fontSizeLarge
+ color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
+ font.weight: Font.Medium
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationHeader.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationHeader.qml
new file mode 100644
index 0000000..201e716
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationHeader.qml
@@ -0,0 +1,158 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var keyboardController: null
+ property bool showSettings: false
+ property int currentTab: 0
+
+ onCurrentTabChanged: {
+ if (currentTab === 1 && !SettingsData.notificationHistoryEnabled)
+ currentTab = 0;
+ }
+
+ Connections {
+ target: SettingsData
+ function onNotificationHistoryEnabledChanged() {
+ if (!SettingsData.notificationHistoryEnabled)
+ root.currentTab = 0;
+ }
+ }
+
+ width: parent.width
+ height: headerColumn.implicitHeight
+
+ DankTooltipV2 {
+ id: sharedTooltip
+ }
+
+ Column {
+ id: headerColumn
+ width: parent.width
+ spacing: Theme.spacingS
+
+ Item {
+ width: parent.width
+ height: Math.max(titleRow.implicitHeight, actionsRow.implicitHeight)
+
+ Row {
+ id: titleRow
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ StyledText {
+ text: I18n.tr("Notifications")
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ DankActionButton {
+ id: doNotDisturbButton
+ iconName: SessionData.doNotDisturb ? "notifications_off" : "notifications"
+ iconColor: SessionData.doNotDisturb ? Theme.error : Theme.surfaceText
+ buttonSize: Theme.iconSize + Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: SessionData.setDoNotDisturb(!SessionData.doNotDisturb)
+ onEntered: sharedTooltip.show(I18n.tr("Do Not Disturb"), doNotDisturbButton, 0, 0, "bottom")
+ onExited: sharedTooltip.hide()
+ }
+ }
+
+ Row {
+ id: actionsRow
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ DankActionButton {
+ id: helpButton
+ iconName: "info"
+ iconColor: (keyboardController && keyboardController.showKeyboardHints) ? Theme.primary : Theme.surfaceText
+ buttonSize: Theme.iconSize + Theme.spacingS
+ visible: keyboardController !== null
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: {
+ if (keyboardController)
+ keyboardController.showKeyboardHints = !keyboardController.showKeyboardHints;
+ }
+ }
+
+ DankActionButton {
+ id: settingsButton
+ iconName: "settings"
+ iconColor: root.showSettings ? Theme.primary : Theme.surfaceText
+ buttonSize: Theme.iconSize + Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: root.showSettings = !root.showSettings
+ }
+
+ Rectangle {
+ id: clearAllButton
+ width: clearButtonContent.implicitWidth + Theme.spacingM * 2
+ height: Theme.iconSize + Theme.spacingS
+ radius: Theme.cornerRadius
+ visible: root.currentTab === 0 ? NotificationService.notifications.length > 0 : NotificationService.historyList.length > 0
+ color: clearArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
+
+ Row {
+ id: clearButtonContent
+ anchors.centerIn: parent
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "delete_sweep"
+ size: Theme.iconSizeSmall
+ color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: I18n.tr("Clear")
+ font.pixelSize: Theme.fontSizeSmall
+ color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ id: clearArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (root.currentTab === 0) {
+ NotificationService.clearAllNotifications();
+ } else {
+ NotificationService.clearHistory();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ DankButtonGroup {
+ id: tabGroup
+ width: parent.width
+ currentIndex: root.currentTab
+ buttonHeight: 32
+ buttonPadding: Theme.spacingM
+ checkEnabled: false
+ textSize: Theme.fontSizeSmall
+ visible: SettingsData.notificationHistoryEnabled
+ model: [I18n.tr("Current", "notification center tab") + " (" + NotificationService.notifications.length + ")", I18n.tr("History", "notification center tab") + " (" + NotificationService.historyList.length + ")"]
+ onSelectionChanged: (index, selected) => {
+ if (selected)
+ root.currentTab = index;
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml
new file mode 100644
index 0000000..eda12c0
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationKeyboardController.qml
@@ -0,0 +1,556 @@
+import QtQuick
+import qs.Services
+
+QtObject {
+ id: controller
+
+ property var listView: null
+ property bool isOpen: false
+ property var onClose: null
+
+ property int selectionVersion: 0
+
+ property bool keyboardNavigationActive: false
+ property int selectedFlatIndex: 0
+ property var flatNavigation: []
+ property bool showKeyboardHints: false
+
+ property string selectedNotificationId: ""
+ property string selectedGroupKey: ""
+ property string selectedItemType: ""
+ property bool isTogglingGroup: false
+ property bool isRebuilding: false
+
+ function rebuildFlatNavigation() {
+ isRebuilding = true;
+
+ const nav = [];
+ const groups = NotificationService.groupedNotifications;
+
+ for (var i = 0; i < groups.length; i++) {
+ const group = groups[i];
+ const isExpanded = NotificationService.expandedGroups[group.key] || false;
+
+ nav.push({
+ "type": "group",
+ "groupIndex": i,
+ "notificationIndex": -1,
+ "groupKey": group.key,
+ "notificationId": ""
+ });
+
+ if (isExpanded) {
+ const notifications = group.notifications || [];
+ const maxNotifications = Math.min(notifications.length, 10);
+ for (var j = 0; j < maxNotifications; j++) {
+ const notifId = String(notifications[j] && notifications[j].notification && notifications[j].notification.id ? notifications[j].notification.id : "");
+ nav.push({
+ "type": "notification",
+ "groupIndex": i,
+ "notificationIndex": j,
+ "groupKey": group.key,
+ "notificationId": notifId
+ });
+ }
+ }
+ }
+
+ flatNavigation = nav;
+ updateSelectedIndexFromId();
+ isRebuilding = false;
+ }
+
+ function updateSelectedIndexFromId() {
+ if (!keyboardNavigationActive) {
+ return;
+ }
+
+ for (var i = 0; i < flatNavigation.length; i++) {
+ const item = flatNavigation[i];
+
+ if (selectedItemType === "group" && item.type === "group" && item.groupKey === selectedGroupKey) {
+ selectedFlatIndex = i;
+ selectionVersion++; // Trigger UI update
+ return;
+ } else if (selectedItemType === "notification" && item.type === "notification" && String(item.notificationId) === String(selectedNotificationId)) {
+ selectedFlatIndex = i;
+ selectionVersion++; // Trigger UI update
+ return;
+ }
+ }
+
+ // If not found, try to find the same group but select the group header instead
+ if (selectedItemType === "notification") {
+ for (var j = 0; j < flatNavigation.length; j++) {
+ const groupItem = flatNavigation[j];
+ if (groupItem.type === "group" && groupItem.groupKey === selectedGroupKey) {
+ selectedFlatIndex = j;
+ selectedItemType = "group";
+ selectedNotificationId = "";
+ selectionVersion++; // Trigger UI update
+ return;
+ }
+ }
+ }
+
+ // If still not found, clamp to valid range and update
+ if (flatNavigation.length > 0) {
+ selectedFlatIndex = Math.min(selectedFlatIndex, flatNavigation.length - 1);
+ selectedFlatIndex = Math.max(selectedFlatIndex, 0);
+ updateSelectedIdFromIndex();
+ selectionVersion++; // Trigger UI update
+ }
+ }
+
+ function updateSelectedIdFromIndex() {
+ if (selectedFlatIndex >= 0 && selectedFlatIndex < flatNavigation.length) {
+ const item = flatNavigation[selectedFlatIndex];
+ selectedItemType = item.type;
+ selectedGroupKey = item.groupKey;
+ selectedNotificationId = item.notificationId;
+ }
+ }
+
+ function reset() {
+ selectedFlatIndex = 0;
+ keyboardNavigationActive = false;
+ showKeyboardHints = false;
+ // Reset keyboardActive when modal is reset
+ if (listView) {
+ listView.keyboardActive = false;
+ }
+ rebuildFlatNavigation();
+ }
+
+ function selectNext() {
+ keyboardNavigationActive = true;
+ if (flatNavigation.length === 0)
+ return;
+
+ // Re-enable auto-scrolling when arrow keys are used
+ if (listView && listView.enableAutoScroll) {
+ listView.enableAutoScroll();
+ }
+
+ selectedFlatIndex = Math.min(selectedFlatIndex + 1, flatNavigation.length - 1);
+ updateSelectedIdFromIndex();
+ selectionVersion++;
+ ensureVisible();
+ }
+
+ function selectNextWrapping() {
+ keyboardNavigationActive = true;
+ if (flatNavigation.length === 0)
+ return;
+
+ // Re-enable auto-scrolling when arrow keys are used
+ if (listView && listView.enableAutoScroll) {
+ listView.enableAutoScroll();
+ }
+
+ selectedFlatIndex = (selectedFlatIndex + 1) % flatNavigation.length;
+ updateSelectedIdFromIndex();
+ selectionVersion++;
+ ensureVisible();
+ }
+
+ function selectPrevious() {
+ keyboardNavigationActive = true;
+ if (flatNavigation.length === 0)
+ return;
+
+ // Re-enable auto-scrolling when arrow keys are used
+ if (listView && listView.enableAutoScroll) {
+ listView.enableAutoScroll();
+ }
+
+ selectedFlatIndex = Math.max(selectedFlatIndex - 1, 0);
+ updateSelectedIdFromIndex();
+ selectionVersion++;
+ ensureVisible();
+ }
+
+ function toggleGroupExpanded() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return;
+ const currentItem = flatNavigation[selectedFlatIndex];
+ const groups = NotificationService.groupedNotifications;
+ const group = groups[currentItem.groupIndex];
+ if (!group)
+ return;
+
+ // Prevent expanding groups with < 2 notifications
+ const notificationCount = group.notifications ? group.notifications.length : 0;
+ if (notificationCount < 2)
+ return;
+ const wasExpanded = NotificationService.expandedGroups[group.key] || false;
+ const groupIndex = currentItem.groupIndex;
+
+ isTogglingGroup = true;
+ NotificationService.toggleGroupExpansion(group.key);
+ rebuildFlatNavigation();
+
+ // Smart selection after toggle
+ if (!wasExpanded) {
+ // Just expanded - move to first notification in the group
+ for (var i = 0; i < flatNavigation.length; i++) {
+ if (flatNavigation[i].type === "notification" && flatNavigation[i].groupIndex === groupIndex) {
+ selectedFlatIndex = i;
+ break;
+ }
+ }
+ } else {
+ // Just collapsed - stay on the group header
+ for (var i = 0; i < flatNavigation.length; i++) {
+ if (flatNavigation[i].type === "group" && flatNavigation[i].groupIndex === groupIndex) {
+ selectedFlatIndex = i;
+ break;
+ }
+ }
+ }
+
+ isTogglingGroup = false;
+ }
+
+ function handleEnterKey() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return;
+ const currentItem = flatNavigation[selectedFlatIndex];
+ const groups = NotificationService.groupedNotifications;
+ const group = groups[currentItem.groupIndex];
+ if (!group)
+ return;
+ if (currentItem.type === "group") {
+ const notificationCount = group.notifications ? group.notifications.length : 0;
+ if (notificationCount >= 2) {
+ toggleGroupExpanded();
+ } else {
+ executeAction(0);
+ }
+ } else if (currentItem.type === "notification") {
+ executeAction(0);
+ }
+ }
+
+ function toggleTextExpanded() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return;
+ const currentItem = flatNavigation[selectedFlatIndex];
+ const groups = NotificationService.groupedNotifications;
+ const group = groups[currentItem.groupIndex];
+ if (!group)
+ return;
+ let messageId = "";
+
+ if (currentItem.type === "group") {
+ messageId = group.latestNotification?.notification?.id + "_desc";
+ } else if (currentItem.type === "notification" && currentItem.notificationIndex >= 0 && currentItem.notificationIndex < group.notifications.length) {
+ messageId = group.notifications[currentItem.notificationIndex]?.notification?.id + "_desc";
+ }
+
+ if (messageId) {
+ NotificationService.toggleMessageExpansion(messageId);
+ }
+ }
+
+ function executeAction(actionIndex) {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return;
+ const currentItem = flatNavigation[selectedFlatIndex];
+ const groups = NotificationService.groupedNotifications;
+ const group = groups[currentItem.groupIndex];
+ if (!group)
+ return;
+ let actions = [];
+
+ if (currentItem.type === "group") {
+ actions = group.latestNotification?.actions || [];
+ } else if (currentItem.type === "notification" && currentItem.notificationIndex >= 0 && currentItem.notificationIndex < group.notifications.length) {
+ actions = group.notifications[currentItem.notificationIndex]?.actions || [];
+ }
+
+ if (actionIndex >= 0 && actionIndex < actions.length) {
+ const action = actions[actionIndex];
+ if (action.invoke) {
+ action.invoke();
+ if (onClose)
+ onClose();
+ }
+ }
+ }
+
+ function clearSelected() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length)
+ return;
+ const currentItem = flatNavigation[selectedFlatIndex];
+ const groups = NotificationService.groupedNotifications;
+ const group = groups[currentItem.groupIndex];
+ if (!group)
+ return;
+ if (currentItem.type === "group") {
+ NotificationService.dismissGroup(group.key);
+ } else if (currentItem.type === "notification") {
+ const notification = group.notifications[currentItem.notificationIndex];
+ NotificationService.dismissNotification(notification);
+ }
+
+ rebuildFlatNavigation();
+
+ if (flatNavigation.length === 0) {
+ keyboardNavigationActive = false;
+ if (listView) {
+ listView.keyboardActive = false;
+ }
+ } else {
+ selectedFlatIndex = Math.min(selectedFlatIndex, flatNavigation.length - 1);
+ updateSelectedIdFromIndex();
+ ensureVisible();
+ }
+ }
+
+ function findRepeater(parent) {
+ if (!parent || !parent.children) {
+ return null;
+ }
+ for (var i = 0; i < parent.children.length; i++) {
+ const child = parent.children[i];
+ if (child.objectName === "notificationRepeater") {
+ return child;
+ }
+ const found = findRepeater(child);
+ if (found) {
+ return found;
+ }
+ }
+ return null;
+ }
+
+ function ensureVisible() {
+ if (flatNavigation.length === 0 || selectedFlatIndex >= flatNavigation.length || !listView)
+ return;
+ const currentItem = flatNavigation[selectedFlatIndex];
+
+ if (keyboardNavigationActive && currentItem && currentItem.groupIndex >= 0) {
+ if (currentItem.type === "notification") {
+ const groupDelegate = listView.itemAtIndex(currentItem.groupIndex);
+ if (groupDelegate && groupDelegate.children && groupDelegate.children.length > 0) {
+ const notificationCard = groupDelegate.children[0];
+ const repeater = findRepeater(notificationCard);
+
+ if (repeater && currentItem.notificationIndex < repeater.count) {
+ const notificationItem = repeater.itemAt(currentItem.notificationIndex);
+ if (notificationItem) {
+ const itemPos = notificationItem.mapToItem(listView.contentItem, 0, 0);
+ const itemY = itemPos.y;
+ const itemHeight = notificationItem.height;
+
+ const viewportTop = listView.contentY;
+ const viewportBottom = listView.contentY + listView.height;
+
+ if (itemY < viewportTop) {
+ listView.contentY = itemY - 20;
+ } else if (itemY + itemHeight > viewportBottom) {
+ listView.contentY = itemY + itemHeight - listView.height + 20;
+ }
+ }
+ }
+ }
+ } else {
+ listView.positionViewAtIndex(currentItem.groupIndex, ListView.Contain);
+ }
+
+ listView.forceLayout();
+ }
+ }
+
+ function handleKey(event) {
+ if ((event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) && (event.modifiers & Qt.ShiftModifier)) {
+ NotificationService.clearAllNotifications();
+ rebuildFlatNavigation();
+ if (flatNavigation.length === 0) {
+ keyboardNavigationActive = false;
+ if (listView) {
+ listView.keyboardActive = false;
+ }
+ } else {
+ selectedFlatIndex = 0;
+ updateSelectedIdFromIndex();
+ }
+ selectionVersion++;
+ event.accepted = true;
+ return;
+ }
+
+ if (event.key === Qt.Key_Escape) {
+ if (keyboardNavigationActive) {
+ keyboardNavigationActive = false;
+ event.accepted = true;
+ } else {
+ if (onClose)
+ onClose();
+ event.accepted = true;
+ }
+ } else if (event.key === Qt.Key_Down || event.key === 16777237) {
+ if (!keyboardNavigationActive) {
+ keyboardNavigationActive = true;
+ rebuildFlatNavigation(); // Ensure we have fresh navigation data
+ selectedFlatIndex = 0;
+ updateSelectedIdFromIndex();
+ // Set keyboardActive on listView to show highlight
+ if (listView) {
+ listView.keyboardActive = true;
+ }
+ selectionVersion++;
+ ensureVisible();
+ event.accepted = true;
+ } else {
+ selectNext();
+ event.accepted = true;
+ }
+ } else if (event.key === Qt.Key_Up || event.key === 16777235) {
+ if (!keyboardNavigationActive) {
+ keyboardNavigationActive = true;
+ rebuildFlatNavigation(); // Ensure we have fresh navigation data
+ selectedFlatIndex = 0;
+ updateSelectedIdFromIndex();
+ // Set keyboardActive on listView to show highlight
+ if (listView) {
+ listView.keyboardActive = true;
+ }
+ selectionVersion++;
+ ensureVisible();
+ event.accepted = true;
+ } else if (selectedFlatIndex === 0) {
+ keyboardNavigationActive = false;
+ // Reset keyboardActive when navigation is disabled
+ if (listView) {
+ listView.keyboardActive = false;
+ }
+ selectionVersion++;
+ event.accepted = true;
+ return;
+ } else {
+ selectPrevious();
+ event.accepted = true;
+ }
+ } else if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) {
+ if (!keyboardNavigationActive) {
+ keyboardNavigationActive = true;
+ rebuildFlatNavigation();
+ selectedFlatIndex = 0;
+ updateSelectedIdFromIndex();
+ if (listView) {
+ listView.keyboardActive = true;
+ }
+ selectionVersion++;
+ ensureVisible();
+ } else {
+ selectNext();
+ }
+ event.accepted = true;
+ } else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) {
+ if (!keyboardNavigationActive) {
+ keyboardNavigationActive = true;
+ rebuildFlatNavigation();
+ selectedFlatIndex = 0;
+ updateSelectedIdFromIndex();
+ if (listView) {
+ listView.keyboardActive = true;
+ }
+ selectionVersion++;
+ ensureVisible();
+ } else if (selectedFlatIndex === 0) {
+ keyboardNavigationActive = false;
+ if (listView) {
+ listView.keyboardActive = false;
+ }
+ selectionVersion++;
+ } else {
+ selectPrevious();
+ }
+ event.accepted = true;
+ } else if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) {
+ if (!keyboardNavigationActive) {
+ keyboardNavigationActive = true;
+ rebuildFlatNavigation();
+ selectedFlatIndex = 0;
+ updateSelectedIdFromIndex();
+ if (listView) {
+ listView.keyboardActive = true;
+ }
+ selectionVersion++;
+ ensureVisible();
+ } else {
+ selectNext();
+ }
+ event.accepted = true;
+ } else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) {
+ if (!keyboardNavigationActive) {
+ keyboardNavigationActive = true;
+ rebuildFlatNavigation();
+ selectedFlatIndex = 0;
+ updateSelectedIdFromIndex();
+ if (listView) {
+ listView.keyboardActive = true;
+ }
+ selectionVersion++;
+ ensureVisible();
+ } else if (selectedFlatIndex === 0) {
+ keyboardNavigationActive = false;
+ if (listView) {
+ listView.keyboardActive = false;
+ }
+ selectionVersion++;
+ } else {
+ selectPrevious();
+ }
+ event.accepted = true;
+ } else if (keyboardNavigationActive) {
+ if (event.key === Qt.Key_Space) {
+ toggleGroupExpanded();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ handleEnterKey();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_E) {
+ toggleTextExpanded();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) {
+ clearSelected();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Tab) {
+ selectNext();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Backtab) {
+ selectPrevious();
+ event.accepted = true;
+ } else if (event.key >= Qt.Key_1 && event.key <= Qt.Key_9) {
+ const actionIndex = event.key - Qt.Key_1;
+ executeAction(actionIndex);
+ event.accepted = true;
+ }
+ }
+
+ if (event.key === Qt.Key_F10) {
+ showKeyboardHints = !showKeyboardHints;
+ event.accepted = true;
+ }
+ }
+
+ // Get current selection info for UI
+ function getCurrentSelection() {
+ if (!keyboardNavigationActive || selectedFlatIndex < 0 || selectedFlatIndex >= flatNavigation.length) {
+ return {
+ "type": "",
+ "groupIndex": -1,
+ "notificationIndex": -1
+ };
+ }
+ const result = flatNavigation[selectedFlatIndex] || {
+ "type": "",
+ "groupIndex": -1,
+ "notificationIndex": -1
+ };
+ return result;
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml
new file mode 100644
index 0000000..78ba2d5
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationKeyboardHints.qml
@@ -0,0 +1,50 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool showHints: false
+
+ height: 80
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
+ border.color: Theme.primary
+ border.width: 2
+ opacity: showHints ? 1 : 0
+ z: 100
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingS
+ spacing: 2
+
+ StyledText {
+ text: I18n.tr("↑/↓: Nav • Space: Expand • Enter: Action/Expand • E: Text")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ StyledText {
+ text: I18n.tr("Del: Clear • Shift+Del: Clear All • 1-9: Actions • F10: Help • Esc: Close")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ width: parent.width
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationSettings.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationSettings.qml
new file mode 100644
index 0000000..97364c7
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationSettings.qml
@@ -0,0 +1,449 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+
+Rectangle {
+ id: root
+
+ property bool expanded: false
+ property real maxAllowedHeight: 0
+ readonly property real naturalContentHeight: contentColumn.height + Theme.spacingL * 2
+
+ width: parent.width
+ height: expanded ? (maxAllowedHeight > 0 ? Math.min(naturalContentHeight, maxAllowedHeight) : naturalContentHeight) : 0
+ visible: expanded
+ clip: true
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.3)
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ border.width: 1
+
+ Behavior on height {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ opacity: expanded ? 1 : 0
+ Behavior on opacity {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.emphasizedEasing
+ }
+ }
+
+ readonly property var timeoutOptions: [
+ {
+ "text": I18n.tr("Never"),
+ "value": 0
+ },
+ {
+ "text": I18n.tr("1 second"),
+ "value": 1000
+ },
+ {
+ "text": I18n.tr("3 seconds"),
+ "value": 3000
+ },
+ {
+ "text": I18n.tr("5 seconds"),
+ "value": 5000
+ },
+ {
+ "text": I18n.tr("8 seconds"),
+ "value": 8000
+ },
+ {
+ "text": I18n.tr("10 seconds"),
+ "value": 10000
+ },
+ {
+ "text": I18n.tr("15 seconds"),
+ "value": 15000
+ },
+ {
+ "text": I18n.tr("30 seconds"),
+ "value": 30000
+ },
+ {
+ "text": I18n.tr("1 minute"),
+ "value": 60000
+ },
+ {
+ "text": I18n.tr("2 minutes"),
+ "value": 120000
+ },
+ {
+ "text": I18n.tr("5 minutes"),
+ "value": 300000
+ },
+ {
+ "text": I18n.tr("10 minutes"),
+ "value": 600000
+ }
+ ]
+
+ function getTimeoutText(value) {
+ if (value === undefined || value === null || isNaN(value)) {
+ return I18n.tr("5 seconds");
+ }
+
+ for (let i = 0; i < timeoutOptions.length; i++) {
+ if (timeoutOptions[i].value === value) {
+ return timeoutOptions[i].text;
+ }
+ }
+ if (value === 0) {
+ return I18n.tr("Never");
+ }
+ if (value < 1000) {
+ return value + "ms";
+ }
+ if (value < 60000) {
+ return Math.round(value / 1000) + " " + I18n.tr("seconds");
+ }
+ return Math.round(value / 60000) + " " + I18n.tr("minutes");
+ }
+
+ Flickable {
+ id: settingsFlickable
+ anchors.fill: parent
+ contentHeight: contentColumn.height + Theme.spacingL * 2
+ clip: true
+ flickableDirection: Flickable.VerticalFlick
+ boundsBehavior: Flickable.DragAndOvershootBounds
+ interactive: root.naturalContentHeight > root.height
+
+ Column {
+ id: contentColumn
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ spacing: Theme.spacingM
+
+ StyledText {
+ text: I18n.tr("Notification Settings")
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Bold
+ color: Theme.surfaceText
+ }
+
+ Item {
+ width: parent.width
+ height: Math.max(dndRow.implicitHeight, dndToggle.implicitHeight) + Theme.spacingS
+
+ Row {
+ id: dndRow
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: SessionData.doNotDisturb ? "notifications_off" : "notifications"
+ size: Theme.iconSizeSmall
+ color: SessionData.doNotDisturb ? Theme.error : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: I18n.tr("Do Not Disturb")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ id: dndToggle
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SessionData.doNotDisturb
+ onToggled: SessionData.setDoNotDisturb(!SessionData.doNotDisturb)
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ }
+
+ StyledText {
+ text: I18n.tr("Notification Timeouts")
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceVariantText
+ }
+
+ DankDropdown {
+ text: I18n.tr("Low Priority")
+ description: I18n.tr("Timeout for low priority notifications")
+ currentValue: getTimeoutText(SettingsData.notificationTimeoutLow)
+ options: timeoutOptions.map(opt => opt.text)
+ onValueChanged: value => {
+ for (let i = 0; i < timeoutOptions.length; i++) {
+ if (timeoutOptions[i].text === value) {
+ SettingsData.set("notificationTimeoutLow", timeoutOptions[i].value);
+ break;
+ }
+ }
+ }
+ }
+
+ DankDropdown {
+ text: I18n.tr("Normal Priority")
+ description: I18n.tr("Timeout for normal priority notifications")
+ currentValue: getTimeoutText(SettingsData.notificationTimeoutNormal)
+ options: timeoutOptions.map(opt => opt.text)
+ onValueChanged: value => {
+ for (let i = 0; i < timeoutOptions.length; i++) {
+ if (timeoutOptions[i].text === value) {
+ SettingsData.set("notificationTimeoutNormal", timeoutOptions[i].value);
+ break;
+ }
+ }
+ }
+ }
+
+ DankDropdown {
+ text: I18n.tr("Critical Priority")
+ description: I18n.tr("Timeout for critical priority notifications")
+ currentValue: getTimeoutText(SettingsData.notificationTimeoutCritical)
+ options: timeoutOptions.map(opt => opt.text)
+ onValueChanged: value => {
+ for (let i = 0; i < timeoutOptions.length; i++) {
+ if (timeoutOptions[i].text === value) {
+ SettingsData.set("notificationTimeoutCritical", timeoutOptions[i].value);
+ break;
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ }
+
+ Item {
+ width: parent.width
+ height: Math.max(overlayRow.implicitHeight, overlayToggle.implicitHeight) + Theme.spacingS
+
+ Row {
+ id: overlayRow
+ anchors.left: parent.left
+ anchors.right: overlayToggle.left
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "notifications_active"
+ size: Theme.iconSizeSmall
+ color: SettingsData.notificationOverlayEnabled ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ spacing: 2
+ anchors.verticalCenter: parent.verticalCenter
+ width: overlayRow.width - Theme.iconSizeSmall - Theme.spacingM
+
+ StyledText {
+ width: parent.width
+ text: I18n.tr("Notification Overlay")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ wrapMode: Text.Wrap
+ }
+
+ StyledText {
+ width: parent.width
+ text: I18n.tr("Display all priorities over fullscreen apps")
+ font.pixelSize: Theme.fontSizeSmall - 1
+ color: Theme.surfaceVariantText
+ wrapMode: Text.Wrap
+ }
+ }
+ }
+
+ DankToggle {
+ id: overlayToggle
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.notificationOverlayEnabled
+ onToggled: toggled => SettingsData.set("notificationOverlayEnabled", toggled)
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: Math.max(privacyRow.implicitHeight, privacyToggle.implicitHeight) + Theme.spacingS
+
+ Row {
+ id: privacyRow
+ anchors.left: parent.left
+ anchors.right: privacyToggle.left
+ anchors.rightMargin: Theme.spacingM
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "privacy_tip"
+ size: Theme.iconSizeSmall
+ color: SettingsData.notificationPopupPrivacyMode ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Column {
+ spacing: 2
+ anchors.verticalCenter: parent.verticalCenter
+ width: privacyRow.width - Theme.iconSizeSmall - Theme.spacingM
+
+ StyledText {
+ width: parent.width
+ text: I18n.tr("Privacy Mode")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ wrapMode: Text.Wrap
+ }
+
+ StyledText {
+ width: parent.width
+ text: I18n.tr("Hide notification content until expanded")
+ font.pixelSize: Theme.fontSizeSmall - 1
+ color: Theme.surfaceVariantText
+ wrapMode: Text.Wrap
+ }
+ }
+ }
+
+ DankToggle {
+ id: privacyToggle
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.notificationPopupPrivacyMode
+ onToggled: toggled => SettingsData.set("notificationPopupPrivacyMode", toggled)
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ }
+
+ StyledText {
+ text: I18n.tr("History Settings")
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ color: Theme.surfaceVariantText
+ }
+
+ Item {
+ width: parent.width
+ height: Math.max(lowRow.implicitHeight, lowToggle.implicitHeight) + Theme.spacingS
+
+ Row {
+ id: lowRow
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "low_priority"
+ size: Theme.iconSizeSmall
+ color: SettingsData.notificationHistorySaveLow ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: I18n.tr("Low Priority")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ id: lowToggle
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.notificationHistorySaveLow
+ onToggled: toggled => SettingsData.set("notificationHistorySaveLow", toggled)
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: Math.max(normalRow.implicitHeight, normalToggle.implicitHeight) + Theme.spacingS
+
+ Row {
+ id: normalRow
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "notifications"
+ size: Theme.iconSizeSmall
+ color: SettingsData.notificationHistorySaveNormal ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: I18n.tr("Normal Priority")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ id: normalToggle
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.notificationHistorySaveNormal
+ onToggled: toggled => SettingsData.set("notificationHistorySaveNormal", toggled)
+ }
+ }
+
+ Item {
+ width: parent.width
+ height: Math.max(criticalRow.implicitHeight, criticalToggle.implicitHeight) + Theme.spacingS
+
+ Row {
+ id: criticalRow
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingM
+
+ DankIcon {
+ name: "priority_high"
+ size: Theme.iconSizeSmall
+ color: SettingsData.notificationHistorySaveCritical ? Theme.primary : Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: I18n.tr("Critical Priority")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ DankToggle {
+ id: criticalToggle
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ checked: SettingsData.notificationHistorySaveCritical
+ onToggled: toggled => SettingsData.set("notificationHistorySaveCritical", toggled)
+ }
+ }
+ }
+ }
+}