1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-09 15:05:39 -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,530 +0,0 @@
import QtQuick
import Quickshell
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
// Grouped notifications model - initialize as ListModel directly
property ListModel groupedNotifications: ListModel {}
// Total count of all notifications across all groups
property int totalCount: 0
// Map to track group indices by app name for efficient lookups
property var appGroupMap: ({})
// Debounce timer for sorting
property bool _sortDirty: false
Timer {
id: sortTimer
interval: 50 // 50ms debounce interval
onTriggered: {
if (_sortDirty) {
sortGroupsByPriority()
_sortDirty = false
}
}
}
// Configuration
property int maxNotificationsPerGroup: 10
property int maxGroups: 20
// Priority constants for Android 16-style stacking
readonly property int priorityHigh: 2 // Conversations, calls, media
readonly property int priorityNormal: 1 // Regular notifications
readonly property int priorityLow: 0 // System, background updates
// Notification type constants
readonly property int typeConversation: 1
readonly property int typeMedia: 2
readonly property int typeSystem: 3
readonly property int typeNormal: 4
// Format timestamp for display
function formatTimestamp(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)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMinutes < 1) {
return "now"
} else if (diffMinutes < 60) {
return `${diffMinutes}m ago`
} else if (diffHours < 24) {
return `${diffHours}h ago`
} else if (diffDays < 7) {
return `${diffDays}d ago`
} else {
return notifTime.toLocaleDateString()
}
}
// Add a new notification to the appropriate group
function addNotification(notificationObj) {
if (!notificationObj || !notificationObj.appName) {
console.warn("Invalid notification object:", notificationObj)
return
}
// Enhance notification with priority and type detection
notificationObj = enhanceNotification(notificationObj)
const appName = notificationObj.appName
let groupIndex = appGroupMap[appName]
if (groupIndex === undefined) {
// Create new group
groupIndex = createNewGroup(appName, notificationObj)
} else {
// Add to existing group
addToExistingGroup(groupIndex, notificationObj)
}
updateTotalCount()
}
// Create a new notification group
function createNewGroup(appName, notificationObj) {
// Check if we need to remove oldest group
if (groupedNotifications.count >= maxGroups) {
removeOldestGroup()
}
const groupIndex = groupedNotifications.count
const notificationsList = Qt.createQmlObject(`
import QtQuick
ListModel {}
`, root)
notificationsList.append(notificationObj)
// Create properly structured latestNotification object
const latestNotificationData = {
"id": notificationObj.id || "",
"appName": notificationObj.appName || "",
"appIcon": notificationObj.appIcon || "",
"summary": notificationObj.summary || "",
"body": notificationObj.body || "",
"timestamp": notificationObj.timestamp || new Date(),
"priority": notificationObj.priority || priorityNormal,
"notificationType": notificationObj.notificationType || typeNormal,
"urgency": notificationObj.urgency || 1,
"image": notificationObj.image || ""
}
const groupData = {
"appName": appName,
"appIcon": notificationObj.appIcon || "",
"notifications": notificationsList,
"totalCount": 1,
"latestNotification": latestNotificationData,
"expanded": false,
"timestamp": notificationObj.timestamp || new Date(),
"priority": notificationObj.priority || priorityNormal,
"notificationType": notificationObj.notificationType || typeNormal
}
groupedNotifications.append(groupData)
// Sort groups by priority after adding
requestSort()
appGroupMap[appName] = groupIndex
updateGroupMap()
return groupIndex
}
// Add notification to existing group
function addToExistingGroup(groupIndex, notificationObj) {
if (groupIndex >= groupedNotifications.count) {
console.warn("Invalid group index:", groupIndex)
return
}
const group = groupedNotifications.get(groupIndex)
if (!group) return
// Add to front of group (newest first)
group.notifications.insert(0, notificationObj)
// Create a new object with proper property structure for latestNotification
const latestNotificationData = {
"id": notificationObj.id || "",
"appName": notificationObj.appName || "",
"appIcon": notificationObj.appIcon || "",
"summary": notificationObj.summary || "",
"body": notificationObj.body || "",
"timestamp": notificationObj.timestamp || new Date(),
"priority": notificationObj.priority || priorityNormal,
"notificationType": notificationObj.notificationType || typeNormal,
"urgency": notificationObj.urgency || 1,
"image": notificationObj.image || ""
}
// Update group metadata
groupedNotifications.setProperty(groupIndex, "totalCount", group.totalCount + 1)
groupedNotifications.setProperty(groupIndex, "latestNotification", latestNotificationData)
groupedNotifications.setProperty(groupIndex, "timestamp", notificationObj.timestamp || new Date())
// Update group priority if this notification has higher priority
const currentPriority = group.priority || priorityNormal
const newPriority = Math.max(currentPriority, notificationObj.priority || priorityNormal)
groupedNotifications.setProperty(groupIndex, "priority", newPriority)
// Update notification type if needed
if (notificationObj.notificationType === typeConversation ||
notificationObj.notificationType === typeMedia) {
groupedNotifications.setProperty(groupIndex, "notificationType", notificationObj.notificationType)
}
// Keep only max notifications per group
while (group.notifications.count > maxNotificationsPerGroup) {
group.notifications.remove(group.notifications.count - 1)
}
// Re-sort groups by priority after updating
requestSort()
}
// Request a debounced sort
function requestSort() {
_sortDirty = true
sortTimer.restart()
}
// Sort groups by priority and recency
function sortGroupsByPriority() {
if (groupedNotifications.count <= 1) return
for (let i = 0; i < groupedNotifications.count - 1; i++) {
for (let j = 0; j < groupedNotifications.count - i - 1; j++) {
const groupA = groupedNotifications.get(j)
const groupB = groupedNotifications.get(j + 1)
const priorityA = groupA.priority || priorityNormal
const priorityB = groupB.priority || priorityNormal
let shouldSwap = false
if (priorityA !== priorityB) {
if (priorityB > priorityA) {
shouldSwap = true
}
} else {
const timeA = new Date(groupA.timestamp || 0).getTime()
const timeB = new Date(groupB.timestamp || 0).getTime()
if (timeB > timeA) {
shouldSwap = true
}
}
if (shouldSwap) {
// Swap the elements at j and j + 1
groupedNotifications.move(j, j + 1, 1)
}
}
}
updateGroupMap()
}
// Remove the oldest group (least recent activity)
function removeOldestGroup() {
if (groupedNotifications.count === 0) return
const lastIndex = groupedNotifications.count - 1
const group = groupedNotifications.get(lastIndex)
if (group) {
delete appGroupMap[group.appName]
groupedNotifications.remove(lastIndex)
updateGroupMap()
}
}
// Update the app group map after structural changes
function updateGroupMap() {
appGroupMap = {}
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (group) {
appGroupMap[group.appName] = i
}
}
}
// Toggle group expansion state
function toggleGroupExpansion(groupIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (group) {
groupedNotifications.setProperty(groupIndex, "expanded", !group.expanded)
}
}
// Remove a specific notification from a group
function removeNotification(groupIndex, notificationIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (!group || notificationIndex >= group.notifications.count) return
group.notifications.remove(notificationIndex)
// Update group count
const newCount = group.totalCount - 1
groupedNotifications.setProperty(groupIndex, "totalCount", newCount)
// If group is empty, remove it
if (newCount === 0) {
removeGroup(groupIndex)
} else {
// Update latest notification if we removed the latest one
if (notificationIndex === 0 && group.notifications.count > 0) {
const newLatest = group.notifications.get(0)
// Create a new object with the correct structure
const latestNotificationData = {
"id": newLatest.id || "",
"appName": newLatest.appName || "",
"appIcon": newLatest.appIcon || "",
"summary": newLatest.summary || "",
"body": newLatest.body || "",
"timestamp": newLatest.timestamp || new Date(),
"priority": newLatest.priority || priorityNormal,
"notificationType": newLatest.notificationType || typeNormal,
"urgency": newLatest.urgency || 1,
"image": newLatest.image || ""
}
groupedNotifications.setProperty(groupIndex, "latestNotification", latestNotificationData)
// Update group priority after removal
const newPriority = getGroupPriority(groupIndex)
groupedNotifications.setProperty(groupIndex, "priority", newPriority)
}
}
updateTotalCount()
}
// Remove an entire group
function removeGroup(groupIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (group) {
delete appGroupMap[group.appName]
groupedNotifications.remove(groupIndex)
updateGroupMap() // Re-map all group indices
updateTotalCount()
}
}
// Clear all notifications
function clearAllNotifications() {
groupedNotifications.clear()
appGroupMap = {}
totalCount = 0
}
// Update total count across all groups
function updateTotalCount() {
let count = 0
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (group) {
count += group.totalCount
}
}
totalCount = count
}
// Enhance notification with priority and type detection
function enhanceNotification(notificationObj) {
const enhanced = Object.assign({}, notificationObj)
// Detect notification type and priority
enhanced.notificationType = detectNotificationType(enhanced)
enhanced.priority = detectPriority(enhanced)
return enhanced
}
// Detect notification type based on content and app
function detectNotificationType(notification) {
const appName = notification.appName?.toLowerCase() || ""
const summary = notification.summary?.toLowerCase() || ""
const body = notification.body?.toLowerCase() || ""
// Media notifications
if (appName.includes("music") || appName.includes("player") ||
appName.includes("spotify") || appName.includes("youtube") ||
summary.includes("now playing") || summary.includes("playing")) {
return typeMedia
}
// Conversation notifications
if (appName.includes("message") || appName.includes("chat") ||
appName.includes("telegram") || appName.includes("whatsapp") ||
appName.includes("discord") || appName.includes("slack") ||
summary.includes("message") || body.includes("message")) {
return typeConversation
}
// System notifications
if (appName.includes("system") || appName.includes("update") ||
summary.includes("update") || summary.includes("system")) {
return typeSystem
}
return typeNormal
}
// Detect priority based on type and urgency
function detectPriority(notification) {
const notificationType = notification.notificationType
const urgency = notification.urgency || 1 // Default to normal
// High priority for conversations and media
if (notificationType === typeConversation || notificationType === typeMedia) {
return priorityHigh
}
// Low priority for system notifications
if (notificationType === typeSystem) {
return priorityLow
}
// Use urgency for regular notifications
if (urgency >= 2) {
return priorityHigh
} else if (urgency >= 1) {
return priorityNormal
}
return priorityLow
}
// Get group priority (highest priority notification in group)
function getGroupPriority(groupIndex) {
if (groupIndex >= groupedNotifications.count) return priorityLow
const group = groupedNotifications.get(groupIndex)
if (!group) return priorityLow
let maxPriority = priorityLow
for (let i = 0; i < group.notifications.count; i++) {
const notification = group.notifications.get(i)
if (notification && notification.priority > maxPriority) {
maxPriority = notification.priority
}
}
return maxPriority
}
// Generate smart group summary for collapsed state
function generateGroupSummary(group) {
if (!group || !group.notifications || group.notifications.count === 0) {
return ""
}
const notificationCount = group.notifications.count
const latestNotification = group.notifications.get(0)
if (notificationCount === 1) {
return latestNotification.summary || latestNotification.body || ""
}
// For conversations, show sender names
if (latestNotification.notificationType === typeConversation) {
const senders = []
for (let i = 0; i < Math.min(3, notificationCount); i++) {
const notif = group.notifications.get(i)
if (notif && notif.summary && !senders.includes(notif.summary)) {
senders.push(notif.summary)
}
}
if (senders.length > 0) {
const remaining = notificationCount - senders.length
if (remaining > 0) {
return `${senders.join(", ")} and ${remaining} other${remaining > 1 ? "s" : ""}`
}
return senders.join(", ")
}
}
// For media, show current track info
if (latestNotification.notificationType === typeMedia) {
return latestNotification.summary || "Media playing"
}
// Generic summary for other types
return `${notificationCount} notification${notificationCount > 1 ? "s" : ""}`
}
// Get notification by ID across all groups
function getNotificationById(notificationId) {
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (!group) continue
for (let j = 0; j < group.notifications.count; j++) {
const notification = group.notifications.get(j)
if (notification && notification.id === notificationId) {
return {
groupIndex: i,
notificationIndex: j,
notification: notification
}
}
}
}
return null
}
// Get group by app name
function getGroupByAppName(appName) {
const groupIndex = appGroupMap[appName]
if (groupIndex !== undefined) {
return {
groupIndex: groupIndex,
group: groupedNotifications.get(groupIndex)
}
}
return null
}
// Get visible notifications for a group (considering expansion state)
function getVisibleNotifications(groupIndex, maxVisible = 3) {
if (groupIndex >= groupedNotifications.count) return []
const group = groupedNotifications.get(groupIndex)
if (!group) return []
if (group.expanded) {
// Show all notifications when expanded
return group.notifications
} else {
// Show only the latest notification(s) when collapsed
const visibleCount = Math.min(maxVisible, group.notifications.count)
const visible = []
for (let i = 0; i < visibleCount; i++) {
visible.push(group.notifications.get(i))
}
return visible
}
}
}

