From 5d84e6e47dd1b08cfa8d76d985eb0e3e80f71425 Mon Sep 17 00:00:00 2001 From: Devin Lin Date: Fri, 26 Jul 2024 22:29:38 -0400 Subject: [PATCH] widgets/notifications: Cleanup, fix some visual issues, and add jobs Note: There was a lot of whitespace that my IDE is now removing. Fixes job notifications not being dismissable (#208), and imports an implementation from workspace for the progress bar and actions. Fixes notification contents not being clipped when being dragged (https://invent.kde.org/teams/plasma-mobile/issues/-/issues/287) Also fixes notification text being spread over multiple lines unnecessarily (https://invent.kde.org/teams/plasma-mobile/issues/-/issues/302). --- .../notifications/BaseNotificationItem.qml | 58 ++-- .../notifications/NotificationBodyLabel.qml | 6 +- .../notifications/NotificationCard.qml | 52 ++-- .../NotificationEditContextMenu.qml | 66 +++++ .../notifications/NotificationGroupHeader.qml | 12 +- .../notifications/NotificationItem.qml | 73 +++-- .../notifications/NotificationJobDetails.qml | 148 ++++++++++ .../notifications/NotificationJobItem.qml | 277 ++++++++++++++++++ .../notifications/NotificationsWidget.qml | 127 ++++---- 9 files changed, 659 insertions(+), 160 deletions(-) create mode 100644 components/mobileshell/qml/widgets/notifications/NotificationEditContextMenu.qml create mode 100644 components/mobileshell/qml/widgets/notifications/NotificationJobDetails.qml create mode 100644 components/mobileshell/qml/widgets/notifications/NotificationJobItem.qml diff --git a/components/mobileshell/qml/widgets/notifications/BaseNotificationItem.qml b/components/mobileshell/qml/widgets/notifications/BaseNotificationItem.qml index 60b7b7e3..052f97da 100644 --- a/components/mobileshell/qml/widgets/notifications/BaseNotificationItem.qml +++ b/components/mobileshell/qml/widgets/notifications/BaseNotificationItem.qml @@ -1,7 +1,7 @@ /* * SPDX-FileCopyrightText: 2021 Devin Lin * SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik - * + * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -18,25 +18,25 @@ import org.kde.coreaddons 1.0 as KCoreAddons Item { id: notificationItem - + required property var notificationsModel - + required property int notificationsModelType - + /** * Whether the notification is allowed to invoke any action, or if it should instead * emit the runActionRequested(action) signal, containing the code to run. - * + * * This is useful for cases like the lockscreen, where actions should only be run after * the user logs in. */ property bool requestToInvoke: false - + property var model property int modelIndex - + property P5Support.DataSource timeSource - + readonly property int notificationType: model.type readonly property bool inGroup: model.isInGroup || false @@ -54,7 +54,7 @@ Item { readonly property string replyPlaceholderText: model.replyPlaceholderText || "" readonly property string replySubmitButtonText: model.replySubmitButtonText || "" readonly property string replySubmitButtonIconName: model.replySubmitButtonIconName || "" - + // configure button on every single notifications is a bit overwhelming readonly property bool configurable: !inGroup && model.configurable @@ -75,7 +75,7 @@ Item { readonly property int jobError: model.jobError || 0 readonly property bool suspendable: !!model.suspendable readonly property bool killable: !!model.killable - + readonly property QtObject jobDetails: model.jobDetails || null readonly property string configureActionLabel: model.configureActionLabel || "" @@ -97,13 +97,13 @@ Item { } return labels; } - + /** * This signal is emitted and intended for the parent to make its own decision * on whether to run the requested notification action. */ signal runActionRequested() - + signal actionInvoked(string actionName) signal replied(string text) signal openUrl(string url) @@ -112,7 +112,7 @@ Item { signal suspendJobClicked signal resumeJobClicked signal killJobClicked - + function expire() { if (model.resident) { model.expired = true; @@ -132,17 +132,17 @@ Item { notificationsModel.close(notificationsModel.index(modelIndex, 0)); } } - + // TODO call function configure() { notificationsModel.configure(notificationsModel.index(modelIndex, 0)) } - + property var pendingAction: () => {} function runPendingAction() { pendingAction(); } - + onActionInvoked: { let action = () => { if (notificationsModelType === NotificationsModelType.WatchedNotificationsModel) { @@ -153,14 +153,14 @@ Item { } } else if (notificationsModelType === NotificationsModelType.NotificationsModel) { if (actionName === "default") { - notificationsModel.invokeDefaultAction(notificationsModel.index(modelIndex, 0), NotificationManager.Close); // notification closes + notificationsModel.invokeDefaultAction(notificationsModel.index(modelIndex, 0), NotificationManager.Close); // notification closes } else { - notificationsModel.invokeAction(notificationsModel.index(modelIndex, 0), actionName, NotificationManager.Close); // notification closes + notificationsModel.invokeAction(notificationsModel.index(modelIndex, 0), actionName, NotificationManager.Close); // notification closes } } expire(); } - + if (notificationItem.requestToInvoke) { pendingAction = action; runActionRequested(); @@ -168,13 +168,13 @@ Item { action(); } } - + onOpenUrl: { let action = () => { Qt.openUrlExternally(url); expire(); } - + if (notificationItem.requestToInvoke) { pendingAction = action; runActionRequested(); @@ -182,7 +182,7 @@ Item { action(); } } - + onFileActionInvoked: { let action = () => { if (action.objectName === "movetotrash" || action.objectName === "deletefile") { @@ -191,7 +191,7 @@ Item { expire(); } } - + if (notificationItem.requestToInvoke) { pendingAction = action; runActionRequested(); @@ -199,10 +199,10 @@ Item { action(); } } - + onSuspendJobClicked: { let action = () => notificationsModel.suspendJob(notificationsModel.index(modelIndex, 0)); - + if (notificationItem.requestToInvoke) { pendingAction = action; runActionRequested(); @@ -210,10 +210,10 @@ Item { action(); } } - + onResumeJobClicked: { let action = () => notificationsModel.resumeJob(notificationsModel.index(modelIndex, 0)); - + if (notificationItem.requestToInvoke) { pendingAction = action; runActionRequested(); @@ -221,10 +221,10 @@ Item { action(); } } - + onKillJobClicked: { let action = () => notificationsModel.killJob(notificationsModel.index(modelIndex, 0)); - + if (notificationItem.requestToInvoke) { pendingAction = action; runActionRequested(); diff --git a/components/mobileshell/qml/widgets/notifications/NotificationBodyLabel.qml b/components/mobileshell/qml/widgets/notifications/NotificationBodyLabel.qml index 20f141dc..bc8bbad3 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationBodyLabel.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationBodyLabel.qml @@ -2,12 +2,12 @@ * SPDX-FileCopyrightText: 2021 Devin Lin * SPDX-FileCopyrightText: 2011 Marco Martin * SPDX-FileCopyrightText: 2014, 2019 Kai Uwe Broulik - * + * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ -import QtQuick 2.15 -import QtQuick.Window 2.2 +import QtQuick +import QtQuick.Window import org.kde.plasma.components 3.0 as PlasmaComponents diff --git a/components/mobileshell/qml/widgets/notifications/NotificationCard.qml b/components/mobileshell/qml/widgets/notifications/NotificationCard.qml index 141be0dd..bbaf4ff5 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationCard.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationCard.qml @@ -10,52 +10,54 @@ import org.kde.plasma.components 3.0 as PlasmaComponents Item { id: root - + default property Item contentItem - + property bool tapEnabled: false - + property bool swipeGestureEnabled: false - + property real dragOffset: 0 - + signal tapped() signal dismissRequested() signal configureClicked() // TODO implement settings button - + onContentItemChanged: { contentItem.parent = contentParent; contentItem.anchors.fill = contentParent; contentItem.anchors.margins = Kirigami.Units.largeSpacing; contentParent.children.push(contentItem); } - + implicitHeight: contentParent.implicitHeight - + NumberAnimation on dragOffset { id: dragAnim duration: Kirigami.Units.longDuration + easing.type: Easing.OutCubic onFinished: { if (to !== 0) { root.dismissRequested(); } } } - + // shadow MultiEffect { anchors.fill: mainCard visible: Math.abs(dragOffset) !== root.width - source: mainCard + source: simpleShadow blurMax: 16 shadowEnabled: true shadowVerticalOffset: 1 - shadowOpacity: 0.5 - shadowColor: Qt.lighter(Kirigami.Theme.backgroundColor, 0.1) + shadowOpacity: 0.3 + shadowColor: Qt.lighter(Kirigami.Theme.backgroundColor, 0.2) } // shadow Rectangle { + id: simpleShadow visible: Math.abs(dragOffset) !== root.width anchors.fill: mainCard anchors.leftMargin: -1 @@ -74,12 +76,14 @@ Item { anchors.right: parent.right anchors.rightMargin: root.dragOffset < 0 ? -root.dragOffset : 0 anchors.top: parent.top - + color: (root.tapEnabled && mouseArea.pressed) ? Qt.darker(Kirigami.Theme.backgroundColor, 1.1) : Kirigami.Theme.backgroundColor - radius: Kirigami.Units.smallSpacing + radius: Kirigami.Units.smallSpacing implicitHeight: contentParent.implicitHeight - clip: true - + + // clip + layer.enabled: true + // ensure this is behind the content to not interfere MouseArea { id: mouseArea @@ -90,28 +94,28 @@ Item { } } } - + // content parent Item { id: contentParent anchors.top: parent.top anchors.left: root.dragOffset > 0 ? parent.left : undefined anchors.right: root.dragOffset < 0 ? parent.right : undefined - + width: root.width - implicitHeight: contentItem.implicitHeight + contentItem.anchors.topMargin + contentItem.anchors.bottomMargin + implicitHeight: contentItem.implicitHeight + contentItem.anchors.topMargin + contentItem.anchors.bottomMargin } } - + DragHandler { id: dragHandler enabled: root.swipeGestureEnabled yAxis.enabled: false - + property real startDragOffset: 0 property real startPosition: 0 property bool startActive: false - + onTranslationChanged: { if (startActive) { startDragOffset = root.dragOffset; @@ -120,11 +124,11 @@ Item { } root.dragOffset = startDragOffset + (translation.x - startPosition); } - + onActiveChanged: { dragAnim.stop(); startActive = active; - + if (!active) { // release event let threshold = Kirigami.Units.gridUnit * 5; // drag threshold if (root.dragOffset > threshold) { diff --git a/components/mobileshell/qml/widgets/notifications/NotificationEditContextMenu.qml b/components/mobileshell/qml/widgets/notifications/NotificationEditContextMenu.qml new file mode 100644 index 00000000..53765bbb --- /dev/null +++ b/components/mobileshell/qml/widgets/notifications/NotificationEditContextMenu.qml @@ -0,0 +1,66 @@ +/* + SPDX-FileCopyrightText: 2019 Kai Uwe Broulik + + SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL +*/ + +import QtQuick 2.8 + +import org.kde.plasma.extras 2.0 as PlasmaExtras + +import org.kde.kquickcontrolsaddons 2.0 as KQCAddons + +PlasmaExtras.Menu { + id: contextMenu + + signal closed + + property QtObject __clipboard: KQCAddons.Clipboard { } + + // can be a Text or TextEdit + property Item target + + property string link + + onStatusChanged: { + if (status === PlasmaExtras.Menu.Closed) { + closed(); + } + } + + PlasmaExtras.MenuItem { + text: i18ndc("plasma_applet_org.kde.plasma.notifications", "@action:inmenu", "Copy Link Address") + icon: "edit-copy-symbolic" + onClicked: __clipboard.content = contextMenu.link + visible: contextMenu.link !== "" + } + + PlasmaExtras.MenuItem { + separator: true + visible: contextMenu.link !== "" + } + + PlasmaExtras.MenuItem { + text: i18ndc("plasma_applet_org.kde.plasma.notifications", "@action:inmenu", "Copy") + icon: "edit-copy-symbolic" + enabled: typeof target.selectionStart !== "undefined" + ? target.selectionStart !== target.selectionEnd + : (target.text || "").length > 0 + onClicked: { + if (typeof target.copy === "function") { + target.copy(); + } else { + __clipboard.content = target.text; + } + } + } + + PlasmaExtras.MenuItem { + id: selectAllAction + icon: "edit-select-all-symbolic" + text: i18ndc("plasma_applet_org.kde.plasma.notifications", "@action:inmenu", "Select All") + onClicked: target.selectAll() + visible: typeof target.selectAll === "function" + } +} + diff --git a/components/mobileshell/qml/widgets/notifications/NotificationGroupHeader.qml b/components/mobileshell/qml/widgets/notifications/NotificationGroupHeader.qml index 75b7799d..4d10aa40 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationGroupHeader.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationGroupHeader.qml @@ -1,7 +1,7 @@ /* * SPDX-FileCopyrightText: 2021 Devin Lin * SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik - * + * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -20,21 +20,11 @@ import org.kde.coreaddons 1.0 as KCoreAddons RowLayout { id: notificationHeading - property int notificationType property var applicationIconSource property string applicationName property string originName - property var time - property P5Support.DataSource timeSource - - property int jobState - property QtObject jobDetails - - property real timeout: 5000 - property real remainingTime: 0 - spacing: Kirigami.Units.smallSpacing Layout.preferredHeight: Math.max(applicationNameLabel.implicitHeight, Kirigami.Units.iconSizes.small) diff --git a/components/mobileshell/qml/widgets/notifications/NotificationItem.qml b/components/mobileshell/qml/widgets/notifications/NotificationItem.qml index 06c058b1..6af06737 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationItem.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationItem.qml @@ -1,7 +1,7 @@ /* * SPDX-FileCopyrightText: 2021 Devin Lin * SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik - * + * * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -23,7 +23,7 @@ import org.kde.coreaddons 1.0 as KCoreAddons BaseNotificationItem { id: notificationItem implicitHeight: mainCard.implicitHeight + mainCard.anchors.topMargin + notificationHeading.height - + // notification heading for groups with one element NotificationGroupHeader { id: notificationHeading @@ -40,15 +40,8 @@ BaseNotificationItem { applicationName: notificationItem.applicationName applicationIconSource: notificationItem.applicationIconSource originName: notificationItem.originName - - notificationType: notificationItem.notificationType - jobState: notificationItem.jobState - jobDetails: notificationItem.jobDetails - - time: notificationItem.time - timeSource: notificationItem.timeSource } - + // notification NotificationCard { id: mainCard @@ -56,21 +49,21 @@ BaseNotificationItem { anchors.top: notificationHeading.bottom anchors.left: parent.left anchors.right: parent.right - + tapEnabled: notificationItem.hasDefaultAction onTapped: notificationItem.actionInvoked("default"); - swipeGestureEnabled: notificationItem.notificationType != NotificationManager.Notifications.JobType + swipeGestureEnabled: notificationItem.closable onDismissRequested: notificationItem.close() - + ColumnLayout { id: column spacing: 0 - + // notification summary row RowLayout { Layout.fillWidth: true Layout.bottomMargin: Kirigami.Units.smallSpacing - + // notification summary PlasmaComponents.Label { id: summaryLabel @@ -83,19 +76,19 @@ BaseNotificationItem { visible: text !== "" font.weight: Font.DemiBold } - + // notification timestamp NotificationTimeText { Layout.alignment: Qt.AlignRight | Qt.AlignTop notificationType: notificationItem.notificationType jobState: notificationItem.jobState jobDetails: notificationItem.jobDetails - + time: notificationItem.time timeSource: notificationItem.timeSource } } - + // notification contents RowLayout { Layout.fillWidth: true @@ -105,17 +98,11 @@ BaseNotificationItem { NotificationBodyLabel { id: bodyLabel Layout.alignment: Qt.AlignTop | Qt.AlignLeft - Layout.fillWidth: true - - // HACK RichText does not allow to specify link color and since LineEdit - // does not support StyledText, we have to inject some CSS to force the color, - // cf. QTBUG-81463 and to some extent QTBUG-80354 - text: "" + notificationItem.body + Layout.preferredWidth: column.width - iconContainer.width - Kirigami.Units.smallSpacing - // Cannot do text !== "" because RichText adds some HTML tags even when empty - visible: notificationItem.body !== "" + text: notificationItem.body } - + // notification icon Item { id: iconContainer @@ -138,13 +125,41 @@ BaseNotificationItem { } } } - + + // Job progress reporting + Loader { + id: jobLoader + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: notificationItem.notificationType === NotificationManager.Notifications.JobType + visible: active + sourceComponent: NotificationJobItem { + iconContainerItem: iconContainer + + jobState: notificationItem.jobState + jobError: notificationItem.jobError + percentage: notificationItem.percentage + suspendable: notificationItem.suspendable + killable: notificationItem.killable + + jobDetails: notificationItem.jobDetails + + onSuspendJobClicked: notificationItem.suspendJobClicked() + onResumeJobClicked: notificationItem.resumeJobClicked() + onKillJobClicked: notificationItem.killJobClicked() + + onOpenUrl: notificationItem.openUrl(url) + onFileActionInvoked: notificationItem.fileActionInvoked(action) + } + } + // notification actions NotificationFooterActions { Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.smallSpacing notification: notificationItem } - + // thumbnails Loader { id: thumbnailStripLoader diff --git a/components/mobileshell/qml/widgets/notifications/NotificationJobDetails.qml b/components/mobileshell/qml/widgets/notifications/NotificationJobDetails.qml new file mode 100644 index 00000000..89a96d4b --- /dev/null +++ b/components/mobileshell/qml/widgets/notifications/NotificationJobDetails.qml @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2019 Kai Uwe Broulik +// SPDX-FileCopyrightText: 2024 Devin Lin +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick 2.8 +import QtQuick.Layouts 1.1 + +import org.kde.plasma.extras 2.0 as PlasmaExtras +import org.kde.kirigami 2.20 as Kirigami + +import org.kde.coreaddons 1.0 as KCoreAddons + +GridLayout { + id: detailsGrid + + property QtObject jobDetails + + columns: 2 + rowSpacing: Math.round(Kirigami.Units.smallSpacing / 2) + columnSpacing: Kirigami.Units.smallSpacing + + // once you use Layout.column/Layout.row *all* of the items in the Layout have to use them + Repeater { + model: [1, 2] + + PlasmaExtras.DescriptiveLabel { + Layout.column: 0 + Layout.row: index + Layout.alignment: Qt.AlignTop | Qt.AlignRight + text: jobDetails["descriptionLabel" + modelData] && jobDetails["descriptionValue" + modelData] + ? i18ndc("plasma_applet_org.kde.plasma.notifications", "Row description, e.g. Source", "%1:", jobDetails["descriptionLabel" + modelData]) : "" + font: Kirigami.Theme.smallFont + textFormat: Text.PlainText + visible: text !== "" + } + } + + Repeater { + model: [1, 2] + + PlasmaExtras.DescriptiveLabel { + id: descriptionValueLabel + Layout.column: 1 + Layout.row: index + Layout.fillWidth: true + font: Kirigami.Theme.smallFont + elide: Text.ElideMiddle + textFormat: Text.PlainText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + verticalAlignment: Text.AlignTop + maximumLineCount: 5 + visible: text !== "" + + // Only let the label grow, never shrink, to avoid repeatedly resizing the dialog when copying many files + onImplicitHeightChanged: { + if (implicitHeight > Layout.preferredHeight) { + Layout.preferredHeight = implicitHeight; + } + } + + Component.onCompleted: bindText() + function bindText() { + text = Qt.binding(function() { + return jobDetails["descriptionValue" + modelData] || ""; + }); + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onPressed: mouse => { + // break binding so it doesn't update while the menu is opened + descriptionValueLabel.text = descriptionValueLabel.text; + descriptionValueMenu.open(mouse.x, mouse.y) + } + } + + NotificationEditContextMenu { + id: descriptionValueMenu + target: descriptionValueLabel + // defer re-binding until after the "Copy" action in the menu has triggered + onClosed: Qt.callLater(descriptionValueLabel.bindText) + } + } + } + + Repeater { + model: ["Bytes", "Files", "Directories", "Items"] + + PlasmaExtras.DescriptiveLabel { + Layout.column: 1 + Layout.row: 2 + index + Layout.fillWidth: true + text: { + var processed = jobDetails["processed" + modelData]; + var total = jobDetails["total" + modelData]; + + if (processed > 0 || total > 1) { + if (processed > 0 && total > 0 && processed <= total) { + switch(modelData) { + case "Bytes": + return i18ndc("plasma_applet_org.kde.plasma.notifications", "How many bytes have been copied", "%2 of %1", + KCoreAddons.Format.formatByteSize(total), + KCoreAddons.Format.formatByteSize(processed)) + case "Files": + return i18ndcp("plasma_applet_org.kde.plasma.notifications", "How many files have been copied", "%2 of %1 file", "%2 of %1 files", + total, processed); + case "Directories": + return i18ndcp("plasma_applet_org.kde.plasma.notifications", "How many dirs have been copied", "%2 of %1 folder", "%2 of %1 folders", + total, processed); + case "Items": + return i18ndcp("plasma_applet_org.kde.plasma.notifications", "How many items (that includes files and dirs) have been copied", "%2 of %1 item", "%2 of %1 items", + total, processed); + } + } else { + switch(modelData) { + case "Bytes": + return KCoreAddons.Format.formatByteSize(processed || total) + case "Files": + return i18ndp("plasma_applet_org.kde.plasma.notifications", "%1 file", "%1 files", (processed || total)); + case "Directories": + return i18ndp("plasma_applet_org.kde.plasma.notifications", "%1 folder", "%1 folders", (processed || total)); + case "Items": + return i18ndp("plasma_applet_org.kde.plasma.notifications", "%1 item", "%1 items", (processed || total)); + } + } + } + + return ""; + } + font: Kirigami.Theme.smallFont + textFormat: Text.PlainText + visible: text !== "" + } + } + + PlasmaExtras.DescriptiveLabel { + Layout.column: 1 + Layout.row: 2 + 4 + Layout.fillWidth: true + text: jobDetails.speed > 0 ? i18ndc("plasma_applet_org.kde.plasma.notifications", "Bytes per second", "%1/s", + KCoreAddons.Format.formatByteSize(jobDetails.speed)) : "" + font: Kirigami.Theme.smallFont + textFormat: Text.PlainText + visible: text !== "" + } +} + diff --git a/components/mobileshell/qml/widgets/notifications/NotificationJobItem.qml b/components/mobileshell/qml/widgets/notifications/NotificationJobItem.qml new file mode 100644 index 00000000..61647702 --- /dev/null +++ b/components/mobileshell/qml/widgets/notifications/NotificationJobItem.qml @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: 2019 Kai Uwe Broulik +// SPDX-FileCopyrightText: 2024 Devin Lin +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick 2.8 +import QtQuick.Window 2.2 +import QtQuick.Layouts 1.1 +import QtQml 2.15 + +import org.kde.plasma.components 3.0 as PlasmaComponents3 +import org.kde.kirigami 2.20 as Kirigami + +import org.kde.notificationmanager as NotificationManager + +import org.kde.plasma.private.notifications 2.0 as Notifications + +ColumnLayout { + id: jobItem + + property int jobState + property int jobError + + property alias percentage: progressBar.value + property alias suspendable: suspendButton.visible + property alias killable: killButton.visible + + property QtObject jobDetails + + readonly property int totalFiles: jobItem.jobDetails && jobItem.jobDetails.totalFiles || 0 + readonly property var url: { + if (jobItem.jobState !== NotificationManager.Notifications.JobStateStopped + || jobItem.jobError) { + return null; + } + + // For a single file show actions for it + // Otherwise the destination folder all of them were copied into + const url = totalFiles === 1 ? jobItem.jobDetails.descriptionUrl + : jobItem.jobDetails.destUrl; + + // Don't offer opening files in Trash + if (url && url.toString().startsWith("trash:")) { + return null; + } + + return url; + } + + property alias iconContainerItem: busyIndicatorContainer.parent + + readonly property alias menuOpen: otherFileActionsMenu.visible + + signal suspendJobClicked + signal resumeJobClicked + signal killJobClicked + + signal openUrl(string url) + signal fileActionInvoked(QtObject action) + + spacing: Kirigami.Units.smallSpacing + + Notifications.FileInfo { + id: fileInfo + url: jobItem.totalFiles === 1 && jobItem.url ? jobItem.url : "" + } + + Item { + id: busyIndicatorContainer + width: parent ? parent.width : 0 + height: parent ? parent.height : 0 + + PlasmaComponents3.BusyIndicator { + id: busyIndicator + anchors.centerIn: parent + running: fileInfo.busy && !delayBusyTimer.running + visible: running + + // Avoid briefly flashing the busy indicator + Timer { + id: delayBusyTimer + interval: 500 + repeat: false + running: fileInfo.busy + } + } + } + + RowLayout { + id: progressRow + Layout.fillWidth: true + // Even when indeterminate, we want to reserve the height for the text, otherwise it's too tightly spaced + Layout.minimumHeight: progressText.implicitHeight + // We want largeSpacing between the progress bar and the label + spacing: Kirigami.Units.largeSpacing + + PlasmaComponents3.ProgressBar { + id: progressBar + Layout.fillWidth: true + from: 0 + to: 100 + // TODO do we actually need the window visible check? perhaps I do because it can be in popup or expanded plasmoid + indeterminate: visible && Window.window && Window.window.visible && percentage < 1 + && jobItem.jobState === NotificationManager.Notifications.JobStateRunning + // is this too annoying? + && (jobItem.jobDetails.processedBytes === 0 || jobItem.jobDetails.totalBytes === 0) + && jobItem.jobDetails.processedFiles === 0 + //&& jobItem.jobDetails.processedDirectories === 0 + } + + PlasmaComponents3.Label { + id: progressText + + visible: !progressBar.indeterminate + // the || "0" is a workaround for the fact that 0 as number is falsey, and is wrongly considered a missing argument + // BUG: 451807 + text: i18ndc("plasma_applet_org.kde.plasma.notifications", "Percentage of a job", "%1%", jobItem.percentage || "0") + textFormat: Text.PlainText + } + } + + RowLayout { + id: jobActionsRow + Layout.fillWidth: true + + spacing: Kirigami.Units.smallSpacing + + PlasmaComponents3.Button { + id: expandButton + + icon.name: checked ? "collapse-symbolic" : "expand-symbolic" + text: i18ndc("plasma_applet_org.kde.plasma.notifications", "Hides/expands item details", "Details") + checkable: true + enabled: jobItem.jobDetails && jobItem.jobDetails.hasDetails + } + + Item { Layout.fillWidth: true } + + PlasmaComponents3.Button { + id: suspendButton + + icon.name: "media-playback-pause-symbolic" + text: i18ndc("plasma_applet_org.kde.plasma.notifications", "Pause running job", "Pause") + onClicked: jobItem.jobState === NotificationManager.Notifications.JobStateSuspended ? jobItem.resumeJobClicked() + : jobItem.suspendJobClicked() + } + + PlasmaComponents3.Button { + id: killButton + + icon.name: "dialog-cancel-symbolic" + text: i18ndc("plasma_applet_org.kde.plasma.notifications", "Cancel running job", "Cancel") + onClicked: jobItem.killJobClicked() + } + } + + Loader { + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + active: expandButton.checked + // Loader doesn't reset its height when unloaded, just hide it altogether + visible: active + sourceComponent: NotificationJobDetails { + jobDetails: jobItem.jobDetails + } + } + + Row { + id: fileActionsRow + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + // We want the actions to be right-aligned but Row also reverses + // the order of items, so we put them in reverse order + layoutDirection: Qt.RightToLeft + visible: jobItem.url && jobItem.url.toString() !== "" && !fileInfo.error + + PlasmaComponents3.Button { + id: otherFileActionsButton + height: Math.max(implicitHeight, openButton.implicitHeight) + icon.name: "application-menu-symbolic" + checkable: true + text: openButton.visible ? "" : Accessible.name + Accessible.name: i18nd("plasma_applet_org.kde.plasma.notifications", "More Options…") + onPressedChanged: { + if (pressed) { + checked = Qt.binding(function() { + return otherFileActionsMenu.visible; + }); + otherFileActionsMenu.visualParent = this; + // -1 tells it to "align bottom left of visualParent (this)" + otherFileActionsMenu.open(-1, -1); + } + } + + PlasmaComponents3.ToolTip { + text: parent.Accessible.name + enabled: parent.text === "" + } + + Notifications.FileMenu { + id: otherFileActionsMenu + url: jobItem.url || "" + onActionTriggered: jobItem.fileActionInvoked(action) + } + } + + PlasmaComponents3.Button { + id: openButton + width: Math.min(implicitWidth, jobItem.width - otherFileActionsButton.width - fileActionsRow.spacing) + height: Math.max(implicitHeight, otherFileActionsButton.implicitHeight) + text: i18nd("plasma_applet_org.kde.plasma.notifications", "Open") + onClicked: jobItem.openUrl(jobItem.url) + + states: [ + State { + when: jobItem.jobDetails && jobItem.jobDetails.totalFiles !== 1 + PropertyChanges { + target: openButton + text: i18nd("plasma_applet_org.kde.plasma.notifications", "Open Containing Folder") + icon.name: "folder-open-symbolic" + } + }, + State { + when: fileInfo.openAction + PropertyChanges { + target: openButton + text: fileInfo.openAction.text + icon.name: fileInfo.openActionIconName + visible: fileInfo.openAction.enabled + onClicked: { + fileInfo.openAction.trigger(); + jobItem.fileActionInvoked(fileInfo.openAction); + } + } + } + ] + } + } + + states: [ + State { + when: jobItem.jobState === NotificationManager.Notifications.JobStateRunning + PropertyChanges { + target: suspendButton + // Explicitly set it to false so it unchecks when pausing from applet + // and then the job unpauses programmatically elsewhere. + checked: false + } + }, + State { + when: jobItem.jobState === NotificationManager.Notifications.JobStateSuspended + PropertyChanges { + target: suspendButton + checked: true + } + PropertyChanges { + target: progressBar + enabled: false + } + }, + State { + when: jobItem.jobState === NotificationManager.Notifications.JobStateStopped + PropertyChanges { + target: progressRow + visible: false + } + PropertyChanges { + target: jobActionsRow + visible: false + } + PropertyChanges { + target: expandButton + checked: false + } + } + ] +} + diff --git a/components/mobileshell/qml/widgets/notifications/NotificationsWidget.qml b/components/mobileshell/qml/widgets/notifications/NotificationsWidget.qml index a3a6f621..23bd4c84 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationsWidget.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationsWidget.qml @@ -26,69 +26,69 @@ import org.kde.notificationmanager as NotificationManager */ Item { id: root - + /** * The notification model for the widget. */ property var historyModel - + /** * The type of notification model used for the widget. */ property int historyModelType - + /** * The notification model settings for the widget. */ property var notificationSettings - + /** * Whether invoking notification actions requires authentiation of some sort. - * + * * If set to true, any attempted invoking will trigger the unlockRequested() signal. * Any consumers can then call the runPendingAction() function if authenticated to proceed * executing the notification action. */ property bool actionsRequireUnlock: false - + /** * Whether the widget has notifications. */ readonly property bool hasNotifications: list.count > 0 - + readonly property bool doNotDisturbModeEnabled: !isNaN(notificationSettings.notificationsInhibitedUntil) - + enum ModelType { NotificationsModel, // used in the logged-in shell WatchedNotificationsModel // used on the lockscreen } - + /** * Signal emitted when authentication is requested for an action. * Listeners should call runPendingAction() if authentication is successful. - * + * * Only emitted if actionsRequireUnlock is enabled. */ signal unlockRequested() - + /** * Emitted when the background is clicked (not a notification or other element). */ signal backgroundClicked() - + /** * Run pending action that was pending for authentication when unlockRequested() was emitted. */ function runPendingAction() { list.pendingNotificationWithAction.runPendingAction(); } - + /** * Clears the history of the notification model. */ function clearHistory() { historyModel.clear(NotificationManager.Notifications.ClearExpired); - + if (historyModel.count === 0) { backgroundClicked(); } @@ -102,15 +102,15 @@ Item { notificationSettings.defaults(); } else { var until = new Date(); - + until.setFullYear(until.getFullYear() + 1); - + notificationSettings.notificationsInhibitedUntil = until; } - + notificationSettings.save(); } - + /** * Open the system notification settings. */ @@ -125,7 +125,7 @@ Item { interval: 60000 // 1 min intervalAlignment: P5Support.Types.AlignToMinute } - + // implement background clicking signal MouseArea { anchors.fill: parent @@ -136,40 +136,40 @@ Item { ListView { id: list model: historyModel - + clip: true - + currentIndex: 0 - + property var pendingNotificationWithAction readonly property int animationDuration: ShellSettings.Settings.animationsEnabled ? Kirigami.Units.longDuration : 0 - + // If a screen overflow occurs, fix height in order to maintain tool buttons in place. readonly property bool listOverflowing: contentItem.childrenRect.height + toolButtons.height + spacing >= root.height - + bottomMargin: spacing height: count === 0 ? 0 : (listOverflowing ? root.height - toolButtons.height : contentItem.childrenRect.height + bottomMargin) - + anchors { top: parent.top left: parent.left right: parent.right } - + boundsBehavior: Flickable.StopAtBounds spacing: Kirigami.Units.largeSpacing // TODO keyboard focus highlightMoveDuration: 0 highlightResizeDuration: 0 - highlight: Item {} + highlight: Item {} section { property: "isGroup" criteria: ViewSection.FullString } - + PlasmaExtras.PlaceholderMessage { anchors.centerIn: parent width: parent.width - (Kirigami.Units.gridUnit * 4) @@ -187,7 +187,7 @@ Item { visible: currentOwner && currentOwner.vendor && currentOwner.name } } - + // Run every time an item is visually added to the list, thus when `Show n more` button is clicked as well. add: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: list.animationDuration } @@ -196,7 +196,7 @@ Item { displaced: Transition { NumberAnimation { properties: "y"; duration: list.animationDuration } } - + function isRowExpanded(row) { var idx = historyModel.index(row, 0); return historyModel.data(idx, NotificationManager.Notifications.IsGroupExpandedRole); @@ -219,90 +219,89 @@ Item { } } } - + // Instantly re-align items after group expansion. forceLayout(); } - + delegate: Loader { id: delegateLoader - + anchors { left: parent ? parent.left : undefined leftMargin: Kirigami.Units.gridUnit right: parent ? parent.right : undefined rightMargin: Kirigami.Units.gridUnit } - + height: model.isGroup ? groupDelegate.height : notificationDelegate.height sourceComponent: model.isGroup ? groupDelegate : notificationDelegate asynchronous: true - + required property var model required property int index - + // We have to do this here in order to control the animation before the item is completely removed ListView.onRemove: SequentialAnimation { PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: true } NumberAnimation { target: delegateLoader; property: "opacity"; to: 0.0; duration: list.animationDuration } PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: false } } - + Component { id: groupDelegate - NotificationGroupHeader { + NotificationGroupHeader { applicationName: model.applicationName applicationIconSource: model.applicationIconName originName: model.originName || "" - timeSource: timeDataSource } } - + Component { id: notificationDelegate - + Column { spacing: Kirigami.Units.smallSpacing - + height: notificationItem.height + showMoreLoader.height - + NotificationItem { id: notificationItem width: parent.width height: implicitHeight - + model: delegateLoader.model modelIndex: delegateLoader.index notificationsModel: root.historyModel notificationsModelType: root.historyModelType timeSource: timeDataSource - + requestToInvoke: root.actionsRequireUnlock onRunActionRequested: { list.pendingNotificationWithAction = notificationItem; root.unlockRequested(); } } - + // Every item has got an instance of this loader, but it becomes active only for last ones that take place in big enough groups. Loader { id: showMoreLoader - + height: visible ? implicitHeight : 0 opacity: 0.0 visible: active - + asynchronous: true - + active: { // if we have the WatchedNotificationsModel, we don't have notification grouping support if (typeof model.groupChildrenCount === 'undefined') return false; - + return (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded) && delegateLoader.ListView.nextSection != delegateLoader.ListView.section; } - + // state + transition: animates the item when it becomes visible. Fade off is handled by above ListView.onRemove. states: State { name: "VISIBLE" @@ -316,7 +315,7 @@ Item { NumberAnimation { properties: "opacity"; duration: list.animationDuration } } } - + sourceComponent: PlasmaComponents3.ToolButton { icon.name: model.isGroupExpanded ? "arrow-up" : "arrow-down" text: model.isGroupExpanded ? i18n("Show Fewer") @@ -331,31 +330,31 @@ Item { } } } - + Item { id: toolButtons height: visible ? spacer.height + toolLayout.height + toolLayout.anchors.topMargin + toolLayout.anchors.bottomMargin : 0 - + // do not show on lockscreen visible: !root.actionsRequireUnlock - + anchors { top: list.bottom left: parent.left right: parent.right } - + Rectangle { id: spacer anchors.left: parent.left anchors.right: parent.right - + visible: list.listOverflowing - height: 1 + height: 1 opacity: 0.25 color: Kirigami.Theme.textColor } - + RowLayout { id: toolLayout @@ -368,17 +367,17 @@ Item { topMargin: list.spacing bottomMargin: list.spacing } - + PlasmaComponents3.ToolButton { id: clearButton - + Layout.alignment: Qt.AlignCenter - + visible: hasNotifications - + font.bold: true font.pointSize: Kirigami.Theme.smallFont.pointSize - + icon.name: "edit-clear-history" text: i18n("Clear All Notifications") onClicked: clearHistory()