From 4ea04f57b4040193f60305c2c7d1fb8536ebec64 Mon Sep 17 00:00:00 2001 From: purian23 Date: Tue, 15 Jul 2025 16:41:34 -0400 Subject: [PATCH] Migrate notification system to native Quickshell NotificationServer API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Services/NotificationGroupingService.qml | 530 ------------- Services/NotificationService.qml | 184 +++++ Services/qmldir | 2 +- Tests/NotificationNativeDemo.qml | 97 +++ Tests/NotificationSystemDemo.qml | 441 ----------- Tests/run_notification_demo.sh | 38 - Widgets/NotificationCompactGroup.qml | 405 ---------- Widgets/NotificationHistoryNative.qml | 309 ++++++++ Widgets/NotificationHistoryPopup.qml | 949 ----------------------- Widgets/NotificationItem.qml | 350 +++++++++ Widgets/NotificationPopup.qml | 490 ------------ Widgets/NotificationPopupNative.qml | 104 +++ Widgets/TopBar/TopBar.qml | 20 - Widgets/qmldir | 6 +- shell.qml | 151 +--- 15 files changed, 1056 insertions(+), 3020 deletions(-) delete mode 100644 Services/NotificationGroupingService.qml create mode 100644 Services/NotificationService.qml create mode 100644 Tests/NotificationNativeDemo.qml delete mode 100644 Tests/NotificationSystemDemo.qml delete mode 100755 Tests/run_notification_demo.sh delete mode 100644 Widgets/NotificationCompactGroup.qml create mode 100644 Widgets/NotificationHistoryNative.qml delete mode 100644 Widgets/NotificationHistoryPopup.qml create mode 100644 Widgets/NotificationItem.qml delete mode 100644 Widgets/NotificationPopup.qml create mode 100644 Widgets/NotificationPopupNative.qml diff --git a/Services/NotificationGroupingService.qml b/Services/NotificationGroupingService.qml deleted file mode 100644 index aac5c0d9..00000000 --- a/Services/NotificationGroupingService.qml +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml new file mode 100644 index 00000000..b79e646a --- /dev/null +++ b/Services/NotificationService.qml @@ -0,0 +1,184 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Notifications + +Singleton { + id: root + + readonly property list notifications: [] + readonly property list 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 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); + } +} \ No newline at end of file diff --git a/Services/qmldir b/Services/qmldir index 39d8eccd..46b0e084 100644 --- a/Services/qmldir +++ b/Services/qmldir @@ -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 diff --git a/Tests/NotificationNativeDemo.qml b/Tests/NotificationNativeDemo.qml new file mode 100644 index 00000000..1f8d763a --- /dev/null +++ b/Tests/NotificationNativeDemo.qml @@ -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 + } +} \ No newline at end of file diff --git a/Tests/NotificationSystemDemo.qml b/Tests/NotificationSystemDemo.qml deleted file mode 100644 index e421b70e..00000000 --- a/Tests/NotificationSystemDemo.qml +++ /dev/null @@ -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) - }) - } -} \ No newline at end of file diff --git a/Tests/run_notification_demo.sh b/Tests/run_notification_demo.sh deleted file mode 100755 index 4e5ccccc..00000000 --- a/Tests/run_notification_demo.sh +++ /dev/null @@ -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." \ No newline at end of file diff --git a/Widgets/NotificationCompactGroup.qml b/Widgets/NotificationCompactGroup.qml deleted file mode 100644 index 1ec646d4..00000000 --- a/Widgets/NotificationCompactGroup.qml +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/Widgets/NotificationHistoryNative.qml b/Widgets/NotificationHistoryNative.qml new file mode 100644 index 00000000..4ab93707 --- /dev/null +++ b/Widgets/NotificationHistoryNative.qml @@ -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 + } +} \ No newline at end of file diff --git a/Widgets/NotificationHistoryPopup.qml b/Widgets/NotificationHistoryPopup.qml deleted file mode 100644 index a43893cf..00000000 --- a/Widgets/NotificationHistoryPopup.qml +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/Widgets/NotificationItem.qml b/Widgets/NotificationItem.qml new file mode 100644 index 00000000..edaf6c9b --- /dev/null +++ b/Widgets/NotificationItem.qml @@ -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 + } + } +} \ No newline at end of file diff --git a/Widgets/NotificationPopup.qml b/Widgets/NotificationPopup.qml deleted file mode 100644 index e9d57ef2..00000000 --- a/Widgets/NotificationPopup.qml +++ /dev/null @@ -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 - } - } - } - } -} \ No newline at end of file diff --git a/Widgets/NotificationPopupNative.qml b/Widgets/NotificationPopupNative.qml new file mode 100644 index 00000000..380964ab --- /dev/null +++ b/Widgets/NotificationPopupNative.qml @@ -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 + } + } +} \ No newline at end of file diff --git a/Widgets/TopBar/TopBar.qml b/Widgets/TopBar/TopBar.qml index b70b4652..2413579b 100644 --- a/Widgets/TopBar/TopBar.qml +++ b/Widgets/TopBar/TopBar.qml @@ -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() diff --git a/Widgets/qmldir b/Widgets/qmldir index 6af260f5..f6596023 100644 --- a/Widgets/qmldir +++ b/Widgets/qmldir @@ -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 diff --git a/shell.qml b/shell.qml index d48ffdf2..d17e2a9e 100644 --- a/shell.qml +++ b/shell.qml @@ -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 {