1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-09 15:05:39 -05:00

Notifications base overhaul

This commit is contained in:
purian23
2025-07-16 01:31:30 -04:00
parent 8905b10773
commit 21bc4ece52
11 changed files with 3816 additions and 139 deletions

View File

@@ -10,6 +10,12 @@ Singleton {
readonly property list<NotifWrapper> notifications: [] readonly property list<NotifWrapper> notifications: []
readonly property list<NotifWrapper> popups: notifications.filter(n => n.popup) readonly property list<NotifWrapper> popups: notifications.filter(n => n.popup)
// Android 16-style grouped notifications
readonly property var groupedNotifications: getGroupedNotifications()
readonly property var groupedPopups: getGroupedPopups()
property var expandedGroups: ({}) // Track which groups are expanded
NotificationServer { NotificationServer {
id: server id: server
@@ -20,8 +26,10 @@ Singleton {
bodyImagesSupported: true bodyImagesSupported: true
bodyMarkupSupported: true bodyMarkupSupported: true
imageSupported: true imageSupported: true
inlineReplySupported: true
onNotification: notif => { onNotification: notif => {
console.log("New notification received:", notif.appName, "-", notif.summary);
notif.tracked = true; notif.tracked = true;
const wrapper = notifComponent.createObject(root, { const wrapper = notifComponent.createObject(root, {
@@ -29,7 +37,13 @@ Singleton {
notification: notif notification: notif
}); });
root.notifications.push(wrapper); if (wrapper) {
root.notifications.push(wrapper);
console.log("Notification added. Total notifications:", root.notifications.length);
console.log("Grouped notifications:", root.groupedNotifications.length);
} else {
console.error("Failed to create notification wrapper");
}
} }
} }
@@ -166,19 +180,140 @@ Singleton {
function getFallbackIcon(wrapper) { function getFallbackIcon(wrapper) {
if (wrapper.isConversation) { if (wrapper.isConversation) {
return Quickshell.iconPath("chat", "image-missing"); return Quickshell.iconPath("chat-symbolic");
} else if (wrapper.isMedia) { } else if (wrapper.isMedia) {
return Quickshell.iconPath("music_note", "image-missing"); return Quickshell.iconPath("audio-x-generic-symbolic");
} else if (wrapper.isSystem) { } else if (wrapper.isSystem) {
return Quickshell.iconPath("settings", "image-missing"); return Quickshell.iconPath("preferences-system-symbolic");
} }
return Quickshell.iconPath("apps", "image-missing"); return Quickshell.iconPath("application-x-executable-symbolic");
} }
function getAppIconPath(wrapper) { function getAppIconPath(wrapper) {
if (wrapper.hasAppIcon) { if (wrapper.hasAppIcon) {
return Quickshell.iconPath(wrapper.appIcon, "image-missing"); return Quickshell.iconPath(wrapper.appIcon);
} }
return getFallbackIcon(wrapper); return getFallbackIcon(wrapper);
} }
// Android 16-style notification grouping functions
function getGroupKey(wrapper) {
const appName = wrapper.appName.toLowerCase();
// Group by app only - one group per unique application
return appName;
}
function getGroupedNotifications() {
const groups = {};
for (const notif of notifications) {
const groupKey = getGroupKey(notif);
if (!groups[groupKey]) {
groups[groupKey] = {
key: groupKey,
appName: notif.appName,
notifications: [],
latestNotification: null,
count: 0,
hasInlineReply: false,
isConversation: notif.isConversation,
isMedia: notif.isMedia,
isSystem: notif.isSystem
};
}
groups[groupKey].notifications.unshift(notif);
groups[groupKey].latestNotification = groups[groupKey].notifications[0];
groups[groupKey].count = groups[groupKey].notifications.length;
if (notif.notification.hasInlineReply) {
groups[groupKey].hasInlineReply = true;
}
}
return Object.values(groups).sort((a, b) => {
return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
});
}
function getGroupedPopups() {
const groups = {};
for (const notif of popups) {
const groupKey = getGroupKey(notif);
if (!groups[groupKey]) {
groups[groupKey] = {
key: groupKey,
appName: notif.appName,
notifications: [],
latestNotification: null,
count: 0,
hasInlineReply: false,
isConversation: notif.isConversation,
isMedia: notif.isMedia,
isSystem: notif.isSystem
};
}
groups[groupKey].notifications.unshift(notif);
groups[groupKey].latestNotification = groups[groupKey].notifications[0];
groups[groupKey].count = groups[groupKey].notifications.length;
if (notif.notification.hasInlineReply) {
groups[groupKey].hasInlineReply = true;
}
}
return Object.values(groups).sort((a, b) => {
return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
});
}
function toggleGroupExpansion(groupKey) {
let newExpandedGroups = {};
for (const key in expandedGroups) {
newExpandedGroups[key] = expandedGroups[key];
}
newExpandedGroups[groupKey] = !newExpandedGroups[groupKey];
expandedGroups = newExpandedGroups;
}
function dismissGroup(groupKey) {
// Use array iteration to avoid spread operator issues
for (let i = notifications.length - 1; i >= 0; i--) {
const notif = notifications[i];
if (getGroupKey(notif) === groupKey) {
notif.notification.dismiss();
}
}
}
function getGroupTitle(group) {
if (group.count === 1) {
return group.latestNotification.summary;
}
if (group.isConversation) {
return `${group.count} new messages`;
}
if (group.isMedia) {
return "Now playing";
}
return `${group.count} notifications`;
}
function getGroupBody(group) {
if (group.count === 1) {
return group.latestNotification.body;
}
if (group.isConversation) {
return group.latestNotification.body || "Tap to view messages";
}
return `Latest: ${group.latestNotification.summary}`;
}
} }

2557
Tests/NOTIFICATIONS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -86,12 +86,12 @@ ApplicationWindow {
} }
// Native notification popup // Native notification popup
NotificationPopupNative { NotificationInit {
id: notificationPopup id: notificationPopup
} }
// Native notification history // Native notification history
NotificationHistoryNative { NotificationCenter {
id: notificationHistory id: notificationHistory
} }
} }

