/* * 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 */ import QtQuick 2.15 import QtQuick.Layouts 1.15 import QtQuick.Window 2.2 import Qt5Compat.GraphicalEffects import org.kde.kirigami as Kirigami import org.kde.plasma.clock import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.extras 2.0 as PlasmaExtras import org.kde.plasma.components 3.0 as PlasmaComponents3 import org.kde.notificationmanager as NotificationManager /** * Embeddable notification list widget optimized for mobile and touch. * Used on the lockscreen and action drawer. */ Item { id: root /** * If the notification is in the lockscreen. */ property bool inLockScreen: false /** * The panel background type for all the notifications within the widget. */ property int panelType: MobileShell.PanelBackground.PanelType.Drawer /** * The color scheme of the foreground cards (but not notification headers, which are outside of it). */ property var cardColorScheme: Kirigami.Theme.View /** * 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 /** * Top padding of the notification list. */ property int topPadding: 0 /** * Bottom padding of the notification list. */ property int bottomPadding: 0 /** * Header component for notification list. */ property var header /** * Whether to show the header component. */ property bool showHeader: false property string emptyText: "" /** * Gives access to the notification list view outside of the notification widget. */ property alias listView: list /** * 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() /** * Run pending action that was pending for authentication when unlockRequested() was emitted. */ function runPendingAction() { if (list.pendingNotificationWithAction) { list.pendingNotificationWithAction.runPendingAction(); list.pendingNotificationWithAction = null; } } /** * Clears the history of the notification model. */ function clearHistory() { historyModel.clear(NotificationManager.Notifications.ClearExpired); if (historyModel.count === 0) { backgroundClicked(); } } /** * Sets Do Not Disturb mode to the intended setting. * Note: The state may not change to the desired setting, always read doNotDisturbEnabled for the current state. */ function setDoNotDisturbMode(doNotDisturb: bool) { if (!doNotDisturb) { // Turn off do not disturb notificationSettings.notificationsInhibitedUntil = undefined; notificationSettings.revokeApplicationInhibitions(); notificationSettings.fullscreenFocused = false; // overrules current mirrored screen setup, updates again when screen configuration changes notificationSettings.screensMirrored = false; } else { // Turn on do not disturb // We just have a global toggle, so set it to a really long time (in this case, a year) var until = new Date(); until.setFullYear(until.getFullYear() + 1); notificationSettings.notificationsInhibitedUntil = until; } notificationSettings.save(); } /** * Open the system notification settings. */ function openNotificationSettings() { MobileShell.ShellUtil.executeCommand("plasma-open-settings kcm_notifications"); } // Implement listening to system "do not disturb" requests Connections { target: MobileShellState.ShellDBusClient function onDoNotDisturbChanged() { if (root.doNotDisturbModeEnabled !== MobileShellState.ShellDBusClient.doNotDisturb) { root.setDoNotDisturbMode(MobileShellState.ShellDBusClient.doNotDisturb); } } } Clock { id: clock } // Empty-state placeholders centred in the full widget rather than the // (possibly tiny) ListView so they don't clip out of view. PlasmaExtras.PlaceholderMessage { anchors.centerIn: parent width: parent.width - (Kirigami.Units.gridUnit * 4) text: i18n("Notification service not available") visible: list.count === 0 && !NotificationManager.Server.valid && historyModelType === NotificationsModelType.NotificationsModel PlasmaComponents3.Label { readonly property NotificationManager.ServerInfo currentOwner: !NotificationManager.Server.valid ? NotificationManager.Server.currentOwner : null Layout.fillWidth: true wrapMode: Text.WordWrap text: currentOwner ? i18nc("Vendor and product name", "Notifications are currently provided by '%1 %2'", currentOwner.vendor, currentOwner.name) : "" visible: currentOwner && currentOwner.vendor && currentOwner.name } } PlasmaExtras.PlaceholderMessage { anchors.centerIn: parent width: parent.width - (Kirigami.Units.gridUnit * 4) text: root.emptyText visible: list.count === 0 && root.emptyText.length > 0 && NotificationManager.Server.valid } ListView { id: list model: historyModel clip: true currentIndex: 0 property NotificationItem pendingNotificationWithAction: null readonly property int animationDuration: MobileShell.Motion.duration(MobileShell.Motion.Standard) // If a screen overflow occurs, fix height in order to maintain tool buttons in place. readonly property bool listOverflowing: listHeight + spacing >= root.height readonly property int listHeight: contentItem.childrenRect.height bottomMargin: spacing height: count === 0 ? (root.topPadding + (showHeader ? root.header.height + listHeight + Kirigami.Units.largeSpacing * 2 : 0)) : (listOverflowing ? root.height : listHeight + bottomMargin) anchors { top: parent.top left: parent.left right: parent.right } boundsBehavior: Flickable.DragAndOvershootBounds spacing: Kirigami.Units.largeSpacing // TODO keyboard focus highlightMoveDuration: 0 highlightResizeDuration: 0 highlight: Item {} // media control widget // added to the notification list when in landscape mode Component { id: headerComponent Item { width: parent.width MobileShell.BaseItem { id: headerComponentProxy contentItem: showHeader ? root.header : null y: root.topPadding + Kirigami.Units.largeSpacing width: parent.width - Kirigami.Units.gridUnit * 2 anchors.left: parent.left anchors.leftMargin: Kirigami.Units.gridUnit } } } // set bottom padding for the notification list Component { id: footerComponent Item { width: parent.width height: root.bottomPadding } } header: headerComponent footer: footerComponent section { property: "isGroup" criteria: ViewSection.FullString } // Run every time an item is visually added to the list, thus when `Show n more` button is clicked as well. add: Transition { MobileShell.MotionNumberAnimation { property: "opacity"; from: 0; to: 1; duration: list.animationDuration; type: MobileShell.Motion.Standard } } // Run every time an item is displaced, such as when the order is scrambled due to a group expansion. displaced: Transition { MobileShell.MotionNumberAnimation { properties: "y"; duration: list.animationDuration; type: MobileShell.Motion.Standard } } function isRowExpanded(row) { var idx = historyModel.index(row, 0); return historyModel.data(idx, NotificationManager.Notifications.IsGroupExpandedRole); } 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; } } } // 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 } MobileShell.MotionNumberAnimation { target: delegateLoader; property: "opacity"; to: 0.0; duration: list.animationDuration; type: MobileShell.Motion.Standard } PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: false } } // adjust top paddding for media control widget Component { id: groupDelegate Column { spacing: Kirigami.Units.smallSpacing height: headerSpace.height + groupHeader.height Item { id: headerSpace width: parent.width height: index == 0 ? root.topPadding + (showHeader && root.header.visible ? root.header.height + Kirigami.Units.largeSpacing * 2 : 0) : 0 visible: index == 0 } NotificationGroupHeader { id: groupHeader applicationName: model.applicationName applicationIconSource: model.applicationIconName originName: model.originName || "" } } } Component { id: notificationDelegate Column { spacing: Kirigami.Units.smallSpacing height: headerSpace.height + notificationItem.height + showMoreLoader.height Item { id: headerSpace width: parent.width height: index == 0 ? root.topPadding + (showHeader && root.header.visible ? root.header.height + Kirigami.Units.largeSpacing * 2 : 0) : 0 visible: index == 0 } NotificationItem { id: notificationItem width: parent.width height: implicitHeight inLockScreen: root.inLockScreen panelType: root.panelType cardColorScheme: root.cardColorScheme model: delegateLoader.model modelIndex: delegateLoader.index notificationsModel: root.historyModel notificationsModelType: root.historyModelType clockSource: clock 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" when: showMoreLoader.status == Loader.Ready PropertyChanges { target: showMoreLoader; opacity: 1.0 } } transitions: Transition { to: "VISIBLE" SequentialAnimation { PauseAnimation { duration: list.animationDuration * 2 } MobileShell.MotionNumberAnimation { properties: "opacity"; duration: list.animationDuration; type: MobileShell.Motion.Standard } } } sourceComponent: 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)) onClicked: { list.setGroupExpanded(model.index, !model.isGroupExpanded) } } } } } } } }