diff --git a/Services/NotificationGroupingService.qml b/Services/NotificationGroupingService.qml index 3f48b325..aac5c0d9 100644 --- a/Services/NotificationGroupingService.qml +++ b/Services/NotificationGroupingService.qml @@ -6,8 +6,8 @@ pragma ComponentBehavior: Bound Singleton { id: root - // Grouped notifications model - property var groupedNotifications: ListModel {} + // Grouped notifications model - initialize as ListModel directly + property ListModel groupedNotifications: ListModel {} // Total count of all notifications across all groups property int totalCount: 0 @@ -15,16 +15,35 @@ Singleton { // 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 - Component.onCompleted: { - groupedNotifications = Qt.createQmlObject(` - import QtQuick - ListModel {} - `, root) - } + // 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) { @@ -57,6 +76,9 @@ Singleton { return } + // Enhance notification with priority and type detection + notificationObj = enhanceNotification(notificationObj) + const appName = notificationObj.appName let groupIndex = appGroupMap[appName] @@ -86,15 +108,36 @@ Singleton { notificationsList.append(notificationObj) - groupedNotifications.append({ + // 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": notificationObj, + "latestNotification": latestNotificationData, "expanded": false, - "timestamp": notificationObj.timestamp - }) + "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() @@ -115,34 +158,83 @@ Singleton { // 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", notificationObj) - groupedNotifications.setProperty(groupIndex, "timestamp", notificationObj.timestamp) + 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) } - // Move group to front (most recent activity) - moveGroupToFront(groupIndex) + // Re-sort groups by priority after updating + requestSort() } - // Move a group to the front of the list - function moveGroupToFront(groupIndex) { - if (groupIndex === 0) return // Already at front - - const group = groupedNotifications.get(groupIndex) - if (!group) return - - // Remove from current position - groupedNotifications.remove(groupIndex) - - // Insert at front - groupedNotifications.insert(0, group) - - // Update group map + // 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() } @@ -200,7 +292,26 @@ Singleton { // Update latest notification if we removed the latest one if (notificationIndex === 0 && group.notifications.count > 0) { const newLatest = group.notifications.get(0) - groupedNotifications.setProperty(groupIndex, "latestNotification", newLatest) + + // 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) } } @@ -210,12 +321,12 @@ Singleton { // 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() + updateGroupMap() // Re-map all group indices updateTotalCount() } } @@ -239,6 +350,131 @@ Singleton { 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++) { diff --git a/Tests/NotificationSystemDemo.qml b/Tests/NotificationSystemDemo.qml new file mode 100644 index 00000000..e421b70e --- /dev/null +++ b/Tests/NotificationSystemDemo.qml @@ -0,0 +1,441 @@ +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 new file mode 100755 index 00000000..4e5ccccc --- /dev/null +++ b/Tests/run_notification_demo.sh @@ -0,0 +1,38 @@ +#!/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 new file mode 100644 index 00000000..1ec646d4 --- /dev/null +++ b/Widgets/NotificationCompactGroup.qml @@ -0,0 +1,405 @@ +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/NotificationHistoryPopup.qml b/Widgets/NotificationHistoryPopup.qml index b2f8d438..a43893cf 100644 --- a/Widgets/NotificationHistoryPopup.qml +++ b/Widgets/NotificationHistoryPopup.qml @@ -48,7 +48,7 @@ PanelWindow { color: Theme.popupBackground() radius: Theme.cornerRadiusLarge border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) - border.width: 1 + border.width: 0.5 // TopBar dropdown animation - slide down from bar (consistent with other TopBar widgets) transform: [ @@ -225,46 +225,128 @@ PanelWindow { 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 + // Group Header with enhanced visual hierarchy Rectangle { width: parent.width - height: 56 + height: getGroupHeaderHeight() radius: Theme.cornerRadius - color: groupHeaderArea.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) + color: getGroupHeaderColor() - // App Icon + // 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: 32 - height: 32 - radius: width / 2 - color: Theme.primaryContainer + width: 4 + height: parent.height anchors.left: parent.left - anchors.leftMargin: Theme.spacingM + 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 - // Material icon fallback + // 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: "apps" + text: getDefaultIcon() font.family: Theme.iconFont - font.pixelSize: 16 + 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 + // App icon with priority-based sizing Loader { active: model.appIcon && model.appIcon !== "" anchors.centerIn: parent sourceComponent: IconImage { - width: 24 - height: 24 + width: groupPriority === NotificationGroupingService.priorityHigh ? 28 : 24 + height: width asynchronous: true source: { if (!model.appIcon) return "" @@ -277,14 +359,14 @@ PanelWindow { } } - // App Name and Summary + // App Name and Summary with enhanced layout Column { anchors.left: parent.left - anchors.leftMargin: Theme.spacingM + 32 + Theme.spacingM // Icon + spacing + anchors.leftMargin: Theme.spacingM + (groupPriority === NotificationGroupingService.priorityHigh ? 48 : 40) + Theme.spacingM anchors.right: parent.right - anchors.rightMargin: 80 // Space for buttons - anchors.verticalCenter: parent.verticalCenter - spacing: 2 + 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 @@ -292,20 +374,29 @@ PanelWindow { Text { text: model.appName || "App" - font.pixelSize: Theme.fontSizeMedium + font.pixelSize: groupPriority === NotificationGroupingService.priorityHigh ? Theme.fontSizeLarge : Theme.fontSizeMedium color: Theme.surfaceText - font.weight: Font.Medium + font.weight: groupPriority === NotificationGroupingService.priorityHigh ? Font.DemiBold : Font.Medium } - // Notification count badge + // Enhanced notification count badge Rectangle { width: Math.max(countText.width + 8, 20) height: 20 radius: 10 - color: Theme.primary + 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 @@ -317,29 +408,67 @@ PanelWindow { } } + // Latest message summary (title) Text { - text: model.latestNotification ? - (model.latestNotification.summary || model.latestNotification.body || "") : "" - font.pixelSize: Theme.fontSizeSmall - color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + 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 } - // Expand/Collapse Icon + // Enhanced Expand/Collapse Icon - moved up more for better spacing Rectangle { id: expandCollapseButton - width: 32 + width: model.totalCount > 1 ? 32 : 0 height: 32 radius: 16 anchors.right: parent.right - anchors.rightMargin: 40 // More space from close button - anchors.verticalCenter: parent.verticalCenter + 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 @@ -348,11 +477,8 @@ PanelWindow { font.pixelSize: 20 color: expandButtonArea.containsMouse ? Theme.primary : Theme.surfaceText - Behavior on rotation { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing - } + Behavior on text { + enabled: false // Disable animation on text change to prevent flicker } } @@ -361,6 +487,7 @@ PanelWindow { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + enabled: model.totalCount > 1 onClicked: { NotificationGroupingService.toggleGroupExpansion(index) @@ -416,14 +543,16 @@ PanelWindow { MouseArea { id: groupHeaderArea anchors.fill: parent - anchors.rightMargin: 76 // Exclude both expand and close button areas + anchors.rightMargin: 32 // Adjusted for maximum content width hoverEnabled: true - cursorShape: Qt.PointingHandCursor + cursorShape: model.totalCount > 1 ? Qt.PointingHandCursor : Qt.ArrowCursor preventStealing: false propagateComposedEvents: true onClicked: { - NotificationGroupingService.toggleGroupExpansion(index) + if (model.totalCount > 1) { + NotificationGroupingService.toggleGroupExpansion(index) + } } } @@ -435,16 +564,75 @@ PanelWindow { } } - // Expanded Notifications List + // 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 : 0 + height: isExpanded ? expandedContent.height + Theme.spacingS : 0 clip: true + // Enhanced staggered animation Behavior on height { - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.emphasizedEasing + SequentialAnimation { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } } } @@ -453,10 +641,13 @@ PanelWindow { 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.shortDuration + duration: Theme.mediumDuration easing.type: Theme.standardEasing } } @@ -465,6 +656,8 @@ PanelWindow { 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 @@ -472,6 +665,37 @@ PanelWindow { 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 @@ -508,6 +732,7 @@ PanelWindow { Row { anchors.fill: parent anchors.margins: Theme.spacingM + anchors.leftMargin: Theme.spacingM + 8 // Extra space for border anchors.rightMargin: 36 spacing: Theme.spacingM diff --git a/Widgets/NotificationPopup.qml b/Widgets/NotificationPopup.qml index 582cae34..e9d57ef2 100644 --- a/Widgets/NotificationPopup.qml +++ b/Widgets/NotificationPopup.qml @@ -242,7 +242,7 @@ PanelWindow { } } - // Small dismiss button - bottom right corner + // Small dismiss button - bottom right corner with better positioning Rectangle { width: 60 height: 18 @@ -257,7 +257,7 @@ PanelWindow { anchors.right: parent.right anchors.bottom: parent.bottom anchors.rightMargin: 12 - anchors.bottomMargin: 10 + anchors.bottomMargin: 14 // Moved up for better padding Row { anchors.centerIn: parent @@ -312,7 +312,7 @@ PanelWindow { anchors.fill: parent anchors.margins: 12 anchors.rightMargin: 32 - anchors.bottomMargin: 6 // Reduced bottom margin to account for dismiss button + anchors.bottomMargin: 24 // Even more space to ensure 2px+ margin above dismiss button spacing: 12 // Notification icon based on EXAMPLE NotificationAppIcon pattern @@ -426,17 +426,52 @@ PanelWindow { // Text content Column { width: parent.width - 68 - anchors.verticalCenter: parent.verticalCenter - spacing: 4 + anchors.top: parent.top + anchors.topMargin: 4 // Move content up slightly + spacing: 3 - Text { - text: root.activeNotification ? (root.activeNotification.summary || "") : "" - font.pixelSize: 14 - color: Theme.surfaceText - font.weight: Font.Medium + // Title and timestamp row + Row { width: parent.width - elide: Text.ElideRight - visible: text.length > 0 + 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 { diff --git a/Widgets/TopBar/SystemTrayWidget.qml b/Widgets/TopBar/SystemTrayWidget.qml index 330f1fff..9cee9e04 100644 --- a/Widgets/TopBar/SystemTrayWidget.qml +++ b/Widgets/TopBar/SystemTrayWidget.qml @@ -33,15 +33,16 @@ Rectangle { width: 18 height: 18 source: { - let icon = trayItem?.icon || ""; - if (!icon) return ""; - - if (icon.includes("?path=")) { - const [name, path] = icon.split("?path="); - const fileName = name.substring(name.lastIndexOf("/") + 1); - return `file://${path}/${fileName}`; + let icon = trayItem?.icon; + if (typeof icon === 'string' || icon instanceof String) { + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + const fileName = name.substring(name.lastIndexOf("/") + 1); + return `file://${path}/${fileName}`; + } + return icon; } - return icon; + return ""; // Return empty string if icon is not a string } asynchronous: true smooth: true diff --git a/Widgets/qmldir b/Widgets/qmldir index ccbac49f..6af260f5 100644 --- a/Widgets/qmldir +++ b/Widgets/qmldir @@ -2,6 +2,7 @@ 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 WifiPasswordDialog 1.0 WifiPasswordDialog.qml AppLauncher 1.0 AppLauncher.qml ClipboardHistory 1.0 ClipboardHistory.qml