aerothemeplasma/plasma/plasmoids/org.kde.plasma.notifications/contents/ui/FullRepresentation.qml
2024-08-09 03:20:25 +02:00

644 lines
29 KiB
QML

/*
SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de>
SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
import QtQuick 2.10
import QtQuick.Layouts 1.1
import QtQuick.Controls 2.15 as QQC2
import org.kde.plasma.plasmoid 2.0
import org.kde.plasma.core as PlasmaCore
import org.kde.ksvg 1.0 as KSvg
import org.kde.plasma.components 3.0 as PlasmaComponents3
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
import org.kde.notificationmanager as NotificationManager
import org.kde.plasma.private.notifications as Notifications
import "global"
PlasmaExtras.Representation {
// TODO these should be configurable in the future
readonly property int dndMorningHour: 6
readonly property int dndEveningHour: 20
readonly property var appletInterface: root
Layout.minimumWidth: Kirigami.Units.iconSizes.small * 12
Layout.minimumHeight: Kirigami.Units.iconSizes.small * 12
Layout.preferredWidth: Kirigami.Units.iconSizes.small * 18
Layout.preferredHeight: Kirigami.Units.iconSizes.small * 24
Layout.maximumWidth: Kirigami.Units.iconSizes.small * 80
Layout.maximumHeight: Kirigami.Units.iconSizes.small * 40
Layout.fillHeight: Plasmoid.formFactor === PlasmaCore.Types.Vertical
collapseMarginsHint: true
Keys.onDownPressed: dndCheck.forceActiveFocus(Qt.TabFocusReason);
Connections {
target: root
function onExpandedChanged() {
if (root.expanded) {
list.positionViewAtBeginning();
list.currentIndex = -1;
}
}
}
header: PlasmaExtras.PlasmoidHeading {
ColumnLayout {
anchors {
fill: parent
leftMargin: Kirigami.Units.smallSpacing
}
id: header
spacing: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
QQC2.CheckBox {
id: dndCheck
enabled: NotificationManager.Server.valid
text: i18n("Do not disturb")
icon.name: "notifications-disabled"
checkable: true
checked: Globals.inhibited
KeyNavigation.down: list
KeyNavigation.tab: list
// Let the menu open on press
onPressed: {
if (!Globals.inhibited) {
dndMenu.date = new Date();
// shows ontop of CheckBox to hide the fact that it's unchecked
// until you actually select something :)
dndMenu.open(0, 0);
}
}
// but disable only on click
onClicked: {
if (Globals.inhibited) {
Globals.revokeInhibitions();
}
}
PlasmaExtras.ModelContextMenu {
id: dndMenu
property date date
visualParent: dndCheck
onClicked: {
notificationSettings.notificationsInhibitedUntil = model.date;
notificationSettings.save();
}
model: {
var model = [];
// For 1 hour
var d = dndMenu.date;
d.setHours(d.getHours() + 1);
d.setSeconds(0);
model.push({date: d, text: i18n("For 1 hour")});
d = dndMenu.date;
d.setHours(d.getHours() + 4);
d.setSeconds(0);
model.push({date: d, text: i18n("For 4 hours")});
// Until this evening
if (dndMenu.date.getHours() < dndEveningHour) {
d = dndMenu.date;
// TODO make the user's preferred time schedule configurable
d.setHours(dndEveningHour);
d.setMinutes(0);
d.setSeconds(0);
model.push({date: d, text: i18n("Until this evening")});
}
// Until next morning
if (dndMenu.date.getHours() > dndMorningHour) {
d = dndMenu.date;
d.setDate(d.getDate() + 1);
d.setHours(dndMorningHour);
d.setMinutes(0);
d.setSeconds(0);
model.push({date: d, text: i18n("Until tomorrow morning")});
}
// Until Monday
// show Friday and Saturday, Sunday is "0" but for that you can use "until tomorrow morning"
if (dndMenu.date.getDay() >= 5) {
d = dndMenu.date;
d.setHours(dndMorningHour);
// wraps around if necessary
d.setDate(d.getDate() + (7 - d.getDay() + 1));
d.setMinutes(0);
d.setSeconds(0);
model.push({date: d, text: i18n("Until Monday")});
}
// Until "turned off"
d = dndMenu.date;
// Just set it to one year in the future so we don't need yet another "do not disturb enabled" property
d.setFullYear(d.getFullYear() + 1);
model.push({date: d, text: i18n("Until manually disabled")});
return model;
}
}
}
Item {
Layout.fillWidth: true
}
PlasmaComponents3.ToolButton {
visible: !(Plasmoid.containmentDisplayHints & PlasmaCore.Types.ContainmentDrawsPlasmoidHeading)
Accessible.name: root.clearHistoryAction.text
icon.name: "edit-clear-history"
enabled: root.clearHistoryAction.visible
onClicked: root.clearHistoryAction.trigger()
PlasmaComponents3.ToolTip {
text: parent.Accessible.name
}
}
}
PlasmaExtras.DescriptiveLabel {
Layout.leftMargin: dndCheck.mirrored ? 0 : dndCheck.indicator.width + 2 * dndCheck.spacing + Kirigami.Units.iconSizes.smallMedium
Layout.rightMargin: dndCheck.mirrored ? dndCheck.indicator.width + 2 * dndCheck.spacing + Kirigami.Units.iconSizes.smallMedium : 0
Layout.fillWidth: true
wrapMode: Text.WordWrap
textFormat: Text.PlainText
text: {
if (!Globals.inhibited) {
return "";
}
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
var inhibitedUntilTime = inhibitedUntil.getTime();
var inhibitedByApp = notificationSettings.notificationsInhibitedByApplication;
var inhibitedByMirroredScreens = notificationSettings.inhibitNotificationsWhenScreensMirrored
&& notificationSettings.screensMirrored;
var dateNow = Date.now();
var sections = [];
// Show until time if valid but not if too far int he future
if (!isNaN(inhibitedUntilTime) && inhibitedUntilTime - dateNow > 0 &&
inhibitedUntilTime - dateNow < 100 * 24 * 60 * 60 * 1000 /* 1 year*/) {
const endTime = KCoreAddons.Format.formatRelativeDateTime(inhibitedUntil, Locale.ShortFormat);
const lowercaseEndTime = endTime[0] + endTime.slice(1);
sections.push(i18nc("Do not disturb until date", "Automatically ends: %1", lowercaseEndTime));
}
if (inhibitedByApp) {
var inhibitionAppNames = notificationSettings.notificationInhibitionApplications;
var inhibitionAppReasons = notificationSettings.notificationInhibitionReasons;
for (var i = 0, length = inhibitionAppNames.length; i < length; ++i) {
var name = inhibitionAppNames[i];
var reason = inhibitionAppReasons[i];
if (reason) {
sections.push(i18nc("Do not disturb until app has finished (reason)", "While %1 is active (%2)", name, reason));
} else {
sections.push(i18nc("Do not disturb until app has finished", "While %1 is active", name));
}
}
}
if (inhibitedByMirroredScreens) {
sections.push(i18nc("Do not disturb because external mirrored screens connected", "Screens are mirrored"))
}
return sections.join(" · ");
}
visible: text !== ""
}
}
}
Notifications.WheelForwarder {
id: wheelForwarder
toItem: scrollView.contentItem
}
PlasmaComponents3.ScrollView {
id: scrollView
anchors.fill: parent
background: null
focus: true
contentItem: ListView {
id: list
width: scrollView.availableWidth
focus: true
model: root.expanded ? historyModel : null
currentIndex: -1
topMargin: Kirigami.Units.largeSpacing
bottomMargin: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.smallSpacing
readonly property alias wheelForwarder: wheelForwarder
KeyNavigation.up: dndCheck
Keys.onDeletePressed: {
var idx = historyModel.index(currentIndex, 0);
if (historyModel.data(idx, NotificationManager.Notifications.ClosableRole)) {
historyModel.close(idx);
// TODO would be nice to stay inside the current group when deleting an item
}
}
Keys.onEnterPressed: event => { Keys.returnPressed(event) }
Keys.onReturnPressed: {
// Trigger default action, if any
var idx = historyModel.index(currentIndex, 0);
if (historyModel.data(idx, NotificationManager.Notifications.HasDefaultActionRole)) {
historyModel.invokeDefaultAction(idx);
return;
}
// Trigger thumbnail URL if there's one
var urls = historyModel.data(idx, NotificationManager.Notifications.UrlsRole);
if (urls && urls.length === 1) {
Qt.openUrlExternally(urls[0]);
return;
}
// TODO for finished jobs trigger "Open" or "Open Containing Folder" action
}
Keys.onLeftPressed: setGroupExpanded(currentIndex, LayoutMirroring.enabled)
Keys.onRightPressed: setGroupExpanded(currentIndex, !LayoutMirroring.enabled)
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Home:
currentIndex = 0;
break;
case Qt.Key_End:
currentIndex = count - 1;
break;
}
}
function setGroupExpanded(row, expanded) {
var rowIdx = historyModel.index(row, 0);
var persistentRowIdx = historyModel.makePersistentModelIndex(rowIdx);
var persistentGroupIdx = historyModel.makePersistentModelIndex(historyModel.groupIndex(rowIdx));
historyModel.setData(rowIdx, expanded, NotificationManager.Notifications.IsGroupExpandedRole);
// If the current item went away when the group collapsed, scroll to the group heading
if (!persistentRowIdx || !persistentRowIdx.valid) {
if (persistentGroupIdx && persistentGroupIdx.valid) {
list.positionViewAtIndex(persistentGroupIdx.row, ListView.Contain);
// When closed via keyboard, also set a sane current index
if (list.currentIndex > -1) {
list.currentIndex = persistentGroupIdx.row;
}
}
}
}
highlightMoveDuration: 0
highlightResizeDuration: 0
// Not using PlasmaExtras.Highlight as this is only for indicating keyboard focus
highlight: KSvg.FrameSvgItem {
imagePath: "widgets/listitem"
prefix: "pressed"
}
// This is so the delegates can detect the change in "isInGroup" and show a separator
section {
property: "isInGroup"
criteria: ViewSection.FullString
}
delegate: DraggableDelegate {
id: delegate
width: ListView.view.width
contentItem: delegateLoader
// NOTE: The following animations replace the Transitions in the ListView
// because they don't work when the items change size during the animation
// (showing/hiding the show more/show less button) in that case they will
// animate to a wrong position and stay there
// see https://bugs.kde.org/show_bug.cgi?id=427894 and QTBUG-110366
property real oldY: -1
property int oldListCount: -1
onYChanged: {
if (oldY < 0 || oldListCount === list.count) {
oldY = y;
return;
}
traslAnim.from = oldY - y;
traslAnim.running = true;
oldY = y;
oldListCount = list.count;
}
transform: Translate {
id: transl
}
NumberAnimation {
id: traslAnim
target: transl
properties: "y"
to: 0
duration: Kirigami.Units.longDuration
}
opacity: 0
ListView.onAdd: appearAnim.restart();
Component.onCompleted: {
Qt.callLater(() => {
if (!appearAnim.running) {
opacity = 1;
}
});
oldListCount = list.count;
}
SequentialAnimation {
id: appearAnim
PropertyAnimation { target: delegate; property: "opacity"; to: 0 }
PauseAnimation { duration: Kirigami.Units.longDuration}
NumberAnimation {
target: delegate
property: "opacity"
from: 0
to: 1
duration: Kirigami.Units.longDuration
}
}
SequentialAnimation {
id: removeAnimation
PropertyAction { target: delegate; property: "ListView.delayRemove"; value: true }
ParallelAnimation {
NumberAnimation { target: delegate; property: "opacity"; to: 0; duration: Kirigami.Units.longDuration }
NumberAnimation {
target: transl
property: "x"
to: list.width - (scrollView.PlasmaComponents3.ScrollBar.vertical.visible ? Kirigami.Units.largeSpacing * 2 : 0)
duration: Kirigami.Units.longDuration
}
}
PropertyAction { target: delegate; property: "ListView.delayRemove"; value: false }
}
draggable: !model.isGroup && model.type != NotificationManager.Notifications.JobType
onDismissRequested: {
removeAnimation.start();
historyModel.close(historyModel.index(index, 0));
}
Loader {
id: delegateLoader
anchors {
left: parent.left
leftMargin: Kirigami.Units.largeSpacing
right: parent.right
rightMargin: Kirigami.Units.largeSpacing
}
sourceComponent: model.isGroup ? groupDelegate : notificationDelegate
Component {
id: groupDelegate
NotificationHeader {
applicationName: model.applicationName
applicationIconSource: model.applicationIconName
originName: model.originName || ""
// don't show timestamp for group
configurable: model.configurable
closable: model.closable
closeButtonTooltip: i18n("Close Group")
onCloseClicked: historyModel.close(historyModel.index(index, 0));
onConfigureClicked: historyModel.configure(historyModel.index(index, 0))
}
}
Component {
id: notificationDelegate
ColumnLayout {
spacing: Kirigami.Units.smallSpacing
RowLayout {
Item {
id: groupLineContainer
Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing
width: Kirigami.Units.iconSizes.small
visible: model.isInGroup
// Not using the Plasma theme's vertical line SVG because we want something thicker
// than a hairline, and thickening a thin line SVG does not necessarily look good
// with all Plasma themes.
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 3
height: parent.height
// TODO: use separator color here, once that color role is implemented
color: Kirigami.Theme.textColor
opacity: 0.2
}
}
NotificationItem {
Layout.fillWidth: true
notificationType: model.type
inGroup: model.isInGroup
inHistory: true
listViewParent: list
applicationName: model.applicationName
applicationIconSource: model.applicationIconName
originName: model.originName || ""
time: model.updated || model.created
// configure button on every single notifications is bit overwhelming
configurable: !inGroup && model.configurable
dismissable: model.type === NotificationManager.Notifications.JobType
&& model.jobState !== NotificationManager.Notifications.JobStateStopped
&& model.dismissed
// TODO would be nice to be able to undismiss jobs even when they autohide
&& notificationSettings.permanentJobPopups
dismissed: model.dismissed || false
closable: model.closable
summary: model.summary
body: model.body || ""
icon: model.image || model.iconName
urls: model.urls || []
jobState: model.jobState || 0
percentage: model.percentage || 0
jobError: model.jobError || 0
suspendable: !!model.suspendable
killable: !!model.killable
jobDetails: model.jobDetails || null
configureActionLabel: model.configureActionLabel || ""
// In the popup the default action is triggered by clicking on the popup
// however in the list this is undesirable, so instead show a clickable button
// in case you have a non-expired notification in history (do not disturb mode)
// unless it has the same label as an action
readonly property bool addDefaultAction: (model.hasDefaultAction
&& model.defaultActionLabel
&& (model.actionLabels || []).indexOf(model.defaultActionLabel) === -1) ? true : false
actionNames: {
var actions = (model.actionNames || []);
if (addDefaultAction) {
actions.unshift("default"); // prepend
}
return actions;
}
actionLabels: {
var labels = (model.actionLabels || []);
if (addDefaultAction) {
labels.unshift(model.defaultActionLabel);
}
return labels;
}
onCloseClicked: close()
onDismissClicked: {
model.dismissed = false;
root.closePlasmoid();
}
onConfigureClicked: historyModel.configure(historyModel.index(index, 0))
onActionInvoked: {
if (actionName === "default") {
historyModel.invokeDefaultAction(historyModel.index(index, 0));
} else {
historyModel.invokeAction(historyModel.index(index, 0), actionName);
}
expire();
}
onOpenUrl: {
Qt.openUrlExternally(url);
expire();
}
onFileActionInvoked: {
if (action.objectName === "movetotrash" || action.objectName === "deletefile") {
close();
} else {
expire();
}
}
onSuspendJobClicked: historyModel.suspendJob(historyModel.index(index, 0))
onResumeJobClicked: historyModel.resumeJob(historyModel.index(index, 0))
onKillJobClicked: historyModel.killJob(historyModel.index(index, 0))
function expire() {
if (model.resident) {
model.expired = true;
} else {
historyModel.expire(historyModel.index(index, 0));
}
}
function close() {
removeAnimation.start();
historyModel.close(historyModel.index(index, 0));
}
}
}
PlasmaComponents3.ToolButton {
icon.name: model.isGroupExpanded ? "arrow-up" : "arrow-down"
text: model.isGroupExpanded ? i18n("Show Fewer")
: i18nc("Expand to show n more notifications",
"Show %1 More", (model.groupChildrenCount - model.expandedGroupChildrenCount))
visible: (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded)
&& delegate.ListView.nextSection !== delegate.ListView.section
onClicked: list.setGroupExpanded(model.index, !model.isGroupExpanded)
}
KSvg.SvgItem {
Layout.fillWidth: true
Layout.bottomMargin: Kirigami.Units.smallSpacing
imagePath: "widgets/line"
elementId: "horizontal-line"
// property is only atached to the delegate itself (the Loader in our case)
visible: (!model.isInGroup || delegate.ListView.nextSection !== delegate.ListView.section)
&& delegate.ListView.nextSection !== "" // don't show after last item
&& !removeAnimation.running
}
}
}
}
}
Loader {
anchors.centerIn: parent
width: parent.width - (Kirigami.Units.iconSizes.small * 4)
active: list.count === 0
visible: active
asynchronous: true
sourceComponent: NotificationManager.Server.valid ? noUnreadMessage : notAvailableMessage
}
Component {
id: noUnreadMessage
PlasmaExtras.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width
iconName: "checkmark"
text: i18n("No unread notifications")
}
}
Component {
id: notAvailableMessage
PlasmaExtras.PlaceholderMessage {
// Checking valid to avoid creating ServerInfo object if everything is alright
readonly property NotificationManager.ServerInfo currentOwner: NotificationManager.Server.currentOwner
anchors.centerIn: parent
width: parent.width
iconName: "notifications-disabled"
text: i18n("Notification service not available")
explanation: currentOwner && currentOwner.vendor && currentOwner.name
? i18nc("Vendor and product name", "Notifications are currently provided by '%1 %2'", currentOwner.vendor, currentOwner.name)
: ""
}
}
}
}
}