/**************************************************************************** ** ** Copyright (C) 2013 Jolla Ltd. ** Contact: Vesa Halttunen ** ****************************************************************************/ import QtQuick 2.0 import Sailfish.Silica 1.0 import Sailfish.Lipstick 1.0 import com.jolla.lipstick 0.1 import org.nemomobile.lipstick 0.1 import org.nemomobile.thumbnailer 1.0 import org.nemomobile.devicelock 1.0 import "../systemwindow" import Nemo.Configuration 1.0 SystemWindow { id: notificationWindow property QtObject notification: notificationPreviewPresenter.notification property bool showNotification: notification != null && (notification.previewBody || notification.previewSummary) property string summaryText: showNotification ? notification.previewSummary : '' property string bodyText: showNotification ? notification.previewBody : '' property bool popupPresentation: state == "showPopup" || state == "hidePopup" property string iconSource: showNotification ? (popupPresentation ? (notification.previewIcon || notification.icon || notification.appIcon) : (notification.previewIcon || notification.icon)) : "" property real statusBarPushDownY: bannerArea.y + bannerArea.height property string iconUrl: { if (iconSource.length) { if (iconSource.indexOf("http") === 0) { return iconSource } else if (iconSource.indexOf("/") === 0) { return "image://nemoThumbnail/" + iconSource } else if (iconSource.indexOf("image://theme/") === 0) { return iconSource } else { return "image://theme/" + iconSource } } return '' } property bool _invoked Binding { // Invocation typically closes the notification, so bind the current values // to prevent unwanted changes to these properties when: notificationWindow._invoked target: notificationWindow property: "summaryText" value: notificationWindow.summaryText } Binding { when: notificationWindow._invoked target: notificationWindow property: "bodyText" value: notificationWindow.bodyText } Binding { when: notificationWindow._invoked target: notificationWindow property: "iconUrl" value: notificationWindow.iconUrl } function firstLine(str) { var i = str.indexOf("\n") if (i >= 0) { return str.substr(0, i) } return str } opacity: 0 visible: false property bool removeRequested: false function notificationAction() { if (notification) { notificationWindow._invoked = true notification.actionInvoked("default") // Also go to the switcher in case the screen was locked at invocation Lipstick.compositor.unlock() } } function dismissPreview() { forceHideTimer.stop() notificationWindow.notificationExpired() } function removeNotification() { removeRequested = true forceHideTimer.stop() notificationWindow.notificationExpired() } ConfigurationGroup { id: previewSettings path: "/desktop/lipstick-jolla-home/notification-preview" property int style: 0 property int corner_radius: {Theme.paddingSmall} property int margin: 0 property int position: 0 property int min_width: 0 property int max_width: 57 property int timeout: 5000 property int click_action: 1 property int left_swipe_action: 0 property int right_swipe_action: 0 property int down_swipe_action: 0 } MouseArea { id: popupArea property bool down: pressed && containsMouse property real textOpacity: 0 property color textColor: down || drag.active ? Theme.highlightColor : Theme.primaryColor property real maxDisplayWidth: previewSettings.max_width / 100 * Screen.height property real minDisplayWidth: previewSettings.min_width / 100 * Screen.height property real displayWidth: body.width + bodyContainer.x + Theme.paddingMedium > minDisplayWidth || summary.contentWidth + summary.x + Theme.paddingMedium > minDisplayWidth ? Math.min((body.width > summary.contentWidth ? body.width : summary.contentWidth) + bodyContainer.x + Theme.paddingMedium, notificationWindow.width - (previewSettings.style !== 2 ? 2*previewSettings.margin : 0), maxDisplayWidth) : Math.min(minDisplayWidth, notificationWindow.width - (previewSettings.style !== 2 ? 2*previewSettings.margin : 0)) property bool notificationShownNSteady: false objectName: "NotificationPreview_popupArea" anchors { top: parent.top horizontalCenter: previewSettings.position === 0 ? parent.horizontalCenter : undefined left: previewSettings.position === 1 ? parent.left : undefined right: previewSettings.position === 2 ? parent.right : undefined margins: previewSettings.style !== 2 ? previewSettings.margin : 0 } width: displayWidth height: Math.max(Theme.itemSizeSmall, summary.y*2 + summary.height + bodyContainer.anchors.topMargin + bodyContainer.height) opacity: 0 onWidthChanged: { if (notificationShownNSteady) { scrollAnimation.reset() if (scrollAnimation.initialize(body, bodyContainer)) { scrollAnimation.start() } } } onClicked: { if (clickAction !== undefined) { clickAction() } } onDownChanged: { if (!down) { if (!notificationTimer.running && !forceHideTimer.running) { notificationWindow.notificationExpired() } } } SequentialAnimation { id: dragAnimation NumberAnimation { id: slideAnimation property: "x" target: popupIcon } ScriptAction { script: { if (Math.abs(slideAnimation.to) === popupArea.drag.maximumX) { notificationWindow.state = "" notificationWindow.notificationExpired() } } } } Rectangle { anchors.fill: parent radius: previewSettings.style === 2 && (transpose && parent.width === Screen.height || !transpose && parent.width === Screen.width) ? 0 : previewSettings.corner_radius color: popupArea.down || popupArea.drag.active ? Qt.tint(Qt.tint(Theme.highlightBackgroundColor, Qt.rgba(0, 0, 0, 0.2)), Theme.rgba(Theme.highlightDimmerColor, 0.4)) : Qt.tint(Theme.highlightBackgroundColor, Qt.rgba(0, 0, 0, 0.2)) clip: true Behavior on radius { NumberAnimation {duration: 100; easing.type: Easing.InOutQuad} } Rectangle { height: parent.radius width: height color: parent.color anchors.top: parent.top anchors.left: parent.left visible: previewSettings.style > 0 && previewSettings.style < 4 } Rectangle { height: parent.radius width: height color: parent.color anchors.top: parent.top anchors.right: parent.right visible: previewSettings.style === 1 || previewSettings.style === 2 || previewSettings.style === 4 } Rectangle { height: parent.radius width: height color: parent.color anchors.bottom: parent.bottom anchors.left: parent.left visible: previewSettings.style === 1 || previewSettings.style === 2 && previewSettings.position === 1 } Rectangle { height: parent.radius width: height color: parent.color anchors.bottom: parent.bottom anchors.right: parent.right visible: previewSettings.style === 1 || previewSettings.style === 2 && previewSettings.position === 2 } Item { id: contentBase height: parent.height width: parent.width Label { id: summary anchors { top: parent.top topMargin: Theme.paddingMedium/2 left: popupIcon.right leftMargin: Theme.paddingMedium right: parent.right rightMargin: Theme.paddingMedium } color: popupArea.textColor opacity: popupArea.textOpacity truncationMode: TruncationMode.Fade font.pixelSize: Theme.fontSizeSmall visible: text.length height: visible ? implicitHeight : 0 textFormat: Text.PlainText maximumLineCount: 1 // Only show the first line of the summary, if there is more text: firstLine(notificationWindow.summaryText) } Item { id: bodyContainer anchors { top: summary.bottom topMargin: summary.visible ? 0 : -height / 2 left: summary.left right: summary.right } clip: true height: body.height Label { id: body width: contentWidth color: popupArea.textColor opacity: popupArea.textOpacity truncationMode: TruncationMode.None font.pixelSize: Theme.fontSizeExtraSmall visible: text.length height: visible ? implicitHeight : 0 textFormat: Text.PlainText maximumLineCount: 1 // Only show the first line of the body, if there is more text: firstLine(notificationWindow.bodyText) } } Image { id: popupIcon anchors { left: parent.left leftMargin: Theme.paddingMedium verticalCenter: parent.verticalCenter } width: Theme.iconSizeMedium fillMode: Image.PreserveAspectFit source: notificationWindow.iconUrl ? notificationWindow.iconUrl : 'image://theme/icon-lock-information' sourceSize.width: width layer.effect: PressEffect {} layer.enabled: popupArea.down || popupArea.drag.active } Image { id: leftIcon x: -(width + Theme.paddingSmall) anchors.verticalCenter: parent.verticalCenter source: (previewSettings.right_swipe_action === 1 ? "image://theme/icon-m-acknowledge?" : previewSettings.right_swipe_action === 2 ? "image://theme/icon-m-dismiss?" : "image://theme/icon-m-delete?") + (contentBase.x === popupArea.rightSwipeAcceptX ? Theme.highlightColor : Theme.primaryColor) } Image { id: rightIcon x: parent.width + Theme.paddingSmall anchors.verticalCenter: parent.verticalCenter source: (previewSettings.left_swipe_action === 1 ? "image://theme/icon-m-acknowledge?" : previewSettings.left_swipe_action === 2 ? "image://theme/icon-m-dismiss?" : "image://theme/icon-m-delete?") + (contentBase.x === popupArea.leftSwipeAcceptX ? Theme.highlightColor : Theme.primaryColor) } Image { id: topIcon y: -(height + Theme.paddingSmall) anchors.horizontalCenter: parent.horizontalCenter source: (previewSettings.down_swipe_action === 1 ? "image://theme/icon-m-acknowledge?" : previewSettings.down_swipe_action === 2 ? "image://theme/icon-m-dismiss?" : "image://theme/icon-m-delete?") + (contentBase.y === popupArea.downSwipeAcceptY ? Theme.highlightColor : Theme.primaryColor) } Behavior on x { id: behaviorX enabled: false NumberAnimation {duration: 300 / popupArea.drag.maximumX * Math.abs(contentBase.x); easing.type: Easing.InOutQuad} } Behavior on y { id: behaviorY enabled: false NumberAnimation {duration: 300 / popupArea.drag.maximumY * contentBase.y; easing.type: Easing.InOutQuad} } onXChanged: if (popupArea.drag.active && !popupArea.dragAxisDetermined) { popupArea.drag.axis = Math.abs(x) > y ? Drag.XAxis : Drag.YAxis popupArea.dragAxisDetermined = true } onYChanged: if (popupArea.drag.active && !popupArea.dragAxisDetermined) { popupArea.drag.axis = y > Math.abs(x) ? Drag.YAxis : Drag.XAxis popupArea.dragAxisDetermined = true } } } property bool dragAxisDetermined: false property var clickAction: { previewSettings.click_action === 0 ? undefined : previewSettings.click_action === 1 ? notificationAction : previewSettings.click_action === 2 ? dismissPreview : removeNotification } property int rightSwipeAcceptX: leftIcon.width + Theme.paddingMedium property var rightSwipeAction: { previewSettings.right_swipe_action === 0 ? undefined : previewSettings.right_swipe_action === 1 ? notificationAction : previewSettings.right_swipe_action === 2 ? dismissPreview : removeNotification } property int leftSwipeAcceptX: -(rightIcon.width + Theme.paddingMedium) property var leftSwipeAction: { previewSettings.left_swipe_action === 0 ? undefined : previewSettings.left_swipe_action === 1 ? notificationAction : previewSettings.left_swipe_action === 2 ? dismissPreview : removeNotification } property int downSwipeAcceptY: topIcon.height + Theme.paddingMedium property var downSwipeAction: { previewSettings.down_swipe_action === 0 ? undefined : previewSettings.down_swipe_action === 1 ? notificationAction : previewSettings.down_swipe_action === 2 ? dismissPreview : removeNotification } clip: true drag.target: contentBase drag.axis: Drag.XAndYAxis drag.maximumX: rightSwipeAction !== undefined ? rightSwipeAcceptX : 0 drag.minimumX: leftSwipeAction !== undefined ? leftSwipeAcceptX : 0 drag.maximumY: downSwipeAction !== undefined ? downSwipeAcceptY : 0 drag.minimumY: 0 drag.onActiveChanged: { if (!drag.active) { if (contentBase.x === rightSwipeAcceptX && rightSwipeAction !== undefined) { rightSwipeAction() } if (contentBase.x === leftSwipeAcceptX && leftSwipeAction !== undefined) { leftSwipeAction() } if (contentBase.y === downSwipeAcceptY && downSwipeAction !== undefined) { downSwipeAction() } behaviorX.enabled = true behaviorY.enabled = true contentBase.x = 0 contentBase.y = 0 drag.axis = Drag.XAndYAxis dragAxisDetermined = false console.log("Drag inactive") } else { behaviorX.enabled = false behaviorY.enabled = false } } drag.onAxisChanged: { if (drag.axis === Drag.XAxis) { contentBase.y = 0 } else if (drag.axis === Drag.YAxis) { contentBase.x = 0 } } } MouseArea { id: bannerArea property real contentOpacity: 0 width: Math.max(parent.width, bannerText.x + bannerText.width + Theme.paddingMedium) height: Lipstick.compositor.homeLayer.statusBar.height y: -height onClicked: notificationWindow.notificationExpired() Rectangle { anchors.fill: parent color: Theme.overlayBackgroundColor opacity: 0.6 } Image { id: bannerIcon anchors { verticalCenter: bannerArea.verticalCenter left: bannerArea.left leftMargin: Theme.horizontalPageMargin } source: notificationWindow.iconUrl sourceSize.height: height height: Theme.iconSizeExtraSmall fillMode: Image.PreserveAspectFit opacity: bannerArea.contentOpacity } Label { id: bannerText anchors { verticalCenter: bannerIcon.verticalCenter left: bannerIcon.right leftMargin: Theme.paddingMedium } width: contentWidth truncationMode: TruncationMode.None font.pixelSize: Theme.fontSizeExtraSmall // If summary text but no body, use the summary as the body text: firstLine(notificationWindow.bodyText || notificationWindow.summaryText) visible: text != "" textFormat: Text.PlainText maximumLineCount: 1 opacity: bannerArea.contentOpacity } } Loader { id: ambiencePreviewLoader } Component { id: ambiencePreviewComponent AmbiencePreview { onFinished: notificationWindow.notificationComplete() } } Binding { target: notificationFeedbackPlayer property: "minimumPriority" value: lipstickSettings.lockscreenVisible ? 100 : 0 } Timer { id: displayTimer interval: 0 repeat: false onTriggered: displayNotification() } Timer { id: forceHideTimer interval: 7000 repeat: false onTriggered: { notificationTimer.interval = 3000 notificationTimer.start() } } Timer { id: notificationTimer repeat: false onTriggered: notificationWindow.notificationExpired() } onNotificationChanged: { if (notification) { // Show notification only then unlocked or locked, so no notifications // in ManagerLockout, TemporaryLockout, PermanentLockout, or Undefined if (Desktop.deviceLockState == DeviceLock.Unlocked || Desktop.deviceLockState == DeviceLock.Locked) { displayTimer.restart() } else { // need to acknowledge all notifications notificationComplete() } } else if (state != "") { notificationExpired() } } function refreshPeriod() { if (scrollAnimation.running || notificationTimer.running || forceHideTimer.running) { forceHideTimer.stop() scrollAnimation.reset() // If the notification is already showing, restart the display period with a shorter timeout notificationShown(notificationTimer.running ? 3000 : 5000) } } onSummaryTextChanged: refreshPeriod() onBodyTextChanged: refreshPeriod() onIconUrlChanged: refreshPeriod() function displayNotification() { // We use two different presentation styles: one that can be clicked and one that cannot. // Check for configurations that can't be correctly activated if (notification.remoteActions.length == 0) { if (notification.previewSummary && notification.previewBody) { // Notifications with preview summary + preview body should have actions, as tapping on the preview pop-up should trigger some action console.log('Warning: Notification has both preview summary and preview body but no actions. Remove the preview body or add an action:', notification.appName, notification.category, notification.previewSummary, notification.previewBody) } } else { if (notification.previewSummary && !notification.previewBody) { // Notifications with preview summary but no body should not have any actions, as the small preview banner is too small to receive presses console.log('Warning: Notification has an action but only shows a preview summary. Add a preview body or remove the actions:', notification.appName, notification.category, notification.previewSummary, notification.previewBody) } else if ((!notification.previewSummary && !notification.previewBody) && notification.hints['transient'] == true) { console.log('Warning: Notification has actions but is transient and without a preview, its actions will not be triggerable from the UI:', notification.appName, notification.category, notification.previewSummary, notification.previewBody) } } if (showNotification) { if (notification.category === "x-jolla.ambience.preview") { ambiencePreviewLoader.sourceComponent = ambiencePreviewComponent var preview = ambiencePreviewLoader.item if (preview) { preview.displayName = notification.previewSummary preview.coverImage = notification.previewBody preview.show() state = "showAmbience" } } else { // Show preview banner or pop-up var hasActions = notification.remoteActions.length > 0 var hasMultipleLines = (notification.previewSummary.length > 0 && notification.previewBody.length > 0) state = hasActions || hasMultipleLines ? "showPopup" : "showBanner" } } } function notificationShown(timeout) { // Min 1sec and max 5secs if (!timeout && notification.expireTimeout > 0) { timeout = Math.min(Math.max(notification.expireTimeout, 1000), 5000) } else { timeout = previewSettings.timeout } var scroll = false if (state == "showPopup") { scroll = scrollAnimation.initialize(body, bodyContainer) popupArea.notificationShownNSteady = true } else if (state == "showBanner") { scroll = scrollAnimation.initialize(bannerArea, notificationWindow) } if (scroll) { scrollAnimation.start() forceHideTimer.start() } else { notificationTimer.interval = timeout notificationTimer.restart() } } function notificationExpired() { forceHideTimer.stop() notificationTimer.stop() if (state == "showPopup") { state = "hidePopup" } else if (state == "showBanner") { state = "hideBanner" } else { notificationComplete() } } function notificationComplete() { state = "" _invoked = false notificationPreviewPresenter.showNextNotification() } states: [ State { name: "showPopup" PropertyChanges { target: notificationWindow opacity: 1 visible: true } PropertyChanges { target: popupArea opacity: 1 textOpacity: 1 } PropertyChanges { target: body x: 0 } }, State { name: "hidePopup" PropertyChanges { target: notificationWindow opacity: 1 visible: true } PropertyChanges { target: body // Keep the body at whatever scroll position it is currently in x: body.x } }, State { name: "showBanner" PropertyChanges { target: notificationWindow opacity: 1 visible: true } PropertyChanges { target: bannerArea y: 0 x: 0 contentOpacity: 1 } }, State { name: "hideBanner" PropertyChanges { target: notificationWindow opacity: 1 visible: true } PropertyChanges { target: bannerArea // Keep the text at whatever scroll position it is currently in x: bannerArea.x contentOpacity: 1 } }, State { name: "showAmbience" PropertyChanges { target: notificationWindow opacity: 1 visible: true } } ] transitions: [ Transition { to: "showPopup" SequentialAnimation { ParallelAnimation { NumberAnimation { target: popupArea property: "width" duration: 200 from: popupArea.displayWidth * 0.9 to: popupArea.displayWidth easing.type: Easing.OutQuad } SequentialAnimation { NumberAnimation { target: popupArea property: "opacity" duration: 50 } NumberAnimation { target: popupArea property: "textOpacity" duration: 150 } } } ScriptAction { script: notificationWindow.notificationShown() } } }, Transition { to: "hidePopup" SequentialAnimation { ScriptAction {script: popupArea.notificationShownNSteady = false} ParallelAnimation { SequentialAnimation { NumberAnimation { target: popupArea property: "textOpacity" duration: 150 } NumberAnimation { target: popupArea property: "opacity" duration: 50 } } NumberAnimation { target: popupArea property: "width" duration: 200 from: popupArea.displayWidth to: popupArea.displayWidth * 0.9 easing.type: Easing.InQuad } } NumberAnimation { target: popupIcon property: "x" duration: 150 easing.type: Easing.InQuad } ScriptAction { script: { if (removeRequested) { notification.removeRequested() removeRequested = false } notificationWindow.notificationComplete() } } } }, Transition { to: "showBanner" SequentialAnimation { ParallelAnimation { PropertyAnimation { target: bannerArea property: "y" duration: 200 easing.type: Easing.OutQuad } SequentialAnimation { PauseAnimation { duration: 150 } PropertyAnimation { target: bannerArea property: "contentOpacity" duration: 150 } } } ScriptAction { script: { notificationWindow.notificationShown() } } } }, Transition { to: "hideBanner" SequentialAnimation { PropertyAnimation { target: bannerArea property: "y" duration: 200 easing.type: Easing.OutQuad } ScriptAction { script: notificationWindow.notificationComplete() } } } ] SequentialAnimation { id: scrollAnimation function initialize(targetItem, containerItem) { target = targetItem container = containerItem return range > 0 } function reset() { stop() if (target) { target.x = 0 target = null } } property Item target property Item container property real range: target && container ? target.width - container.width : 0 property real speed: 120 property real accelerationDuration: 500 PauseAnimation { duration: 2000 } PropertyAnimation { id: startScrollingAnimation target: scrollAnimation.target property: "x" from: 0 to: Math.max(-scrollAnimation.range / 2, -scrollAnimation.speed * scrollAnimation.accelerationDuration / 1000 / 2) duration: (to - from) < 0 ? scrollAnimation.accelerationDuration : 0 easing.type: Easing.InQuad } PropertyAnimation { property real animationDuration: -(to - from) * 1000 / scrollAnimation.speed target: scrollAnimation.target property: "x" from: startScrollingAnimation.to to: stopScrollingAnimation.from duration: Math.max(animationDuration, 0) easing.type: Easing.Linear } PropertyAnimation { id: stopScrollingAnimation target: scrollAnimation.target property: "x" from: Math.min(-scrollAnimation.range / 2, -scrollAnimation.range + scrollAnimation.speed * scrollAnimation.accelerationDuration / 1000 / 2) to: -scrollAnimation.range duration: (to - from) < 0 ? scrollAnimation.accelerationDuration : 0 easing.type: Easing.OutQuad } ScriptAction { script: { scrollAnimation.target = null notificationTimer.interval = 2000 notificationTimer.start() } } } }