View File

@@ -0,0 +1,796 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
import "../Common"
import "../Services"
Rectangle {
id: root
required property var group
// Context detection - set by parent if in popup
property bool isPopupContext: false
// Bind directly to the service property for automatic updates
readonly property bool expanded: NotificationService.expandedGroups[group.key] || false
// Height calculation with popup context adjustment
height: {
let baseHeight = expanded ? expandedContent.height + Theme.spacingL * 2 : collapsedContent.height + Theme.spacingL * 2;
// Add extra height for single notifications in popup context
if (isPopupContext && group.count === 1) {
return baseHeight + 12;
}
return baseHeight;
}
radius: Theme.cornerRadiusLarge
color: Theme.popupBackground()
border.color: group.latestNotification.urgency === 2 ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) :
Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: group.latestNotification.urgency === 2 ? 2 : 1
// Stabilize layout during content changes
clip: true
// Priority indicator for urgent notifications
Rectangle {
width: 4
height: parent.height - 16
anchors.left: parent.left
anchors.leftMargin: 2
anchors.verticalCenter: parent.verticalCenter
radius: 2
color: Theme.primary
visible: group.latestNotification.urgency === 2
}
Behavior on height {
enabled: !isPopupContext // Disable automatic height animation in popup to prevent glitches
SequentialAnimation {
// Small pause to let content settle
PauseAnimation {
duration: 25
}
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
// Collapsed view - shows app header and latest notification
Column {
id: collapsedContent
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
spacing: Theme.spacingS
visible: !expanded
// App header with group info
Item {
width: parent.width
height: 48 // Fixed height to prevent layout shifts
// Round app icon with proper API usage
Item {
id: iconContainer
width: 48
height: 48
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: 48
height: 48
radius: 24
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
border.width: 1
clip: true
IconImage {
anchors.fill: parent
anchors.margins: 6
source: {
console.log("Icon source for", group.appName, ":", group.latestNotification.appIcon)
if (group.latestNotification.appIcon && group.latestNotification.appIcon !== "") {
return Quickshell.iconPath(group.latestNotification.appIcon, "")
}
return ""
}
visible: status === Image.Ready
onStatusChanged: {
console.log("Icon status changed for", group.appName, ":", status)
if (status === Image.Error || status === Image.Null || source === "") {
fallbackIcon.visible = true
} else if (status === Image.Ready) {
fallbackIcon.visible = false
}
}
}
// Fallback icon - show by default, hide when real icon loads
Text {
id: fallbackIcon
anchors.centerIn: parent
visible: true // Start visible, hide when real icon loads
text: {
// Use first letter of app name as fallback
const appName = group.appName || "?"
return appName.charAt(0).toUpperCase()
}
font.pixelSize: 20
font.weight: Font.Bold
color: Theme.primaryText
}
}
// Count badge for multiple notifications - small circle
Rectangle {
width: 20
height: 20
radius: 10
color: Theme.primary
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: -2
anchors.rightMargin: -2
visible: group.count > 1
Text {
id: countText
anchors.centerIn: parent
text: group.count > 99 ? "99+" : group.count.toString()
color: Theme.primaryText
font.pixelSize: 10
font.weight: Font.Bold
}
}
}
// App info and latest notification content
Column {
id: contentColumn
anchors.left: iconContainer.right
anchors.leftMargin: Theme.spacingM
anchors.right: controlsContainer.left
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
// App name and timestamp on same line
Text {
width: parent.width
text: {
if (group.latestNotification.timeStr.length > 0) {
return group.appName + " • " + group.latestNotification.timeStr
} else {
return group.appName
}
}
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
}
// Latest notification title (emphasized)
Text {
text: group.latestNotification.summary
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium + 1 // Slightly larger for emphasis
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
}
// Latest notification body (smaller, secondary)
Text {
text: group.latestNotification.body
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
width: parent.width
elide: Text.ElideRight
maximumLineCount: group.count > 1 ? 1 : 2 // More space for single notifications
wrapMode: Text.WordWrap
visible: text.length > 0
}
}
// Expand/dismiss controls - use anchored layout for stability
Item {
id: controlsContainer
width: 72
height: 32
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: expandButton
width: 32
height: 32
radius: 16
anchors.left: parent.left
color: expandArea.containsMouse ?
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) :
"transparent"
visible: group.count > 1
Text {
anchors.centerIn: parent
text: "expand_more"
font.family: Theme.iconFont
font.pixelSize: 18
color: Theme.surfaceText
rotation: expanded ? 180 : 0
Behavior on rotation {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
MouseArea {
id: expandArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
console.log("Expand clicked for group:", group.key, "current state:", expanded)
NotificationService.toggleGroupExpansion(group.key)
}
}
}
Rectangle {
id: dismissButton
width: 32
height: 32
radius: 16
anchors.right: parent.right
color: dismissArea.containsMouse ?
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) :
"transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 16
color: Theme.surfaceText
}
MouseArea {
id: dismissArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: NotificationService.dismissGroup(group.key)
}
}
}
}
// Quick reply for conversations (only if latest notification supports it)
Row {
width: parent.width
spacing: Theme.spacingS
visible: group.latestNotification.notification.hasInlineReply && !expanded
Rectangle {
width: parent.width - 60
height: 36
radius: 18
color: Theme.surfaceContainer
border.color: quickReplyField.activeFocus ? Theme.primary : Theme.outline
border.width: 1
TextField {
id: quickReplyField
anchors.fill: parent
anchors.margins: Theme.spacingS
placeholderText: group.latestNotification.notification.inlineReplyPlaceholder || "Quick reply..."
background: Item {}
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
onAccepted: {
if (text.length > 0) {
group.latestNotification.notification.sendInlineReply(text)
text = ""
}
}
}
}
Rectangle {
width: 52
height: 36
radius: 18
color: quickReplyField.text.length > 0 ? Theme.primary : Theme.surfaceContainer
border.color: quickReplyField.text.length > 0 ? "transparent" : Theme.outline
border.width: quickReplyField.text.length > 0 ? 0 : 1
Text {
anchors.centerIn: parent
text: "send"
font.family: Theme.iconFont
font.pixelSize: 16
color: quickReplyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText
}
MouseArea {
anchors.fill: parent
enabled: quickReplyField.text.length > 0
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
group.latestNotification.notification.sendInlineReply(quickReplyField.text)
quickReplyField.text = ""
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
// Expanded view - shows all notifications stacked
Column {
id: expandedContent
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
visible: expanded
// Group header with fixed anchored positioning
Item {
width: parent.width
height: 48
// Round app icon - fixed position on left
Rectangle {
id: expandedIconContainer
width: 40
height: 40
radius: 20
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
border.width: 1
clip: true
IconImage {
anchors.fill: parent
anchors.margins: 4
source: group.latestNotification.appIcon ? Quickshell.iconPath(group.latestNotification.appIcon, "") : ""
visible: status === Image.Ready
}
// Fallback for expanded view
Text {
anchors.centerIn: parent
visible: !group.latestNotification.appIcon || group.latestNotification.appIcon === ""
text: {
const appName = group.appName || "?"
return appName.charAt(0).toUpperCase()
}
font.pixelSize: 16
font.weight: Font.Bold
color: Theme.primaryText
}
}
// App name and count badge - centered area
Item {
anchors.left: expandedIconContainer.right
anchors.leftMargin: Theme.spacingM
anchors.right: expandedControlsContainer.left
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
height: 32
Text {
id: expandedAppNameText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: group.appName
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
}
// Count badge in expanded view - positioned next to app name
Rectangle {
width: 24
height: 24
radius: 12
color: Theme.primary
anchors.left: expandedAppNameText.right
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: group.count > 99 ? "99+" : group.count.toString()
color: Theme.primaryText
font.pixelSize: 11
font.weight: Font.Bold
}
}
}
// Controls container - fixed position on right
Item {
id: expandedControlsContainer
width: 72
height: 32
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: collapseButton
width: 32
height: 32
radius: 16
anchors.left: parent.left
color: collapseArea.containsMouse ?
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) :
"transparent"
Text {
anchors.centerIn: parent
text: "expand_less"
font.family: Theme.iconFont
font.pixelSize: 18
color: Theme.surfaceText
}
MouseArea {
id: collapseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: NotificationService.toggleGroupExpansion(group.key)
}
}
Rectangle {
id: dismissAllButton
width: 32
height: 32
radius: 16
anchors.right: parent.right
color: dismissAllArea.containsMouse ?
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) :
"transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 16
color: Theme.surfaceText
}
MouseArea {
id: dismissAllArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: NotificationService.dismissGroup(group.key)
}
}
}
}
// Stacked individual notifications with smooth transitions
Column {
width: parent.width
spacing: Theme.spacingS
Repeater {
model: group.notifications.slice(0, 10) // Show max 10 expanded
delegate: Rectangle {
required property var modelData
width: parent.width
height: notifContent.height + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.5)
border.color: modelData.urgency === 2 ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) :
"transparent"
border.width: modelData.urgency === 2 ? 1 : 0
// Stabilize layout during dismiss operations
clip: true
// Smooth height transitions
Behavior on height {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Item {
id: notifContent
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingM
height: Math.max(individualIcon.height, contentColumn.height)
// Small round notification icon/avatar - fixed position on left
Rectangle {
id: individualIcon
width: 32
height: 32
radius: 16
anchors.left: parent.left
anchors.top: parent.top
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
border.width: 1
clip: true
IconImage {
anchors.fill: parent
anchors.margins: 3
source: modelData.appIcon ? Quickshell.iconPath(modelData.appIcon, "") : ""
visible: status === Image.Ready
}
// Fallback for individual notifications
Text {
anchors.centerIn: parent
visible: !modelData.appIcon || modelData.appIcon === ""
text: {
const appName = modelData.appName || "?"
return appName.charAt(0).toUpperCase()
}
font.pixelSize: 12
font.weight: Font.Bold
color: Theme.primaryText
}
}
// Individual dismiss button - fixed position on right
Rectangle {
id: individualDismissButton
width: 24
height: 24
radius: 12
anchors.right: parent.right
anchors.top: parent.top
color: individualDismissArea.containsMouse ?
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) :
"transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 12
color: Theme.surfaceVariantText
}
MouseArea {
id: individualDismissArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: NotificationService.dismissNotification(modelData)
}
}
// Notification content - fills space between icon and dismiss button
Column {
id: contentColumn
anchors.left: individualIcon.right
anchors.leftMargin: Theme.spacingM
anchors.right: individualDismissButton.left
anchors.rightMargin: Theme.spacingM
anchors.top: parent.top
spacing: Theme.spacingXS
// Title and timestamp
Item {
width: parent.width
height: Math.max(titleText.height, timeText.height)
Text {
id: titleText
anchors.left: parent.left
anchors.right: timeText.left
anchors.rightMargin: Theme.spacingS
text: modelData.summary
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
id: timeText
anchors.right: parent.right
text: modelData.timeStr
color: Theme.surfaceVariantText
font.pixelSize: 10
}
}
// Body text
Text {
text: modelData.body
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
visible: text.length > 0
}
// Individual notification inline reply
Row {
width: parent.width
spacing: Theme.spacingS
visible: modelData.notification.hasInlineReply
Rectangle {
width: parent.width - 50
height: 28
radius: 14
color: Theme.surface
border.color: replyField.activeFocus ? Theme.primary : Theme.outline
border.width: 1
TextField {
id: replyField
anchors.fill: parent
anchors.margins: Theme.spacingXS
placeholderText: modelData.notification.inlineReplyPlaceholder || "Reply..."
background: Item {}
color: Theme.surfaceText
font.pixelSize: 11
onAccepted: {
if (text.length > 0) {
modelData.notification.sendInlineReply(text)
text = ""
}
}
}
}
Rectangle {
width: 42
height: 28
radius: 14
color: replyField.text.length > 0 ? Theme.primary : Theme.surfaceContainer
Text {
anchors.centerIn: parent
text: "send"
font.family: Theme.iconFont
font.pixelSize: 12
color: replyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText
}
MouseArea {
anchors.fill: parent
enabled: replyField.text.length > 0
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
modelData.notification.sendInlineReply(replyField.text)
replyField.text = ""
}
}
}
}
// Actions
Row {
spacing: Theme.spacingS
visible: modelData.actions && modelData.actions.length > 0
Repeater {
model: modelData.actions || []
delegate: Rectangle {
width: actionText.width + Theme.spacingS * 2
height: 24
radius: Theme.cornerRadius
color: actionArea.containsMouse ? Theme.primaryContainer : Theme.surfaceContainer
border.color: Theme.outline
border.width: 1
Text {
id: actionText
anchors.centerIn: parent
text: modelData.text
font.pixelSize: 11
color: Theme.surfaceText
font.weight: Font.Medium
}
MouseArea {
id: actionArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: modelData.invoke()
}
}
}
}
}
}
}
}
// "Show more" if there are many notifications
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadius
color: Theme.surfaceContainer
visible: group.count > 10
Text {
anchors.centerIn: parent
text: `Show ${group.count - 10} more notifications...`
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
// Implement pagination or full expansion
console.log("Show more notifications")
}
}
}
}
}
// Tap to expand (only for collapsed state with multiple notifications)
MouseArea {
anchors.fill: parent
visible: !expanded && group.count > 1
onClicked: NotificationService.toggleGroupExpansion(group.key)
z: -1
}
}