View File

@@ -0,0 +1,184 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Services.Notifications
Singleton {
id: root
readonly property list<NotifWrapper> notifications: []
readonly property list<NotifWrapper> popups: notifications.filter(n => n.popup)
NotificationServer {
id: server
keepOnReload: false
actionsSupported: true
bodyHyperlinksSupported: true
bodyImagesSupported: true
bodyMarkupSupported: true
imageSupported: true
onNotification: notif => {
notif.tracked = true;
const wrapper = notifComponent.createObject(root, {
popup: true,
notification: notif
});
root.notifications.push(wrapper);
}
}
component NotifWrapper: QtObject {
id: wrapper
property bool popup: true
readonly property date time: new Date()
readonly property string timeStr: {
const now = new Date();
const diff = now.getTime() - time.getTime();
const m = Math.floor(diff / 60000);
const h = Math.floor(m / 60);
if (h < 1 && m < 1)
return "now";
if (h < 1)
return `${m}m`;
return `${h}h`;
}
required property Notification notification
readonly property string summary: notification.summary
readonly property string body: notification.body
readonly property string appIcon: notification.appIcon
readonly property string appName: notification.appName
readonly property string image: notification.image
readonly property int urgency: notification.urgency
readonly property list<NotificationAction> actions: notification.actions
// Enhanced properties for better handling
readonly property bool hasImage: image && image.length > 0
readonly property bool hasAppIcon: appIcon && appIcon.length > 0
readonly property bool isConversation: detectIsConversation()
readonly property bool isMedia: detectIsMedia()
readonly property bool isSystem: detectIsSystem()
function detectIsConversation() {
const appNameLower = appName.toLowerCase();
const summaryLower = summary.toLowerCase();
const bodyLower = body.toLowerCase();
return appNameLower.includes("discord") ||
appNameLower.includes("vesktop") ||
appNameLower.includes("vencord") ||
appNameLower.includes("telegram") ||
appNameLower.includes("whatsapp") ||
appNameLower.includes("signal") ||
appNameLower.includes("slack") ||
appNameLower.includes("message") ||
summaryLower.includes("message") ||
bodyLower.includes("message");
}
function detectIsMedia() {
const appNameLower = appName.toLowerCase();
const summaryLower = summary.toLowerCase();
return appNameLower.includes("spotify") ||
appNameLower.includes("vlc") ||
appNameLower.includes("mpv") ||
appNameLower.includes("music") ||
appNameLower.includes("player") ||
summaryLower.includes("now playing") ||
summaryLower.includes("playing");
}
function detectIsSystem() {
const appNameLower = appName.toLowerCase();
const summaryLower = summary.toLowerCase();
return appNameLower.includes("system") ||
appNameLower.includes("update") ||
summaryLower.includes("update") ||
summaryLower.includes("system");
}
readonly property Timer timer: Timer {
running: wrapper.popup
interval: wrapper.notification.expireTimeout > 0 ? wrapper.notification.expireTimeout : 5000 // 5 second default
onTriggered: {
wrapper.popup = false;
}
}
readonly property Connections conn: Connections {
target: wrapper.notification.Retainable
function onDropped(): void {
const index = root.notifications.indexOf(wrapper);
if (index !== -1) {
root.notifications.splice(index, 1);
}
}
function onAboutToDestroy(): void {
wrapper.destroy();
}
}
}
Component {
id: notifComponent
NotifWrapper {}
}
// Helper functions
function clearAllNotifications() {
// Create a copy of the array to avoid modification during iteration
const notificationsCopy = [...root.notifications];
for (const notif of notificationsCopy) {
notif.notification.dismiss();
}
}
function dismissNotification(wrapper) {
wrapper.notification.dismiss();
}
function getNotificationIcon(wrapper) {
// Priority 1: Use notification image if available (Discord avatars, etc.)
if (wrapper.hasImage) {
return wrapper.image;
}
// Priority 2: Use app icon if available
if (wrapper.hasAppIcon) {
return Quickshell.iconPath(wrapper.appIcon, "image-missing");
}
// Priority 3: Generate fallback icon based on type
return getFallbackIcon(wrapper);
}
function getFallbackIcon(wrapper) {
if (wrapper.isConversation) {
return Quickshell.iconPath("chat", "image-missing");
} else if (wrapper.isMedia) {
return Quickshell.iconPath("music_note", "image-missing");
} else if (wrapper.isSystem) {
return Quickshell.iconPath("settings", "image-missing");
}
return Quickshell.iconPath("apps", "image-missing");
}
function getAppIconPath(wrapper) {
if (wrapper.hasAppIcon) {
return Quickshell.iconPath(wrapper.appIcon, "image-missing");
}
return getFallbackIcon(wrapper);
}
}

