1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 21:42:51 -05:00

Migrate notification system to native Quickshell NotificationServer API

- Replace custom NotificationGroupingService with native NotificationService
- Implement proper image/icon priority system (notification image → app icon → fallback)
- Add NotificationItem with image layering and elegant emoji fallbacks
- Create native popup and history components with smooth animations
- Fix Discord/Vesktop avatar display issues
- Clean up legacy notification components and demos
- Improve Material Design 3 theming consistency
This commit is contained in:
purian23
2025-07-15 16:41:34 -04:00
parent 0349b2c361
commit 4ea04f57b4
15 changed files with 1056 additions and 3020 deletions

View File

@@ -1,405 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import "../Common"
import "../Services"
// Compact notification group component for Android 16-style collapsed groups
Rectangle {
id: root
property var groupData
property bool isHovered: false
property bool showExpandButton: groupData ? groupData.totalCount > 1 : false
property int groupPriority: groupData ? (groupData.priority || NotificationGroupingService.priorityNormal) : NotificationGroupingService.priorityNormal
property int notificationType: groupData ? (groupData.notificationType || NotificationGroupingService.typeNormal) : NotificationGroupingService.typeNormal
signal expandRequested()
signal groupClicked()
signal groupDismissed()
width: parent.width
height: getCompactHeight()
radius: Theme.cornerRadius
color: getBackgroundColor()
// Enhanced elevation effect for high priority
layer.enabled: groupPriority === NotificationGroupingService.priorityHigh
layer.effect: MultiEffect {
shadowEnabled: true
shadowHorizontalOffset: 0
shadowVerticalOffset: 1
shadowBlur: 0.2
shadowColor: Qt.rgba(0, 0, 0, 0.08)
}
function getCompactHeight() {
if (notificationType === NotificationGroupingService.typeMedia) {
return 72 // Slightly taller for media controls
}
return groupPriority === NotificationGroupingService.priorityHigh ? 64 : 56
}
function getBackgroundColor() {
if (isHovered) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
}
if (groupPriority === NotificationGroupingService.priorityHigh) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.04)
}
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.06)
}
// Priority indicator strip
Rectangle {
width: 3
height: parent.height - 8
anchors.left: parent.left
anchors.leftMargin: 2
anchors.verticalCenter: parent.verticalCenter
radius: 1.5
color: getPriorityColor()
visible: groupPriority === NotificationGroupingService.priorityHigh
}
function getPriorityColor() {
if (notificationType === NotificationGroupingService.typeConversation) {
return Theme.primary
} else if (notificationType === NotificationGroupingService.typeMedia) {
return "#FF6B35" // Orange for media
}
return Theme.primary
}
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
anchors.leftMargin: groupPriority === NotificationGroupingService.priorityHigh ? Theme.spacingM + 6 : Theme.spacingM
spacing: Theme.spacingM
// App Icon
Rectangle {
width: getIconSize()
height: width
radius: width / 2
color: getIconBackgroundColor()
anchors.verticalCenter: parent.verticalCenter
// Subtle glow for high priority
layer.enabled: groupPriority === NotificationGroupingService.priorityHigh
layer.effect: MultiEffect {
shadowEnabled: true
shadowBlur: 0.4
shadowColor: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
}
function getIconSize() {
if (groupPriority === NotificationGroupingService.priorityHigh) {
return 40
}
return 32
}
function getIconBackgroundColor() {
if (notificationType === NotificationGroupingService.typeConversation) {
return Theme.primaryContainer
} else if (notificationType === NotificationGroupingService.typeMedia) {
return Qt.rgba(1, 0.42, 0.21, 0.2) // Orange tint for media
}
return Theme.primaryContainer
}
// App icon or fallback
Loader {
anchors.fill: parent
sourceComponent: groupData && groupData.appIcon ? iconComponent : fallbackComponent
}
Component {
id: iconComponent
IconImage {
width: parent.width * 0.7
height: width
anchors.centerIn: parent
asynchronous: true
source: {
if (!groupData || !groupData.appIcon) return ""
if (groupData.appIcon.startsWith("file://") || groupData.appIcon.startsWith("/")) {
return groupData.appIcon
}
return Quickshell.iconPath(groupData.appIcon, "image-missing")
}
}
}
Component {
id: fallbackComponent
Text {
anchors.centerIn: parent
text: getDefaultIcon()
font.family: Theme.iconFont
font.pixelSize: parent.width * 0.5
color: Theme.primaryText
function getDefaultIcon() {
if (notificationType === NotificationGroupingService.typeConversation) {
return "chat"
} else if (notificationType === NotificationGroupingService.typeMedia) {
return "music_note"
} else if (notificationType === NotificationGroupingService.typeSystem) {
return "settings"
}
return "apps"
}
}
}
}
// Content area
Column {
width: parent.width - parent.spacing - 40 - (showExpandButton ? 40 : 0) - (notificationType === NotificationGroupingService.typeMedia ? 100 : 0)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
// App name and count
Row {
width: parent.width
spacing: Theme.spacingS
Text {
text: groupData ? groupData.appName : "App"
font.pixelSize: groupPriority === NotificationGroupingService.priorityHigh ? Theme.fontSizeLarge : Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: groupPriority === NotificationGroupingService.priorityHigh ? Font.DemiBold : Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
// Count badge
Rectangle {
width: Math.max(countText.width + 6, 18)
height: 18
radius: 9
color: Theme.primary
visible: groupData && groupData.totalCount > 1
anchors.verticalCenter: parent.verticalCenter
Text {
id: countText
anchors.centerIn: parent
text: groupData ? groupData.totalCount.toString() : "0"
font.pixelSize: Theme.fontSizeSmall
color: Theme.primaryText
font.weight: Font.Medium
}
}
// Time indicator
Text {
text: getTimeText()
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
anchors.verticalCenter: parent.verticalCenter
function getTimeText() {
if (!groupData || !groupData.latestNotification) return ""
return NotificationGroupingService.formatTimestamp(groupData.latestNotification.timestamp)
}
}
}
// Summary text
Text {
text: getSummaryText()
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
width: parent.width
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
function getSummaryText() {
if (!groupData) return ""
if (groupData.totalCount === 1) {
const notif = groupData.latestNotification
return notif ? (notif.summary || notif.body || "") : ""
}
// Use smart summary for multiple notifications
return NotificationGroupingService.generateGroupSummary(groupData)
}
}
}
// Media controls (if applicable)
Loader {
active: notificationType === NotificationGroupingService.typeMedia
width: active ? 100 : 0
height: parent.height
anchors.verticalCenter: parent.verticalCenter
sourceComponent: Row {
spacing: Theme.spacingS
anchors.centerIn: parent
Rectangle {
width: 28
height: 28
radius: 14
color: Theme.primaryContainer
Text {
anchors.centerIn: parent
text: "skip_previous"
font.family: Theme.iconFont
font.pixelSize: 16
color: Theme.primaryText
}
MouseArea {
anchors.fill: parent
onClicked: {
// Handle previous track
console.log("Previous track clicked")
}
}
}
Rectangle {
width: 28
height: 28
radius: 14
color: Theme.primary
Text {
anchors.centerIn: parent
text: "pause" // Could be "play_arrow" based on state
font.family: Theme.iconFont
font.pixelSize: 16
color: Theme.primaryText
}
MouseArea {
anchors.fill: parent
onClicked: {
// Handle play/pause
console.log("Play/pause clicked")
}
}
}
Rectangle {
width: 28
height: 28
radius: 14
color: Theme.primaryContainer
Text {
anchors.centerIn: parent
text: "skip_next"
font.family: Theme.iconFont
font.pixelSize: 16
color: Theme.primaryText
}
MouseArea {
anchors.fill: parent
onClicked: {
// Handle next track
console.log("Next track clicked")
}
}
}
}
}
// Expand button
Rectangle {
width: showExpandButton ? 32 : 0
height: 32
radius: 16
color: expandArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
anchors.verticalCenter: parent.verticalCenter
visible: showExpandButton
Text {
anchors.centerIn: parent
text: "expand_more"
font.family: Theme.iconFont
font.pixelSize: 18
color: expandArea.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: expandArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
expandRequested()
}
}
Behavior on width {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
// Main interaction area
MouseArea {
anchors.fill: parent
anchors.rightMargin: showExpandButton ? 40 : 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
isHovered = true
}
onExited: {
isHovered = false
}
onClicked: {
if (showExpandButton) {
expandRequested()
} else {
groupClicked()
}
}
}
// Swipe gesture for dismissal
DragHandler {
target: null
acceptedDevices: PointerDevice.TouchScreen | PointerDevice.Mouse
property real startX: 0
property real threshold: 100
onActiveChanged: {
if (active) {
startX = centroid.position.x
} else {
const deltaX = centroid.position.x - startX
if (deltaX < -threshold) {
groupDismissed()
}
}
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}

View File

@@ -0,0 +1,309 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
import "../Services"
PanelWindow {
id: notificationHistoryPopup
property bool notificationHistoryVisible: false
signal closeRequested()
visible: notificationHistoryVisible
implicitWidth: 400
implicitHeight: 500
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
// Background to close popup when clicking outside
MouseArea {
anchors.fill: parent
onClicked: {
closeRequested()
}
}
Rectangle {
width: 400
height: 500
x: parent.width - width - Theme.spacingL
y: Theme.barHeight + Theme.spacingXS
color: Theme.popupBackground()
radius: Theme.cornerRadiusLarge
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 0.5
// Animation
transform: [
Scale {
id: scaleTransform
origin.x: parent.width
origin.y: 0
xScale: notificationHistoryVisible ? 1.0 : 0.95
yScale: notificationHistoryVisible ? 1.0 : 0.8
},
Translate {
id: translateTransform
x: notificationHistoryVisible ? 0 : 15
y: notificationHistoryVisible ? 0 : -30
}
]
opacity: notificationHistoryVisible ? 1.0 : 0.0
states: [
State {
name: "visible"
when: notificationHistoryVisible
PropertyChanges { target: scaleTransform; xScale: 1.0; yScale: 1.0 }
PropertyChanges { target: translateTransform; x: 0; y: 0 }
},
State {
name: "hidden"
when: !notificationHistoryVisible
PropertyChanges { target: scaleTransform; xScale: 0.95; yScale: 0.8 }
PropertyChanges { target: translateTransform; x: 15; y: -30 }
}
]
transitions: [
Transition {
from: "*"; to: "*"
ParallelAnimation {
NumberAnimation {
targets: [scaleTransform, translateTransform]
properties: "xScale,yScale,x,y"
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
// Prevent clicks from propagating to background
MouseArea {
anchors.fill: parent
onClicked: {
// Stop propagation - do nothing
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
// Header
Row {
width: parent.width
height: 32
Text {
text: "Notifications"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
Item {
width: parent.width - 240 - Theme.spacingM
height: 1
}
// Clear All Button
Rectangle {
width: 120
height: 28
radius: Theme.cornerRadius
anchors.verticalCenter: parent.verticalCenter
visible: NotificationService.notifications.length > 0
color: clearArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
Theme.surfaceContainer
border.color: clearArea.containsMouse ?
Theme.primary :
Theme.outline
border.width: 1
Row {
anchors.centerIn: parent
spacing: Theme.spacingXS
Text {
text: "delete_sweep"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSizeSmall
color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "Clear All"
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: NotificationService.clearAllNotifications()
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on border.color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
// Notification List
ScrollView {
width: parent.width
height: parent.height - 120
clip: true
contentWidth: -1
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ListView {
model: NotificationService.notifications
spacing: Theme.spacingL
interactive: true
boundsBehavior: Flickable.StopAtBounds
flickDeceleration: 1500
maximumFlickVelocity: 2000
// Smooth animations to prevent layout jumping
add: Transition {
NumberAnimation {
properties: "opacity"
from: 0
to: 1
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
remove: Transition {
SequentialAnimation {
NumberAnimation {
properties: "opacity"
to: 0
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
properties: "height"
to: 0
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
displaced: Transition {
NumberAnimation {
properties: "y"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
delegate: NotificationItem {
required property var modelData
notificationWrapper: modelData
width: ListView.view.width - Theme.spacingM
}
}
// Empty state
Item {
width: parent.width
height: 200
anchors.centerIn: parent
visible: NotificationService.notifications.length === 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingM
width: parent.width * 0.8
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "notifications_none"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSizeLarge + 16
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "No notifications"
font.pixelSize: Theme.fontSizeLarge
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Notifications will appear here"
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
width: parent.width
}
}
}
}
}
}
// Click outside to close
MouseArea {
anchors.fill: parent
z: -1
onClicked: notificationHistoryVisible = false
}
}

View File

@@ -1,949 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
import "../Services"
PanelWindow {
id: notificationHistoryPopup
visible: root.notificationHistoryVisible
implicitWidth: 400
implicitHeight: 500
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
left: true
right: true
bottom: true
}
// Timer to update timestamps periodically
Timer {
id: timestampUpdateTimer
interval: 60000 // Update every minute
running: visible
repeat: true
onTriggered: {
// Force model refresh to update timestamps
groupedNotificationListView.model = NotificationGroupingService.groupedNotifications
}
}
Rectangle {
width: 400
height: 500
x: parent.width - width - Theme.spacingL
y: Theme.barHeight + Theme.spacingXS
color: Theme.popupBackground()
radius: Theme.cornerRadiusLarge
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 0.5
// TopBar dropdown animation - slide down from bar (consistent with other TopBar widgets)
transform: [
Scale {
id: scaleTransform
origin.x: parent.width // Scale from top-right corner
origin.y: 0
xScale: root.notificationHistoryVisible ? 1.0 : 0.95
yScale: root.notificationHistoryVisible ? 1.0 : 0.8
},
Translate {
id: translateTransform
x: root.notificationHistoryVisible ? 0 : 15 // Slide slightly left when hidden
y: root.notificationHistoryVisible ? 0 : -30
}
]
opacity: root.notificationHistoryVisible ? 1.0 : 0.0
// Single coordinated animation for better performance
states: [
State {
name: "visible"
when: root.notificationHistoryVisible
PropertyChanges { target: scaleTransform; xScale: 1.0; yScale: 1.0 }
PropertyChanges { target: translateTransform; x: 0; y: 0 }
},
State {
name: "hidden"
when: !root.notificationHistoryVisible
PropertyChanges { target: scaleTransform; xScale: 0.95; yScale: 0.8 }
PropertyChanges { target: translateTransform; x: 15; y: -30 }
}
]
transitions: [
Transition {
from: "*"; to: "*"
ParallelAnimation {
NumberAnimation {
targets: [scaleTransform, translateTransform]
properties: "xScale,yScale,x,y"
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
// Header
Column {
width: parent.width
spacing: Theme.spacingM
Row {
width: parent.width
height: 32
Text {
id: notificationsTitle
text: "Notifications"
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
Item {
width: parent.width - notificationsTitle.width - clearButton.width - Theme.spacingM
height: 1
}
// Compact Clear All Button
Rectangle {
id: clearButton
width: 120
height: 28
radius: Theme.cornerRadius
anchors.verticalCenter: parent.verticalCenter
visible: NotificationGroupingService.totalCount > 0
color: clearArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
Theme.surfaceContainer
border.color: clearArea.containsMouse ?
Theme.primary :
Theme.outline
border.width: 1
Row {
anchors.centerIn: parent
spacing: Theme.spacingXS
Text {
text: "delete_sweep"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSizeSmall
color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "Clear All"
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: {
NotificationGroupingService.clearAllNotifications()
notificationHistory.clear()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on border.color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
// Grouped Notification List
ScrollView {
width: parent.width
height: parent.height - 120
clip: true
contentWidth: -1 // Fit to width
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ListView {
id: groupedNotificationListView
model: NotificationGroupingService.groupedNotifications
spacing: Theme.spacingM
interactive: true
boundsBehavior: Flickable.StopAtBounds
flickDeceleration: 1500
maximumFlickVelocity: 2000
delegate: Column {
width: groupedNotificationListView.width
spacing: Theme.spacingXS
property var groupData: model
property bool isExpanded: model.expanded || false
property int groupPriority: model.priority || NotificationGroupingService.priorityNormal
property int notificationType: model.notificationType || NotificationGroupingService.typeNormal
// Group Header with enhanced visual hierarchy
Rectangle {
width: parent.width
height: getGroupHeaderHeight()
radius: Theme.cornerRadius
color: getGroupHeaderColor()
// Enhanced elevation effect based on priority
layer.enabled: groupPriority === NotificationGroupingService.priorityHigh
layer.effect: MultiEffect {
shadowEnabled: true
shadowHorizontalOffset: 0
shadowVerticalOffset: 2
shadowBlur: 0.4
shadowColor: Qt.rgba(0, 0, 0, 0.1)
}
// Priority indicator strip
Rectangle {
width: 4
height: parent.height
anchors.left: parent.left
radius: 2
color: getPriorityColor()
visible: groupPriority === NotificationGroupingService.priorityHigh
}
function getGroupHeaderHeight() {
// Dynamic height based on content length and priority
// Calculate height based on message content length
const bodyText = (model.latestNotification && model.latestNotification.body) ? model.latestNotification.body : ""
const bodyLines = Math.min(Math.ceil((bodyText.length / 50)), 4) // Estimate lines needed
const bodyHeight = bodyLines * 16 // 16px per line
const indicatorHeight = model.totalCount > 1 ? 16 : 0
const paddingTop = Theme.spacingM
const paddingBottom = Theme.spacingS
let calculatedHeight = paddingTop + 20 + bodyHeight + indicatorHeight + paddingBottom
// Minimum height based on priority
const minHeight = groupPriority === NotificationGroupingService.priorityHigh ? 90 : 80
return Math.max(calculatedHeight, minHeight)
}
function getGroupHeaderColor() {
if (groupHeaderArea.containsMouse) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
}
// Different background colors based on priority
if (groupPriority === NotificationGroupingService.priorityHigh) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.05)
}
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)
}
function getPriorityColor() {
if (notificationType === NotificationGroupingService.typeConversation) {
return Theme.primary
} else if (notificationType === NotificationGroupingService.typeMedia) {
return "#FF6B35" // Orange for media
}
return Theme.primary
}
// App Icon with enhanced styling
Rectangle {
width: groupPriority === NotificationGroupingService.priorityHigh ? 40 : 32
height: width
radius: width / 2
color: getIconBackgroundColor()
anchors.left: parent.left
anchors.leftMargin: groupPriority === NotificationGroupingService.priorityHigh ? Theme.spacingM + 4 : Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
// Removed glow effect as requested
function getIconBackgroundColor() {
if (notificationType === NotificationGroupingService.typeConversation) {
return Theme.primaryContainer
} else if (notificationType === NotificationGroupingService.typeMedia) {
return Qt.rgba(1, 0.42, 0.21, 0.2) // Orange tint for media
}
return Theme.primaryContainer
}
// Material icon fallback with type-specific icons
Loader {
active: !model.appIcon || model.appIcon === ""
anchors.fill: parent
sourceComponent: Text {
anchors.centerIn: parent
text: getDefaultIcon()
font.family: Theme.iconFont
font.pixelSize: groupPriority === NotificationGroupingService.priorityHigh ? 20 : 16
color: Theme.primaryText
function getDefaultIcon() {
if (notificationType === NotificationGroupingService.typeConversation) {
return "chat"
} else if (notificationType === NotificationGroupingService.typeMedia) {
return "music_note"
} else if (notificationType === NotificationGroupingService.typeSystem) {
return "settings"
}
return "apps"
}
}
}
// App icon with priority-based sizing
Loader {
active: model.appIcon && model.appIcon !== ""
anchors.centerIn: parent
sourceComponent: IconImage {
width: groupPriority === NotificationGroupingService.priorityHigh ? 28 : 24
height: width
asynchronous: true
source: {
if (!model.appIcon) return ""
if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon
}
return Quickshell.iconPath(model.appIcon, "image-missing")
}
}
}
}
// App Name and Summary with enhanced layout
Column {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM + (groupPriority === NotificationGroupingService.priorityHigh ? 48 : 40) + Theme.spacingM
anchors.right: parent.right
anchors.rightMargin: 32 // Maximum available width for message content
anchors.verticalCenter: parent.verticalCenter // Center the entire content vertically
spacing: groupPriority === NotificationGroupingService.priorityHigh ? 4 : 2
Row {
width: parent.width
spacing: Theme.spacingS
Text {
text: model.appName || "App"
font.pixelSize: groupPriority === NotificationGroupingService.priorityHigh ? Theme.fontSizeLarge : Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: groupPriority === NotificationGroupingService.priorityHigh ? Font.DemiBold : Font.Medium
}
// Enhanced notification count badge
Rectangle {
width: Math.max(countText.width + 8, 20)
height: 20
radius: 10
color: getBadgeColor()
visible: model.totalCount > 1
anchors.verticalCenter: parent.verticalCenter
// Removed glow effect as requested
function getBadgeColor() {
if (groupPriority === NotificationGroupingService.priorityHigh) {
return Theme.primary
}
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.8)
}
Text {
id: countText
anchors.centerIn: parent
text: model.totalCount.toString()
font.pixelSize: Theme.fontSizeSmall
color: Theme.primaryText
font.weight: Font.Medium
}
}
}
// Latest message summary (title)
Text {
text: getLatestMessageTitle()
font.pixelSize: groupPriority === NotificationGroupingService.priorityHigh ? Theme.fontSizeMedium : Theme.fontSizeSmall
color: Theme.surfaceText
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
font.weight: Font.Medium
function getLatestMessageTitle() {
if (model.latestNotification) {
return model.latestNotification.summary || ""
}
return ""
}
}
// Latest message body (content)
Text {
text: getLatestMessageBody()
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.8)
width: parent.width
wrapMode: Text.WordWrap
elide: Text.ElideRight
visible: text.length > 0
maximumLineCount: groupPriority === NotificationGroupingService.priorityHigh ? 3 : 2
function getLatestMessageBody() {
if (model.latestNotification) {
return model.latestNotification.body || ""
}
return ""
}
}
// Additional messages indicator removed - moved below as floating text
}
// Enhanced Expand/Collapse Icon - moved up more for better spacing
Rectangle {
id: expandCollapseButton
width: model.totalCount > 1 ? 32 : 0
height: 32
radius: 16
anchors.right: parent.right
anchors.rightMargin: 6 // Reduced right margin to add left padding
anchors.bottom: parent.bottom
anchors.bottomMargin: 16 // Moved up even more for better spacing
color: expandButtonArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
"transparent"
visible: model.totalCount > 1
Behavior on width {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Text {
anchors.centerIn: parent
text: isExpanded ? "expand_less" : "expand_more"
font.family: Theme.iconFont
font.pixelSize: 20
color: expandButtonArea.containsMouse ? Theme.primary : Theme.surfaceText
Behavior on text {
enabled: false // Disable animation on text change to prevent flicker
}
}
MouseArea {
id: expandButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: model.totalCount > 1
onClicked: {
NotificationGroupingService.toggleGroupExpansion(index)
}
}
}
// Close group button
Rectangle {
width: 24
height: 24
radius: 12
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 6
color: closeGroupArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
"transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 14
color: closeGroupArea.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: closeGroupArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
NotificationGroupingService.removeGroup(index)
}
}
}
// Timestamp positioned under close button
Text {
id: timestampText
text: model.latestNotification ?
NotificationGroupingService.formatTimestamp(model.latestNotification.timestamp) : ""
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: 6
anchors.bottomMargin: 6
visible: text.length > 0
}
MouseArea {
id: groupHeaderArea
anchors.fill: parent
anchors.rightMargin: 32 // Adjusted for maximum content width
hoverEnabled: true
cursorShape: model.totalCount > 1 ? Qt.PointingHandCursor : Qt.ArrowCursor
preventStealing: false
propagateComposedEvents: true
onClicked: {
if (model.totalCount > 1) {
NotificationGroupingService.toggleGroupExpansion(index)
}
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Floating "More messages" indicator - positioned below the main group
Rectangle {
width: Math.min(parent.width * 0.8, 200)
height: 24
radius: 12
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 1
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
border.width: 1
visible: model.totalCount > 1 && !isExpanded
// Smooth fade animation
opacity: (model.totalCount > 1 && !isExpanded) ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Text {
anchors.centerIn: parent
text: getFloatingIndicatorText()
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.9)
font.weight: Font.Medium
function getFloatingIndicatorText() {
if (model.totalCount > 1) {
const additionalCount = model.totalCount - 1
return `${additionalCount} more message${additionalCount > 1 ? "s" : ""} Tap to expand`
}
return ""
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
NotificationGroupingService.toggleGroupExpansion(index)
}
}
// Subtle hover effect
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Expanded Notifications List with enhanced animation
Item {
width: parent.width
height: isExpanded ? expandedContent.height + Theme.spacingS : 0
clip: true
// Enhanced staggered animation
Behavior on height {
SequentialAnimation {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}
Column {
id: expandedContent
width: parent.width
spacing: Theme.spacingXS
opacity: isExpanded ? 1.0 : 0.0
topPadding: Theme.spacingS
bottomPadding: Theme.spacingM
// Enhanced opacity animation
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
Repeater {
model: groupData.notifications
delegate: Rectangle {
// Skip the first (latest) notification since it's shown in the header
visible: index > 0
width: parent.width
height: 80
radius: Theme.cornerRadius
color: notifArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)
// Subtle left border for nested notifications
Rectangle {
width: 2
height: parent.height - 16
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
radius: 1
}
// Smooth appearance animation
opacity: isExpanded ? 1.0 : 0.0
transform: Translate {
y: isExpanded ? 0 : -10
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on transform {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
// Individual notification close button
Rectangle {
width: 24
height: 24
radius: 12
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
color: closeNotifArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
"transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 14
color: closeNotifArea.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: closeNotifArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// Use the parent ListView's index to get the group index
let groupIndex = parent.parent.parent.parent.parent.index
NotificationGroupingService.removeNotification(groupIndex, model.index)
}
}
}
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
anchors.leftMargin: Theme.spacingM + 8 // Extra space for border
anchors.rightMargin: 36
spacing: Theme.spacingM
// Notification icon
Rectangle {
width: 48
height: 48
radius: width / 2
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
// Material icon fallback
Loader {
active: !model.appIcon || model.appIcon === ""
anchors.fill: parent
sourceComponent: Text {
anchors.centerIn: parent
text: "notifications"
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.primaryText
}
}
// App icon (when no notification image)
Loader {
active: model.appIcon && model.appIcon !== "" && (!model.image || model.image === "")
anchors.centerIn: parent
sourceComponent: IconImage {
width: 32
height: 32
asynchronous: true
source: {
if (!model.appIcon) return ""
if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon
}
return Quickshell.iconPath(model.appIcon, "image-missing")
}
}
}
// Notification image (priority)
Loader {
active: model.image && model.image !== ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
Image {
id: notifImage
anchors.fill: parent
source: model.image || ""
fillMode: Image.PreserveAspectCrop
cache: true
antialiasing: true
asynchronous: true
smooth: true
sourceSize.width: parent.width
sourceSize.height: parent.height
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: Rectangle {
width: 48
height: 48
radius: 24
}
}
}
// Small app icon overlay
Loader {
active: model.appIcon && model.appIcon !== "" && notifImage.status === Image.Ready
anchors.bottom: parent.bottom
anchors.right: parent.right
sourceComponent: IconImage {
width: 16
height: 16
asynchronous: true
source: {
if (!model.appIcon) return ""
if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon
}
return Quickshell.iconPath(model.appIcon, "image-missing")
}
}
}
}
}
}
// Notification content
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 80
spacing: Theme.spacingXS
Text {
text: model.summary || ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
}
Text {
text: model.body || ""
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
visible: text.length > 0
}
Text {
text: NotificationGroupingService.formatTimestamp(model.timestamp)
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
visible: text.length > 0
}
}
}
MouseArea {
id: notifArea
anchors.fill: parent
anchors.rightMargin: 32
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: false
propagateComposedEvents: true
onClicked: {
if (model && root.handleNotificationClick) {
root.handleNotificationClick(model)
}
// Use the parent ListView's index to get the group index
let groupIndex = parent.parent.parent.parent.parent.index
NotificationGroupingService.removeNotification(groupIndex, model.index)
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
}
}
}
// Empty state
Item {
anchors.fill: parent
visible: NotificationGroupingService.totalCount === 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingM
width: parent.width * 0.8
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "notifications_none"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSizeLarge + 16
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.3)
font.weight: Theme.iconFontWeight
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "No notifications"
font.pixelSize: Theme.fontSizeLarge
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.6)
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Notifications will appear here grouped by app"
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
width: parent.width
}
}
}
}
}
}
// Click outside to close
MouseArea {
anchors.fill: parent
z: -1
onClicked: {
root.notificationHistoryVisible = false
}
}
}

View File

@@ -0,0 +1,350 @@
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 notificationWrapper
readonly property bool hasImage: notificationWrapper.hasImage
readonly property bool hasAppIcon: notificationWrapper.hasAppIcon
readonly property bool isConversation: notificationWrapper.isConversation
readonly property bool isMedia: notificationWrapper.isMedia
readonly property bool isUrgent: notificationWrapper.urgency === 2
readonly property bool isPopup: notificationWrapper.popup
property bool expanded: false
width: 380
height: Math.max(contentColumn.height + Theme.spacingL * 2, 80)
radius: Theme.cornerRadiusLarge
color: isUrgent ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.popupBackground()
border.color: isUrgent ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
// 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: isUrgent
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onEntered: notificationWrapper.timer.stop()
onExited: notificationWrapper.timer.start()
onClicked: (mouse) => {
if (mouse.button === Qt.MiddleButton) {
NotificationService.dismissNotification(notificationWrapper)
} else {
// Handle notification action
const actions = notificationWrapper.actions;
if (actions && actions.length === 1) {
actions[0].invoke();
}
}
}
}
Column {
id: contentColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingL
spacing: Theme.spacingS
Row {
width: parent.width
spacing: Theme.spacingM
// Image/Icon container
Item {
width: 48
height: 48
anchors.top: parent.top
// Notification image (Discord avatars, media artwork, etc.)
Loader {
id: imageLoader
active: root.hasImage
anchors.fill: parent
sourceComponent: Rectangle {
radius: 24 // Fully rounded
color: Theme.surfaceContainer
clip: true
Image {
id: notifImage
anchors.fill: parent
source: root.notificationWrapper.image
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
smooth: true
onStatusChanged: {
if (status === Image.Error) {
console.warn("Failed to load notification image:", source)
}
}
}
}
}
// App icon (shown when no image, or as badge when image present)
Loader {
active: root.hasAppIcon || !root.hasImage
// Position as overlay badge when image is present, center when no image
anchors.centerIn: root.hasImage ? undefined : parent
anchors.bottom: root.hasImage ? parent.bottom : undefined
anchors.right: root.hasImage ? parent.right : undefined
sourceComponent: Rectangle {
width: root.hasImage ? 20 : 48
height: root.hasImage ? 20 : 48
radius: width / 2
color: getIconBackgroundColor()
border.color: root.hasImage ? Theme.surface : "transparent"
border.width: root.hasImage ? 2 : 0
function getIconBackgroundColor() {
if (root.hasImage) {
return Theme.surface // Badge background
} else if (root.isConversation) {
return Theme.primaryContainer
} else if (root.isMedia) {
return Qt.rgba(1, 0.42, 0.21, 0.2) // Orange tint
}
return Theme.primaryContainer
}
IconImage {
id: iconImage
width: root.hasImage ? 14 : 32
height: root.hasImage ? 14 : 32
anchors.centerIn: parent
asynchronous: true
visible: status === Image.Ready
source: {
if (root.hasAppIcon) {
return Quickshell.iconPath(root.notificationWrapper.appIcon, "")
}
// Special cases for specific apps
if (root.notificationWrapper.appName === "niri" && root.notificationWrapper.summary === "Screenshot captured") {
return Quickshell.iconPath("camera-photo", "")
}
// Fallback icons
if (root.isConversation) return Quickshell.iconPath("chat", "")
if (root.isMedia) return Quickshell.iconPath("music_note", "")
return Quickshell.iconPath("dialog-information", "")
}
// Color overlay for symbolic icons when used as badge
layer.enabled: root.hasImage && root.notificationWrapper.appIcon.endsWith("symbolic")
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: Theme.surfaceText
}
}
// Elegant fallback when icon fails to load
Rectangle {
width: root.hasImage ? 14 : 32
height: root.hasImage ? 14 : 32
anchors.centerIn: parent
visible: iconImage.status === Image.Error || iconImage.status === Image.Null
radius: width / 2
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
Text {
anchors.centerIn: parent
text: {
if (root.isConversation) return "💬"
if (root.isMedia) return "🎵"
if (root.notificationWrapper.appName === "niri") return "📷"
return "📋"
}
font.pixelSize: root.hasImage ? 8 : 16
color: Theme.primary
}
}
}
}
// Fallback when no app icon and no image
Loader {
active: !root.hasAppIcon && !root.hasImage
anchors.centerIn: parent
sourceComponent: Rectangle {
width: 48
height: 48
radius: 24
color: Theme.primaryContainer
Text {
anchors.centerIn: parent
text: getFallbackIconText()
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.primaryText
function getFallbackIconText() {
if (root.isConversation) return "chat"
if (root.isMedia) return "music_note"
return "apps"
}
}
}
}
}
// Content area
Column {
width: parent.width - 48 - Theme.spacingM - 24 - Theme.spacingS
spacing: Theme.spacingXS
// Header row: App name and timestamp combined
Text {
text: {
const appName = root.notificationWrapper.appName || "Unknown"
const timeStr = root.notificationWrapper.timeStr || "now"
return appName + " • " + timeStr
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
}
// Summary (title)
Text {
text: root.notificationWrapper.summary
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
}
// Body text - use full available width
Text {
text: root.notificationWrapper.body
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: root.expanded ? -1 : 2
elide: Text.ElideRight
visible: text.length > 0
textFormat: Text.MarkdownText
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
NotificationService.dismissNotification(root.notificationWrapper)
}
}
}
// Close button
Rectangle {
width: 24
height: 24
radius: 12
color: closeArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 14
color: closeArea.containsMouse ? Theme.primary : Theme.surfaceVariantText
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: NotificationService.dismissNotification(root.notificationWrapper)
}
}
}
// Actions (if present)
Row {
width: parent.width
spacing: Theme.spacingS
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 48 + Theme.spacingM + Theme.spacingL
anchors.rightMargin: Theme.spacingL
visible: root.notificationWrapper.actions && root.notificationWrapper.actions.length > 0
Repeater {
model: root.notificationWrapper.actions || []
delegate: Rectangle {
required property NotificationAction modelData
width: actionText.width + Theme.spacingM * 2
height: 32
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: Theme.fontSizeSmall
color: Theme.surfaceVariantText
font.weight: Font.Medium
}
MouseArea {
id: actionArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: modelData.invoke()
}
}
}
}
}
// Animations
Behavior on height {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
}

View File

@@ -1,490 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import "../Common"
import "../Common/Utilities.js" as Utils
PanelWindow {
id: notificationPopup
visible: root.showNotificationPopup && root.activeNotification
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
anchors {
top: true
right: true
}
margins {
top: Theme.barHeight
right: 0
}
implicitWidth: 396
implicitHeight: 116 // Just the notification area
Rectangle {
id: popupContainer
anchors.fill: parent
anchors.topMargin: 16 // 16px from the top of this window
anchors.rightMargin: 16 // 16px from the right edge
color: Theme.popupBackground()
radius: Theme.cornerRadiusLarge
border.width: 0 // Remove border completely
// TopBar dropdown animation - slide down from bar
transform: [
Translate {
id: swipeTransform
x: 0
y: root.showNotificationPopup ? 0 : -30
Behavior on y {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
},
Scale {
id: scaleTransform
origin.x: parent.width
origin.y: 0
xScale: root.showNotificationPopup ? 1.0 : 0.95
yScale: root.showNotificationPopup ? 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: root.showNotificationPopup ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
// Drag area for swipe gestures
DragHandler {
id: dragHandler
target: null // We'll handle the transform manually
acceptedDevices: PointerDevice.TouchScreen | PointerDevice.Mouse
property real startX: 0
property real currentDelta: 0
property bool isDismissing: false
onActiveChanged: {
if (active) {
startX = centroid.position.x
currentDelta = 0
isDismissing = false
} else {
// Handle end of drag
let deltaX = centroid.position.x - startX
if (Math.abs(deltaX) > 80) { // Threshold for swipe action
if (deltaX > 0) {
// Swipe right - open notification history
swipeOpenHistory()
} else {
// Swipe left - dismiss notification
swipeDismiss()
}
} else {
// Snap back to original position
snapBack()
}
}
}
onCentroidChanged: {
if (active) {
let deltaX = centroid.position.x - startX
currentDelta = deltaX
// Limit swipe distance and add resistance
let maxDistance = 120
let resistance = 0.6
if (Math.abs(deltaX) > maxDistance) {
deltaX = deltaX > 0 ? maxDistance : -maxDistance
}
swipeTransform.x = deltaX * resistance
// Visual feedback - reduce opacity when swiping left (dismiss)
if (deltaX < 0) {
popupContainer.opacity = Math.max(0.3, 1.0 - Math.abs(deltaX) / 150)
} else {
popupContainer.opacity = Math.max(0.7, 1.0 - Math.abs(deltaX) / 200)
}
}
}
function swipeOpenHistory() {
// Animate to the right and open history
swipeAnimation.to = 400
swipeAnimation.onFinished = function() {
root.notificationHistoryVisible = true
Utils.hideNotificationPopup()
snapBack()
}
swipeAnimation.start()
}
function swipeDismiss() {
// Animate to the left and dismiss
swipeAnimation.to = -400
swipeAnimation.onFinished = function() {
Utils.hideNotificationPopup()
snapBack()
}
swipeAnimation.start()
}
function snapBack() {
swipeAnimation.to = 0
swipeAnimation.onFinished = function() {
popupContainer.opacity = Qt.binding(() => root.showNotificationPopup ? 1.0 : 0.0)
}
swipeAnimation.start()
}
}
// Swipe animation
NumberAnimation {
id: swipeAnimation
target: swipeTransform
property: "x"
duration: 200
easing.type: Easing.OutCubic
}
// Tap area for notification interaction
MouseArea {
anchors.fill: parent
anchors.rightMargin: 36 // Don't overlap with close button
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onClicked: (mouse) => {
console.log("Popup clicked!")
if (root.activeNotification) {
root.handleNotificationClick(root.activeNotification)
// Don't remove from history - just hide popup
}
// Hide popup but keep in history
Utils.hideNotificationPopup()
mouse.accepted = true // Prevent event propagation
}
}
// Close button with hover styling
Rectangle {
width: 28
height: 28
radius: 14
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
color: closeButtonArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 16
color: closeButtonArea.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: closeButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onClicked: (mouse) => {
Utils.hideNotificationPopup()
mouse.accepted = true // Prevent event propagation
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Small dismiss button - bottom right corner with better positioning
Rectangle {
width: 60
height: 18
radius: 9
color: dismissButtonArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
border.color: dismissButtonArea.containsMouse ?
Theme.primary :
Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.5)
border.width: 1
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: 12
anchors.bottomMargin: 14 // Moved up for better padding
Row {
anchors.centerIn: parent
spacing: 4
Text {
text: "archive"
font.family: Theme.iconFont
font.pixelSize: 10
color: dismissButtonArea.containsMouse ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "Dismiss"
font.pixelSize: 10
color: dismissButtonArea.containsMouse ? Theme.primary : Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: dismissButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
// Just hide the popup, keep in history
Utils.hideNotificationPopup()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on border.color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Content layout
Row {
anchors.fill: parent
anchors.margins: 12
anchors.rightMargin: 32
anchors.bottomMargin: 24 // Even more space to ensure 2px+ margin above dismiss button
spacing: 12
// Notification icon based on EXAMPLE NotificationAppIcon pattern
Rectangle {
width: 48
height: 48
radius: width / 2 // Fully rounded like EXAMPLE
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
// Material icon fallback (when no app icon)
Loader {
active: !root.activeNotification || !root.activeNotification.appIcon || root.activeNotification.appIcon === ""
anchors.fill: parent
sourceComponent: Text {
anchors.centerIn: parent
text: "notifications"
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.primaryText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// App icon (when no notification image)
Loader {
active: root.activeNotification && root.activeNotification.appIcon !== "" && (!root.activeNotification.image || root.activeNotification.image === "")
anchors.centerIn: parent
sourceComponent: IconImage {
width: 32
height: 32
asynchronous: true
source: {
if (!root.activeNotification || !root.activeNotification.appIcon) return ""
let appIcon = root.activeNotification.appIcon
// Handle file:// URLs directly
if (appIcon.startsWith("file://") || appIcon.startsWith("/")) {
return appIcon
}
// Otherwise treat as icon name
return Quickshell.iconPath(appIcon, "image-missing")
}
}
}
// Notification image (like Discord user avatar) - PRIORITY
Loader {
active: root.activeNotification && root.activeNotification.image !== ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
Image {
id: notifImage
anchors.fill: parent
source: root.activeNotification ? root.activeNotification.image : ""
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
smooth: true
// Use the parent size for optimization
sourceSize.width: parent.width
sourceSize.height: parent.height
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskSource: Rectangle {
width: 48
height: 48
radius: 24 // Fully rounded
}
}
onStatusChanged: {
if (status === Image.Error) {
console.warn("Failed to load notification image:", source)
} else if (status === Image.Ready) {
console.log("Notification image loaded:", source, "size:", sourceSize.width + "x" + sourceSize.height)
}
}
}
// Small app icon overlay when showing notification image
Loader {
active: root.activeNotification && root.activeNotification.appIcon !== ""
anchors.bottom: parent.bottom
anchors.right: parent.right
sourceComponent: IconImage {
width: 16
height: 16
asynchronous: true
source: {
if (!root.activeNotification || !root.activeNotification.appIcon) return ""
let appIcon = root.activeNotification.appIcon
if (appIcon.startsWith("file://") || appIcon.startsWith("/")) {
return appIcon
}
return Quickshell.iconPath(appIcon, "image-missing")
}
}
}
}
}
}
// Text content
Column {
width: parent.width - 68
anchors.top: parent.top
anchors.topMargin: 4 // Move content up slightly
spacing: 3
// Title and timestamp row
Row {
width: parent.width
spacing: 8
Text {
text: root.activeNotification ? (root.activeNotification.summary || "") : ""
font.pixelSize: 14
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width - timestampText.width - parent.spacing
elide: Text.ElideRight
visible: text.length > 0
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: timestampText
text: root.activeNotification ? formatNotificationTime(root.activeNotification.timestamp) : ""
font.pixelSize: 9
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5)
visible: text.length > 0
anchors.verticalCenter: parent.verticalCenter
function formatNotificationTime(timestamp) {
if (!timestamp) return ""
const now = new Date()
const notifTime = new Date(timestamp)
const diffMs = now.getTime() - notifTime.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
if (diffMinutes < 1) {
return "now"
} else if (diffMinutes < 60) {
return `${diffMinutes}m`
} else {
const diffHours = Math.floor(diffMs / 3600000)
return `${diffHours}h`
}
}
}
}
Text {
text: root.activeNotification ? (root.activeNotification.body || "") : ""
font.pixelSize: 12
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
width: parent.width
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
visible: text.length > 0
}
}
}
}
}

View File

@@ -0,0 +1,104 @@
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

@@ -189,10 +189,6 @@ PanelWindow {
onClicked: {
if (topBar.shellRoot) {
// Hide notification popup if visible
if (topBar.shellRoot.showNotificationPopup) {
Utils.hideNotificationPopup()
}
topBar.shellRoot.calendarVisible = !topBar.shellRoot.calendarVisible
}
}
@@ -212,10 +208,6 @@ PanelWindow {
onClicked: {
if (topBar.shellRoot) {
// Hide notification popup if visible
if (topBar.shellRoot.showNotificationPopup) {
Utils.hideNotificationPopup()
}
topBar.shellRoot.calendarVisible = !topBar.shellRoot.calendarVisible
}
}
@@ -267,10 +259,6 @@ PanelWindow {
cursorShape: Qt.PointingHandCursor
onClicked: {
// Hide notification popup if visible
if (topBar.shellRoot && topBar.shellRoot.showNotificationPopup) {
Utils.hideNotificationPopup()
}
topBar.clipboardRequested()
}
}
@@ -302,10 +290,6 @@ PanelWindow {
isActive: topBar.shellRoot ? topBar.shellRoot.notificationHistoryVisible : false
onClicked: {
if (topBar.shellRoot) {
// Hide notification popup if visible
if (topBar.shellRoot.showNotificationPopup) {
Utils.hideNotificationPopup()
}
topBar.shellRoot.notificationHistoryVisible = !topBar.shellRoot.notificationHistoryVisible
}
}
@@ -327,10 +311,6 @@ PanelWindow {
onClicked: {
if (topBar.shellRoot) {
// Hide notification popup if visible
if (topBar.shellRoot.showNotificationPopup) {
Utils.hideNotificationPopup()
}
topBar.shellRoot.controlCenterVisible = !topBar.shellRoot.controlCenterVisible
if (topBar.shellRoot.controlCenterVisible) {
WifiService.scanWifi()

View File

@@ -1,8 +1,8 @@
TopBar 1.0 TopBar/TopBar.qml
TrayMenuPopup 1.0 TrayMenuPopup.qml
NotificationPopup 1.0 NotificationPopup.qml
NotificationHistoryPopup 1.0 NotificationHistoryPopup.qml
NotificationCompactGroup 1.0 NotificationCompactGroup.qml
NotificationItem 1.0 NotificationItem.qml
NotificationPopupNative 1.0 NotificationPopupNative.qml
NotificationHistoryNative 1.0 NotificationHistoryNative.qml
WifiPasswordDialog 1.0 WifiPasswordDialog.qml
AppLauncher 1.0 AppLauncher.qml
ClipboardHistory 1.0 ClipboardHistory.qml