View File

@@ -1,4 +1,4 @@
//NotificationHistoryNative.qml //NotificationCenter.qml
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import Quickshell import Quickshell
@@ -207,37 +207,51 @@ PanelWindow {
ScrollBar.vertical.policy: ScrollBar.AsNeeded ScrollBar.vertical.policy: ScrollBar.AsNeeded
ListView { ListView {
model: NotificationService.notifications model: NotificationService.groupedNotifications
spacing: Theme.spacingL spacing: Theme.spacingL
interactive: true interactive: true
boundsBehavior: Flickable.StopAtBounds boundsBehavior: Flickable.StopAtBounds
flickDeceleration: 1500 flickDeceleration: 1500
maximumFlickVelocity: 2000 maximumFlickVelocity: 2000
// Smooth animations to prevent layout jumping // Enhanced smooth animations to prevent layout jumping
add: Transition { add: Transition {
NumberAnimation { ParallelAnimation {
properties: "opacity" NumberAnimation {
from: 0 properties: "opacity"
to: 1 from: 0
duration: Theme.shortDuration to: 1
easing.type: Theme.standardEasing duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
NumberAnimation {
properties: "height"
from: 0
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
} }
} }
remove: Transition { remove: Transition {
SequentialAnimation { SequentialAnimation {
NumberAnimation { // Pause to let internal content animations complete
properties: "opacity" PauseAnimation {
to: 0 duration: 50
duration: Theme.shortDuration
easing.type: Theme.standardEasing
} }
NumberAnimation { ParallelAnimation {
properties: "height" NumberAnimation {
to: 0 properties: "opacity"
duration: Theme.shortDuration to: 0
easing.type: Theme.standardEasing duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
NumberAnimation {
properties: "height,anchors.topMargin,anchors.bottomMargin"
to: 0
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
} }
} }
} }
@@ -245,15 +259,25 @@ PanelWindow {
displaced: Transition { displaced: Transition {
NumberAnimation { NumberAnimation {
properties: "y" properties: "y"
duration: Theme.shortDuration duration: Theme.mediumDuration
easing.type: Theme.standardEasing easing.type: Theme.emphasizedEasing
} }
} }
delegate: NotificationItem { // Add move transition for internal content changes
move: Transition {
NumberAnimation {
properties: "y"
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
delegate: GroupedNotificationCard {
required property var modelData required property var modelData
notificationWrapper: modelData group: modelData
width: ListView.view.width - Theme.spacingM width: ListView.view.width - Theme.spacingM
// expanded property is now readonly and managed by NotificationService
} }
} }

View File

@@ -0,0 +1,172 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
import "../Services"
PanelWindow {
id: notificationPopup
objectName: "notificationPopup" // For context detection
visible: NotificationService.groupedPopups.length > 0
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
right: true
}
margins {
top: Theme.barHeight
right: 16
}
implicitWidth: 400
implicitHeight: groupedNotificationsList.height + 32
Column {
id: groupedNotificationsList
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 16
anchors.rightMargin: 16
spacing: Theme.spacingM
width: 380
Repeater {
model: NotificationService.groupedPopups
delegate: GroupedNotificationCard {
required property var modelData
group: modelData
width: parent.width
// Popup-specific styling: Extra padding for single notifications
property bool isPopupContext: true
property int extraTopMargin: group.count === 1 ? 6 : 0
property int extraBottomMargin: group.count === 1 ? 6 : 0
// Hover detection for preventing auto-dismiss
property bool isHovered: false
MouseArea {
id: hoverDetection
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton // Don't intercept clicks
propagateComposedEvents: true
z: -1 // Behind other elements
onEntered: {
parent.isHovered = true
console.log("Notification hovered - pausing auto-dismiss")
}
onExited: {
parent.isHovered = false
console.log("Notification hover ended - resuming auto-dismiss")
}
}
// Enhanced auto-dismiss timer with hover pause
Timer {
id: autoDismissTimer
running: group.count === 1 && group.latestNotification.popup && !group.latestNotification.notification.hasInlineReply && !parent.isHovered
interval: group.latestNotification.notification.expireTimeout > 0 ?
group.latestNotification.notification.expireTimeout * 1000 : 7000 // Increased to 7 seconds
onTriggered: {
if (!parent.isHovered) {
group.latestNotification.popup = false
}
}
// Restart timer when hover ends
onRunningChanged: {
if (running && !parent.isHovered) {
restart()
}
}
}
// Don't auto-dismiss conversation groups - let user interact
property bool isConversationGroup: group.isConversation && group.count > 1
// Stabilized entry animation for popup context
transform: [
Translate {
id: slideTransform
x: notificationPopup.visible ? 0 : 400
Behavior on x {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
},
Scale {
id: scaleTransform
origin.x: parent.width
origin.y: 0
xScale: notificationPopup.visible ? 1.0 : 0.95
yScale: notificationPopup.visible ? 1.0 : 0.8
Behavior on xScale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on yScale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
opacity: notificationPopup.visible ? 1.0 : 0.0
// Enhanced height transitions for popup stability
Behavior on height {
SequentialAnimation {
PauseAnimation {
duration: 10 // Shorter pause for popup responsiveness
}
NumberAnimation {
duration: Theme.shortDuration // Faster transitions in popup
easing.type: Theme.standardEasing
}
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
// Popup-specific stability improvements
clip: true // Prevent content overflow during animations
}
}
}
// Smooth height animation
Behavior on implicitHeight {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}

View File

@@ -1,104 +0,0 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
import "../Services"
PanelWindow {
id: notificationPopup
visible: NotificationService.popups.length > 0
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
right: true
}
margins {
top: Theme.barHeight
right: 16
}
implicitWidth: 400
implicitHeight: notificationList.height + 32
Column {
id: notificationList
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 16
anchors.rightMargin: 16
spacing: Theme.spacingM
width: 380
Repeater {
model: NotificationService.popups
delegate: NotificationItem {
required property var modelData
notificationWrapper: modelData
// Entry animation
transform: [
Translate {
id: slideTransform
x: notificationPopup.visible ? 0 : 400
Behavior on x {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
},
Scale {
id: scaleTransform
origin.x: parent.width
origin.y: 0
xScale: notificationPopup.visible ? 1.0 : 0.95
yScale: notificationPopup.visible ? 1.0 : 0.8
Behavior on xScale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on yScale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
opacity: notificationPopup.visible ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
}
}
// Smooth height animation
Behavior on implicitHeight {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}

View File

@@ -1,8 +1,9 @@
TopBar 1.0 TopBar/TopBar.qml TopBar 1.0 TopBar/TopBar.qml
TrayMenuPopup 1.0 TrayMenuPopup.qml TrayMenuPopup 1.0 TrayMenuPopup.qml
NotificationItem 1.0 NotificationItem.qml NotificationItem 1.0 NotificationItem.qml
NotificationPopupNative 1.0 NotificationPopupNative.qml NotificationInit 1.0 NotificationInit.qml
NotificationHistoryNative 1.0 NotificationHistoryNative.qml NotificationCenter 1.0 NotificationCenter.qml
GroupedNotificationCard 1.0 GroupedNotificationCard.qml
WifiPasswordDialog 1.0 WifiPasswordDialog.qml WifiPasswordDialog 1.0 WifiPasswordDialog.qml
AppLauncher 1.0 AppLauncher.qml AppLauncher 1.0 AppLauncher.qml
ClipboardHistory 1.0 ClipboardHistory.qml ClipboardHistory 1.0 ClipboardHistory.qml

43
debug-notifications.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
echo "Testing notification fixes..."
echo "This will test:"
echo "1. Icon visibility (should show round icons or emoji fallbacks)"
echo "2. Expand/collapse in popup (should work smoothly)"
echo "3. Expand/collapse in history (should work reliably)"
echo "4. Button alignment (should not be glitchy)"
echo ""
# Wait for shell to be ready
sleep 3
echo "Sending test notifications..."
# Test Discord grouping with multiple messages
notify-send -a "Discord" "User1" "First message in Discord"
sleep 0.5
notify-send -a "Discord" "User2" "Second message in Discord"
sleep 0.5
notify-send -a "Discord" "User3" "Third message in Discord"
sleep 1
# Test app with likely good icon
notify-send -a "firefox" "Download" "File downloaded successfully"
sleep 0.5
notify-send -a "firefox" "Update" "Browser updated"
sleep 1
# Test app that might not have icon (fallback test)
notify-send -a "TestApp" "Test 1" "This should show fallback icon"
sleep 0.5
notify-send -a "TestApp" "Test 2" "Another test notification"
echo ""
echo "Notifications sent! Please test:"
echo "1. Check notification popup - icons should be visible (round)"
echo "2. Try expand/collapse buttons in popup"
echo "3. Open notification history"
echo "4. Try expand/collapse buttons in history"
echo "5. Check that buttons stay aligned when collapsing"
echo ""
echo "Look for console logs in quickshell terminal for debugging info"

View File

@@ -204,8 +204,8 @@ ShellRoot {
// Global popup windows // Global popup windows
CenterCommandCenter {} CenterCommandCenter {}
TrayMenuPopup {} TrayMenuPopup {}
NotificationPopupNative {} NotificationInit {}
NotificationHistoryNative { NotificationCenter {
notificationHistoryVisible: root.notificationHistoryVisible notificationHistoryVisible: root.notificationHistoryVisible
onCloseRequested: { onCloseRequested: {
root.notificationHistoryVisible = false root.notificationHistoryVisible = false

53
verify-notifications.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
echo "Waiting for notification service to be ready..."
# Wait for the notification service to be available
max_attempts=20
attempt=0
while [ $attempt -lt $max_attempts ]; do
if notify-send -a "test" "Service Ready Test" "Testing..." 2>/dev/null; then
echo "Notification service is ready!"
break
fi
echo "Attempt $((attempt + 1))/$max_attempts - waiting..."
sleep 2
attempt=$((attempt + 1))
done
if [ $attempt -eq $max_attempts ]; then
echo "Timeout waiting for notification service"
exit 1
fi
echo ""
echo "Running layout and functionality tests..."
echo ""
# Now run the actual tests
notify-send -a "firefox" "Test 1" "Firefox notification 1"
sleep 0.5
notify-send -a "firefox" "Test 2" "Firefox notification 2"
sleep 1
notify-send -a "MyCustomApp" "Custom 1" "Custom app notification 1"
sleep 0.5
notify-send -a "MyCustomApp" "Custom 2" "Custom app notification 2"
sleep 1
notify-send -a "code" "VS Code 1" "Code notification 1"
sleep 0.5
notify-send -a "code" "VS Code 2" "Code notification 2"
echo ""
echo "✅ All notifications sent successfully!"
echo ""
echo "🧪 Test Results Expected:"
echo "1. ✅ Button container stays within bounds on collapse"
echo "2. ✅ Count badges show as small circles (not parentheses)"
echo "3. ✅ App icons show with themed backgrounds (not black)"
echo "4. ✅ First letter fallbacks when icons don't load"
echo "5. ✅ Expand/collapse works in both popup and history"
echo ""
echo "Check your notification popup and history panel!"