View File

@@ -15,4 +15,4 @@ singleton NiriWorkspaceService 1.0 NiriWorkspaceService.qml
singleton CalendarService 1.0 CalendarService.qml
singleton UserInfoService 1.0 UserInfoService.qml
singleton FocusedWindowService 1.0 FocusedWindowService.qml
singleton NotificationGroupingService 1.0 NotificationGroupingService.qml
singleton NotificationService 1.0 NotificationService.qml

View File

@@ -0,0 +1,97 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "../Common"
import "../Services"
import "../Widgets"
ApplicationWindow {
id: demoWindow
width: 800
height: 600
visible: true
title: "Native Notification System Demo"
color: Theme.background
Column {
anchors.centerIn: parent
spacing: Theme.spacingL
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Native Notification System Demo"
font.pixelSize: Theme.fontSizeXLarge
color: Theme.surfaceText
font.weight: Font.Bold
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "This demo uses Quickshell's native NotificationServer"
font.pixelSize: Theme.fontSizeMedium
color: Theme.onSurfaceVariant
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingL
Button {
text: "Show Popups"
onClicked: notificationPopup.visible = true
}
Button {
text: "Show History"
onClicked: notificationHistory.notificationHistoryVisible = true
}
Button {
text: "Clear All"
onClicked: NotificationService.clearAllNotifications()
}
}
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingM
Text {
text: `Total Notifications: ${NotificationService.notifications.length}`
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
Text {
text: `Active Popups: ${NotificationService.popups.length}`
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
Text {
width: 600
anchors.horizontalCenter: parent.horizontalCenter
text: "Instructions:\n" +
"• Send notifications from other applications (Discord, etc.)\n" +
"• Use 'notify-send' command to test\n" +
"• Notifications will appear automatically in the popup\n" +
"• Images from Discord/Vesktop will show as avatars\n" +
"• App icons are automatically detected"
font.pixelSize: Theme.fontSizeSmall
color: Theme.onSurfaceVariant
wrapMode: Text.WordWrap
}
}
// Native notification popup
NotificationPopupNative {
id: notificationPopup
}
// Native notification history
NotificationHistoryNative {
id: notificationHistory
}
}

View File

@@ -1,441 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "../Common"
import "../Services"
import "../Widgets"
// Demo component to test the enhanced Android 16-style notification system
ApplicationWindow {
id: demoWindow
width: 800
height: 600
visible: true
title: "Android 16 Notification System Demo"
color: Theme.background
Component.onCompleted: {
// Add some sample notifications to demonstrate the system
addSampleNotifications()
}
function addSampleNotifications() {
// High priority conversation notifications
NotificationGroupingService.addNotification({
id: "msg1",
appName: "Messages",
appIcon: "message",
summary: "John Doe",
body: "Hey, are you free for lunch today?",
timestamp: new Date(),
urgency: 2
})
NotificationGroupingService.addNotification({
id: "msg2",
appName: "Messages",
appIcon: "message",
summary: "Jane Smith",
body: "Meeting moved to 3 PM",
timestamp: new Date(Date.now() - 300000), // 5 minutes ago
urgency: 2
})
NotificationGroupingService.addNotification({
id: "msg3",
appName: "Messages",
appIcon: "message",
summary: "John Doe",
body: "Let me know!",
timestamp: new Date(Date.now() - 60000), // 1 minute ago
urgency: 2
})
// Media notification
NotificationGroupingService.addNotification({
id: "media1",
appName: "Spotify",
appIcon: "music_note",
summary: "Now Playing: Gemini Dreams",
body: "Artist: Synthwave Collective",
timestamp: new Date(Date.now() - 120000), // 2 minutes ago
urgency: 1
})
// Regular notifications
NotificationGroupingService.addNotification({
id: "gmail1",
appName: "Gmail",
appIcon: "mail",
summary: "New email from Sarah",
body: "Project update - please review",
timestamp: new Date(Date.now() - 600000), // 10 minutes ago
urgency: 1
})
NotificationGroupingService.addNotification({
id: "gmail2",
appName: "Gmail",
appIcon: "mail",
summary: "Weekly newsletter",
body: "Your weekly digest is ready",
timestamp: new Date(Date.now() - 900000), // 15 minutes ago
urgency: 0
})
// System notifications (low priority)
NotificationGroupingService.addNotification({
id: "sys1",
appName: "System",
appIcon: "settings",
summary: "Software update available",
body: "Update to version 1.2.3",
timestamp: new Date(Date.now() - 1800000), // 30 minutes ago
urgency: 0
})
// Discord conversation
NotificationGroupingService.addNotification({
id: "discord1",
appName: "Discord",
appIcon: "chat",
summary: "Alice in #general",
body: "Anyone up for a game tonight?",
timestamp: new Date(Date.now() - 180000), // 3 minutes ago
urgency: 1
})
NotificationGroupingService.addNotification({
id: "discord2",
appName: "Discord",
appIcon: "chat",
summary: "Bob in #general",
body: "I'm in! What time?",
timestamp: new Date(Date.now() - 150000), // 2.5 minutes ago
urgency: 1
})
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
// Header
Text {
text: "Android 16 Notification System Demo"
font.pixelSize: Theme.fontSizeXLarge
color: Theme.surfaceText
font.weight: Font.Bold
Layout.fillWidth: true
}
// Stats row
Row {
spacing: Theme.spacingL
Layout.fillWidth: true
Text {
text: "Total Notifications: " + NotificationGroupingService.totalCount
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "Groups: " + NotificationGroupingService.groupedNotifications.count
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Button {
text: "Add Sample Notification"
onClicked: addRandomNotification()
anchors.verticalCenter: parent.verticalCenter
}
Button {
text: "Clear All"
onClicked: NotificationGroupingService.clearAllNotifications()
anchors.verticalCenter: parent.verticalCenter
}
}
// Main notification list
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ListView {
id: notificationList
model: NotificationGroupingService.groupedNotifications
spacing: Theme.spacingM
delegate: Column {
width: notificationList.width
spacing: Theme.spacingXS
property var groupData: model
property bool isExpanded: model.expanded || false
// Group header (similar to NotificationHistoryPopup but for demo)
Rectangle {
width: parent.width
height: getPriorityHeight()
radius: Theme.cornerRadius
color: getGroupColor()
// Priority indicator
Rectangle {
width: 4
height: parent.height - 8
anchors.left: parent.left
anchors.leftMargin: 2
anchors.verticalCenter: parent.verticalCenter
radius: 2
color: Theme.primary
visible: (model.priority || 1) === NotificationGroupingService.priorityHigh
}
function getPriorityHeight() {
return (model.priority || 1) === NotificationGroupingService.priorityHigh ? 70 : 60
}
function getGroupColor() {
if ((model.priority || 1) === NotificationGroupingService.priorityHigh) {
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
}
return Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)
}
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
// App icon
Rectangle {
width: 40
height: 40
radius: 20
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: getTypeIcon()
font.family: Theme.iconFont
font.pixelSize: 20
color: Theme.primaryText
function getTypeIcon() {
const type = model.notificationType || NotificationGroupingService.typeNormal
if (type === NotificationGroupingService.typeConversation) {
return "chat"
} else if (type === NotificationGroupingService.typeMedia) {
return "music_note"
} else if (type === NotificationGroupingService.typeSystem) {
return "settings"
}
return "apps"
}
}
}
// Content
Column {
width: parent.width - 40 - Theme.spacingM - 60
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
spacing: Theme.spacingS
Text {
text: model.appName || "App"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
Rectangle {
width: Math.max(countText.width + 6, 18)
height: 18
radius: 9
color: Theme.primary
visible: model.totalCount > 1
anchors.verticalCenter: parent.verticalCenter
Text {
id: countText
anchors.centerIn: parent
text: model.totalCount.toString()
font.pixelSize: 10
color: Theme.primaryText
font.weight: Font.Medium
}
}
Text {
text: getPriorityText()
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
font.weight: Font.Medium
visible: text.length > 0
anchors.verticalCenter: parent.verticalCenter
function getPriorityText() {
const priority = model.priority || NotificationGroupingService.priorityNormal
if (priority === NotificationGroupingService.priorityHigh) {
return "HIGH"
} else if (priority === NotificationGroupingService.priorityLow) {
return "LOW"
}
return ""
}
}
}
Text {
text: NotificationGroupingService.generateGroupSummary(model)
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
width: parent.width
elide: Text.ElideRight
maximumLineCount: 1
}
}
// Expand button
Rectangle {
width: 32
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: model.totalCount > 1
Text {
anchors.centerIn: parent
text: isExpanded ? "expand_less" : "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: {
NotificationGroupingService.toggleGroupExpansion(index)
}
}
}
}
}
// Expanded notifications
Item {
width: parent.width
height: isExpanded ? expandedContent.height : 0
clip: true
Behavior on height {
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
Column {
id: expandedContent
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: groupData.notifications
delegate: Rectangle {
width: parent.width
height: 60
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
Rectangle {
width: 32
height: 32
radius: 16
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "notifications"
font.family: Theme.iconFont
font.pixelSize: 16
color: Theme.primaryText
}
}
Column {
width: parent.width - 32 - Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: model.summary || ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
}
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
elide: Text.ElideRight
}
}
}
}
}
}
}
}
}
}
}
function addRandomNotification() {
const apps = ["Messages", "Gmail", "Discord", "Spotify", "System"]
const summaries = ["New message", "Update available", "Someone mentioned you", "Now playing", "Task completed"]
const bodies = ["This is a sample notification body", "Please check this out", "Important update", "Don't miss this", "Action required"]
const randomApp = apps[Math.floor(Math.random() * apps.length)]
const randomSummary = summaries[Math.floor(Math.random() * summaries.length)]
const randomBody = bodies[Math.floor(Math.random() * bodies.length)]
NotificationGroupingService.addNotification({
id: "random_" + Date.now(),
appName: randomApp,
appIcon: randomApp.toLowerCase(),
summary: randomSummary,
body: randomBody,
timestamp: new Date(),
urgency: Math.floor(Math.random() * 3)
})
}
}

View File

@@ -1,38 +0,0 @@
#!/bin/bash
# Test script to run the Android 16 notification system demo
echo "Starting Android 16 Notification System Demo..."
echo "This demo showcases the enhanced notification grouping and stacking features."
echo ""
# Check if quickshell is available
if ! command -v quickshell &> /dev/null; then
echo "Error: quickshell is not installed or not in PATH"
echo "Please install quickshell to run this demo"
exit 1
fi
# Navigate to the quickshell config directory
cd "$(dirname "$0")/.." || exit 1
# Run the demo in the background
echo "Running demo with quickshell in the background..."
quickshell -p Tests/NotificationSystemDemo.qml &
QUICKSHELL_PID=$!
# Wait for a few seconds to see if it crashes
sleep 5
# Check if the process is still running
if ps -p $QUICKSHELL_PID > /dev/null; then
echo "Demo is running successfully in the background (PID: $QUICKSHELL_PID)."
echo "Please close the demo window manually to stop the process."
# Kill the process for the purpose of this test
kill $QUICKSHELL_PID
else
echo "Error: The demo crashed or failed to start."
exit 1
fi
echo "Demo test completed."

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

151
shell.qml
View File

@@ -39,8 +39,6 @@ ShellRoot {
property string osLogo: OSDetectorService.osLogo
property string osName: OSDetectorService.osName
property bool notificationHistoryVisible: false
property var activeNotification: null
property bool showNotificationPopup: false
property bool mediaPlayerVisible: false
property MprisPlayer activePlayer: MprisController.activePlayer
property bool hasActiveMedia: activePlayer && (activePlayer.trackTitle || activePlayer.trackArtist)
@@ -110,64 +108,6 @@ ShellRoot {
// Wallpaper error status
property string wallpaperErrorStatus: ""
// Notification action handling - ALWAYS invoke action if exists
function handleNotificationClick(notifObj) {
console.log("Handling notification click for:", notifObj.appName)
// ALWAYS try to invoke the action first (this is what real notifications do)
if (notifObj.notification && notifObj.actions && notifObj.actions.length > 0) {
// Look for "default" action first, then fallback to first action
let defaultAction = notifObj.actions.find(action => action.identifier === "default") || notifObj.actions[0]
if (defaultAction) {
console.log("Invoking notification action:", defaultAction.text, "identifier:", defaultAction.identifier)
attemptInvokeAction(notifObj.id, defaultAction.identifier)
return
}
}
// If no action exists, check for URLs in notification text
let notificationText = (notifObj.summary || "") + " " + (notifObj.body || "")
let urlRegex = /(https?:\/\/[^\s]+)/g
let urls = notificationText.match(urlRegex)
if (urls && urls.length > 0) {
console.log("Opening URL from notification:", urls[0])
Qt.openUrlExternally(urls[0])
return
}
console.log("No action or URL found, notification will just dismiss")
}
// Helper function to invoke notification actions (based on EXAMPLE)
function attemptInvokeAction(notifId, actionIdentifier) {
console.log("Attempting to invoke action:", actionIdentifier, "for notification:", notifId)
// Find the notification in the server's tracked notifications
let trackedNotifications = notificationServer.trackedNotifications.values
let serverNotification = trackedNotifications.find(notif => notif.id === notifId)
if (serverNotification) {
let action = serverNotification.actions.find(action => action.identifier === actionIdentifier)
if (action) {
console.log("Invoking action:", action.text)
action.invoke()
} else {
console.warn("Action not found:", actionIdentifier)
}
} else {
console.warn("Notification not found in server:", notifId, "Available IDs:", trackedNotifications.map(n => n.id))
// Try to find by any available action
if (trackedNotifications.length > 0) {
let latestNotif = trackedNotifications[trackedNotifications.length - 1]
let action = latestNotif.actions.find(action => action.identifier === actionIdentifier)
if (action) {
console.log("Using latest notification for action")
action.invoke()
}
}
}
}
// Screen size breakpoints for responsive design
property real screenWidth: Screen.width
@@ -222,86 +162,6 @@ ShellRoot {
wallpaperErrorTimer.restart()
}
// Notification Server
NotificationServer {
id: notificationServer
actionsSupported: true
bodyMarkupSupported: true
imageSupported: true
keepOnReload: false
persistenceSupported: true
onNotification: (notification) => {
if (!notification || !notification.id) return
// Filter empty notifications
if (!notification.appName && !notification.summary && !notification.body) {
return
}
console.log("New notification from:", notification.appName || "Unknown", "Summary:", notification.summary || "No summary")
// CRITICAL: Mark notification as tracked so it stays in server list for actions
notification.tracked = true
// Create notification object with correct properties (based on EXAMPLE)
var notifObj = {
"id": notification.id,
"appName": notification.appName || "App",
"summary": notification.summary || "",
"body": notification.body || "",
"timestamp": new Date(),
"appIcon": notification.appIcon || notification.icon || "",
"icon": notification.icon || "",
"image": notification.image || "",
"actions": notification.actions ? notification.actions.map(action => ({
"identifier": action.identifier,
"text": action.text
})) : [],
"urgency": notification.urgency ? notification.urgency.toString() : "normal",
"notification": notification // Keep reference for action handling
}
// Add to grouped notifications
NotificationGroupingService.addNotification(notifObj)
// Also add to legacy flat history for backwards compatibility
notificationHistory.insert(0, notifObj)
// Keep only last 50 notifications in flat history
while (notificationHistory.count > 50) {
notificationHistory.remove(notificationHistory.count - 1)
}
// Show popup notification
root.activeNotification = notifObj
Utils.showNotificationPopup(notifObj)
}
}
// Notification History Model
ListModel {
id: notificationHistory
}
// Notification popup timer
Timer {
id: notificationTimer
interval: 5000
repeat: false
onTriggered: {
Utils.hideNotificationPopup()
}
}
Timer {
id: clearNotificationTimer
interval: 200
repeat: false
onTriggered: {
root.activeNotification = null
}
}
// Multi-monitor support using Variants
Variants {
@@ -323,7 +183,7 @@ ShellRoot {
bluetoothAvailable: root.bluetoothAvailable
bluetoothEnabled: root.bluetoothEnabled
shellRoot: root
notificationCount: NotificationGroupingService.totalCount
notificationCount: NotificationService.notifications.length
processDropdown: processListDropdown
// Connect tray menu properties
@@ -343,8 +203,13 @@ ShellRoot {
// Global popup windows
CenterCommandCenter {}
TrayMenuPopup {}
NotificationPopup {}
NotificationHistoryPopup {}
NotificationPopupNative {}
NotificationHistoryNative {
notificationHistoryVisible: root.notificationHistoryVisible
onCloseRequested: {
root.notificationHistoryVisible = false
}
}
ControlCenterPopup {}
WifiPasswordDialog {}
InputDialog {