Lomiri
Notification.qml
1 /*
2  * Copyright (C) 2013-2016 Canonical Ltd.
3  *
4  * This program is free software; you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation; version 3.
7  *
8  * This program is distributed in the hope that it will be useful,
9  * but WITHOUT ANY WARRANTY; without even the implied warranty of
10  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11  * GNU General Public License for more details.
12  *
13  * You should have received a copy of the GNU General Public License
14  * along with this program. If not, see <http://www.gnu.org/licenses/>.
15  */
16 
17 import QtQuick 2.12
18 import QtGraphicalEffects 1.12
19 import Powerd 0.1
20 import Lomiri.Components 1.3
21 import Lomiri.Components.ListItems 1.3 as ListItem
22 import Lomiri.Notifications 1.0
23 import QMenuModel 1.0
24 import Utils 0.1
25 import "../Components"
26 
27 StyledItem {
28  id: notification
29 
30  property alias iconSource: icon.fileSource
31  property alias secondaryIconSource: secondaryIcon.source
32  property alias summary: summaryLabel.text
33  property alias body: bodyLabel.text
34  property alias value: valueIndicator.value
35  property var actions
36  property var notificationId
37  property var type
38  property var hints
39  property var notification
40  property color color: theme.palette.normal.background
41  property bool fullscreen: notification.notification && typeof notification.notification.fullscreen != "undefined" ?
42  notification.notification.fullscreen : false // fullscreen prop only exists in the mock
43  property int maxHeight
44  property int margins: units.gu(1)
45  property bool privacyMode: false
46  property bool hideContent: notification.privacyMode &&
47  notification.notification.urgency !== Notification.Critical &&
48  (notification.type === Notification.Ephemeral || notification.type === Notification.Interactive)
49 
50 
51  readonly property real defaultOpacity: 1.0
52  property bool hasMouse
53  property url background: ""
54 
55  objectName: "background"
56  implicitHeight: type !== Notification.PlaceHolder ? (fullscreen ? maxHeight : outterColumn.height + shapedBack.anchors.topMargin + margins * 2) : 0
57 
58  // FIXME: non-zero initially because of LP: #1354406 workaround, we want this to start at 0 upon creation eventually
59  opacity: defaultOpacity - Math.abs(x / notification.width)
60 
61  theme: ThemeSettings {
62  name: "Lomiri.Components.Themes.Ambiance"
63  }
64 
65  readonly property bool expanded: type === Notification.SnapDecision && // expand only snap decisions, if...
66  (fullscreen || // - it's a fullscreen one
67  ListView.view.currentIndex === index || // - it's the one the user clicked on
68  (ListView.view.currentIndex === -1 && index == 0) // - the first one after the user closed the previous one
69  )
70 
71  NotificationAudio {
72  id: sound
73  objectName: "sound"
74  source: hints["suppress-sound"] !== "true" && hints["sound-file"] !== undefined ? hints["sound-file"] : ""
75  }
76 
77  Component.onCompleted: {
78  if (type === Notification.PlaceHolder) {
79  return;
80  }
81 
82  // Turn on screen as needed (Powerd.Notification means the screen
83  // stays on for a shorter amount of time)
84  if (type === Notification.SnapDecision) {
85  Powerd.setStatus(Powerd.On, Powerd.SnapDecision);
86  } else if (type !== Notification.Confirmation) {
87  Powerd.setStatus(Powerd.On, Powerd.Notification);
88  }
89 
90  // FIXME: using onCompleted because of LP: #1354406 workaround, has to be onOpacityChanged really
91  if (opacity == defaultOpacity && hints["suppress-sound"] !== "true" && sound.source !== "") {
92  sound.play();
93  }
94  }
95 
96  Component.onDestruction: {
97  if (type === Notification.PlaceHolder) {
98  return;
99  }
100 
101  if (type === Notification.SnapDecision) {
102  Powerd.setStatus(Powerd.Off, Powerd.SnapDecision);
103  } else if (type !== Notification.Confirmation) {
104  Powerd.setStatus(Powerd.Off, Powerd.Notification);
105  }
106  }
107 
108  function closeNotification() {
109  if (index === ListView.view.currentIndex) { // reset to get the 1st snap decision expanded
110  ListView.view.currentIndex = -1;
111  }
112 
113  // perform the "reject" action
114  notification.notification.invokeAction(notification.actions.data(1, ActionModel.RoleActionId));
115 
116  notification.notification.close();
117  }
118 
119  Behavior on x {
120  LomiriNumberAnimation { easing.type: Easing.OutBounce }
121  }
122 
123  onHintsChanged: {
124  if (type === Notification.Confirmation && opacity == defaultOpacity && hints["suppress-sound"] !== "true" && sound.source !== "") {
125  sound.play();
126  }
127  }
128 
129  onFullscreenChanged: {
130  if (fullscreen) {
131  notification.notification.urgency = Notification.Critical;
132  }
133  if (index == 0) {
134  ListView.view.topmostIsFullscreen = fullscreen;
135  }
136  }
137 
138  Behavior on implicitHeight {
139  enabled: !fullscreen
140  LomiriNumberAnimation {
141  duration: LomiriAnimation.SnapDuration
142  }
143  }
144 
145  visible: type !== Notification.PlaceHolder
146 
147  BorderImage {
148  anchors {
149  fill: contents
150  margins: shapedBack.visible ? -units.gu(1) : -units.gu(1.5)
151  }
152  source: "../graphics/dropshadow2gu.sci"
153  opacity: notification.opacity * 0.5
154  enabled: !fullscreen
155  }
156 
157  LomiriShape {
158  id: shapedBack
159  objectName: "shapedBack"
160 
161  visible: !fullscreen
162  anchors {
163  fill: parent
164  leftMargin: notification.margins
165  rightMargin: notification.margins
166  topMargin: index == 0 ? notification.margins : 0
167  }
168  backgroundColor: parent.color
169  radius: "small"
170  aspect: LomiriShape.Flat
171  }
172 
173  Rectangle {
174  id: nonShapedBack
175 
176  visible: fullscreen
177  anchors.fill: parent
178  color: parent.color
179  }
180 
181  onXChanged: {
182  if (Math.abs(notification.x) > 0.75 * notification.width) {
183  closeNotification();
184  }
185  }
186 
187  Item {
188  id: contents
189  anchors.fill: fullscreen ? nonShapedBack : shapedBack
190 
191  LomiriMenuModelPaths {
192  id: paths
193 
194  source: hints["x-lomiri-private-menu-model"]
195 
196  busNameHint: "busName"
197  actionsHint: "actions"
198  menuObjectPathHint: "menuPath"
199  }
200 
201  AyatanaMenuModel {
202  id: lomiriMenuModel
203 
204  property string lastNameOwner: ""
205 
206  busName: paths.busName
207  actions: paths.actions
208  menuObjectPath: paths.menuObjectPath
209  onNameOwnerChanged: {
210  if (lastNameOwner !== "" && nameOwner === "" && notification.notification !== undefined) {
211  notification.notification.close()
212  }
213  lastNameOwner = nameOwner
214  }
215  }
216 
217  MouseArea {
218  id: interactiveArea
219 
220  anchors.fill: parent
221  objectName: "interactiveArea"
222 
223  drag.target: !fullscreen ? notification : undefined
224  drag.axis: Drag.XAxis
225  drag.minimumX: -notification.width
226  drag.maximumX: notification.width
227  hoverEnabled: true
228 
229  onClicked: {
230  if (notification.type === Notification.Interactive) {
231  notification.notification.invokeAction(actionRepeater.itemAt(0).actionId)
232  } else {
233  notification.ListView.view.currentIndex = index;
234  }
235  }
236  onReleased: {
237  if (Math.abs(notification.x) < notification.width / 2) {
238  notification.x = 0
239  } else {
240  notification.x = notification.width
241  }
242  }
243  }
244 
245  NotificationButton {
246  objectName: "closeButton"
247  width: units.gu(2)
248  height: width
249  radius: width / 2
250  visible: hasMouse && (containsMouse || interactiveArea.containsMouse)
251  iconName: "close"
252  outline: false
253  hoverEnabled: true
254  color: theme.palette.normal.negative
255  anchors.horizontalCenter: parent.left
256  anchors.horizontalCenterOffset: notification.parent.state === "narrow" ? notification.margins / 2 : 0
257  anchors.verticalCenter: parent.top
258  anchors.verticalCenterOffset: notification.parent.state === "narrow" ? notification.margins / 2 : 0
259 
260  onClicked: closeNotification();
261  }
262 
263  Column {
264  id: outterColumn
265  objectName: "outterColumn"
266 
267  anchors {
268  left: parent.left
269  right: parent.right
270  top: parent.top
271  margins: !fullscreen ? notification.margins : 0
272  }
273 
274  spacing: notification.margins
275 
276  Row {
277  id: topRow
278 
279  spacing: notification.margins
280  anchors {
281  left: parent.left
282  right: parent.right
283  }
284 
285  Item {
286  id: iconWrapper
287  width: units.gu(6)
288  height: width
289  visible: iconSource !== "" && type !== Notification.Confirmation
290  ShapedIcon {
291  id: icon
292 
293  objectName: "icon"
294  anchors.fill: parent
295  shaped: notification.hints["x-lomiri-non-shaped-icon"] !== "true"
296  visible: iconSource !== "" && !blurEffect.visible
297  }
298 
299  FastBlur {
300  id: blurEffect
301  objectName: "blurEffect"
302  visible: notification.hideContent
303  anchors.fill: icon
304  source: icon
305  transparentBorder: true
306  radius: 64
307  }
308  }
309 
310  Label {
311  objectName: "privacySummaryLabel"
312  width: secondaryIcon.visible ? parent.width - x - units.gu(3) : parent.width - x
313  height: units.gu(6)
314  anchors.verticalCenter: iconWrapper.verticalCenter
315  verticalAlignment: Text.AlignVCenter
316  visible: notification.hideContent
317  fontSize: "medium"
318  font.weight: Font.Light
319  color: theme.palette.normal.backgroundSecondaryText
320  elide: Text.ElideRight
321  textFormat: Text.PlainText
322  text: i18n.tr("New message")
323  }
324 
325  Column {
326  id: labelColumn
327  width: secondaryIcon.visible ? parent.width - x - units.gu(3) : parent.width - x
328  anchors.verticalCenter: (icon.visible && !bodyLabel.visible) ? iconWrapper.verticalCenter : undefined
329  spacing: units.gu(.4)
330  visible: type !== Notification.Confirmation && !notification.hideContent
331 
332  Label {
333  id: summaryLabel
334 
335  objectName: "summaryLabel"
336  anchors {
337  left: parent.left
338  right: parent.right
339  }
340  fontSize: "medium"
341  font.weight: Font.Light
342  color: theme.palette.normal.backgroundSecondaryText
343  elide: Text.ElideRight
344  textFormat: Text.PlainText
345  }
346 
347  Label {
348  id: bodyLabel
349 
350  objectName: "bodyLabel"
351  anchors {
352  left: parent.left
353  right: parent.right
354  }
355  visible: body != ""
356  fontSize: "small"
357  font.weight: Font.Light
358  color: theme.palette.normal.backgroundTertiaryText
359  wrapMode: Text.Wrap
360  maximumLineCount: {
361  if (type === Notification.SnapDecision) {
362  return 12;
363  } else if (notification.hints["x-lomiri-truncation"] === false) {
364  return 20;
365  } else {
366  return 2;
367  }
368  }
369  elide: Text.ElideRight
370  textFormat: Text.PlainText
371  lineHeight: 1.1
372  }
373  }
374 
375  Image {
376  id: secondaryIcon
377 
378  objectName: "secondaryIcon"
379  width: units.gu(2)
380  height: width
381  visible: status === Image.Ready
382  fillMode: Image.PreserveAspectCrop
383  }
384  }
385 
386  ListItem.ThinDivider {
387  visible: type === Notification.SnapDecision && notification.expanded
388  }
389 
390  Icon {
391  name: "toolkit_chevron-down_3gu"
392  visible: type === Notification.SnapDecision && !notification.expanded
393  width: units.gu(2)
394  height: width
395  anchors.horizontalCenter: parent.horizontalCenter
396  color: theme.palette.normal.base
397  }
398 
399  ShapedIcon {
400  id: centeredIcon
401  objectName: "centeredIcon"
402  width: units.gu(4)
403  height: width
404  shaped: notification.hints["x-lomiri-non-shaped-icon"] !== "true"
405  fileSource: icon.fileSource
406  visible: fileSource !== "" && type === Notification.Confirmation
407  anchors.horizontalCenter: parent.horizontalCenter
408  }
409 
410  Label {
411  id: valueLabel
412  objectName: "valueLabel"
413  text: body
414  anchors.horizontalCenter: parent.horizontalCenter
415  visible: type === Notification.Confirmation && body !== ""
416  fontSize: "medium"
417  font.weight: Font.Light
418  color: theme.palette.normal.backgroundSecondaryText
419  wrapMode: Text.WordWrap
420  maximumLineCount: 1
421  elide: Text.ElideRight
422  textFormat: Text.PlainText
423  }
424 
425  ProgressBar {
426  id: valueIndicator
427  objectName: "valueIndicator"
428  visible: type === Notification.Confirmation
429  minimumValue: 0
430  maximumValue: 100
431  showProgressPercentage: false
432  anchors {
433  left: parent.left
434  right: parent.right
435  }
436  height: units.gu(1)
437  }
438 
439  Column {
440  id: dialogColumn
441  objectName: "dialogListView"
442  spacing: notification.margins
443 
444  visible: count > 0 && (notification.expanded || notification.fullscreen)
445 
446  anchors {
447  left: parent.left
448  right: parent.right
449  top: fullscreen ? parent.top : undefined
450  bottom: fullscreen ? parent.bottom : undefined
451  }
452 
453  Repeater {
454  model: lomiriMenuModel
455 
456  NotificationMenuItemFactory {
457  id: menuItemFactory
458 
459  anchors {
460  left: dialogColumn.left
461  right: dialogColumn.right
462  }
463 
464  menuModel: lomiriMenuModel
465  menuData: model
466  menuIndex: index
467  maxHeight: notification.maxHeight
468  background: notification.background
469 
470  onLoaded: {
471  notification.fullscreen = Qt.binding(function() { return fullscreen; });
472  }
473  onAccepted: {
474  notification.notification.invokeAction(actionRepeater.itemAt(0).actionId)
475  }
476  }
477  }
478  }
479 
480  Column {
481  id: oneOverTwoCase
482 
483  anchors {
484  left: parent.left
485  right: parent.right
486  }
487 
488  spacing: notification.margins
489 
490  visible: notification.type === Notification.SnapDecision && oneOverTwoRepeaterTop.count === 3 && notification.expanded
491 
492  Repeater {
493  id: oneOverTwoRepeaterTop
494 
495  model: notification.actions
496  delegate: Loader {
497  id: oneOverTwoLoaderTop
498 
499  property string actionId: id
500  property string actionLabel: label
501 
502  Component {
503  id: oneOverTwoButtonTop
504 
505  NotificationButton {
506  objectName: "notify_oot_button" + index
507  width: oneOverTwoCase.width
508  text: oneOverTwoLoaderTop.actionLabel
509  outline: notification.hints["x-lomiri-private-affirmative-tint"] !== "true"
510  color: notification.hints["x-lomiri-private-affirmative-tint"] === "true" ? theme.palette.normal.positive
511  : theme.name == "Lomiri.Components.Themes.SuruDark" ? "#888"
512  : "#666"
513  onClicked: notification.notification.invokeAction(oneOverTwoLoaderTop.actionId)
514  }
515  }
516  sourceComponent: index == 0 ? oneOverTwoButtonTop : undefined
517  }
518  }
519 
520  Row {
521  spacing: notification.margins
522 
523  Repeater {
524  id: oneOverTwoRepeaterBottom
525 
526  model: notification.actions
527  delegate: Loader {
528  id: oneOverTwoLoaderBottom
529 
530  property string actionId: id
531  property string actionLabel: label
532 
533  Component {
534  id: oneOverTwoButtonBottom
535 
536  NotificationButton {
537  objectName: "notify_oot_button" + index
538  width: oneOverTwoCase.width / 2 - spacing / 2
539  text: oneOverTwoLoaderBottom.actionLabel
540  outline: notification.hints["x-lomiri-private-rejection-tint"] !== "true"
541  color: index == 1 && notification.hints["x-lomiri-private-rejection-tint"] === "true" ? theme.palette.normal.negative
542  : theme.name == "Lomiri.Components.Themes.SuruDark" ? "#888"
543  : "#666"
544  onClicked: notification.notification.invokeAction(oneOverTwoLoaderBottom.actionId)
545  }
546  }
547  sourceComponent: (index == 1 || index == 2) ? oneOverTwoButtonBottom : undefined
548  }
549  }
550  }
551  }
552 
553  Row {
554  id: buttonRow
555 
556  objectName: "buttonRow"
557  anchors {
558  left: parent.left
559  right: parent.right
560  }
561  visible: notification.type === Notification.SnapDecision && actionRepeater.count > 0 && !oneOverTwoCase.visible && notification.expanded
562  spacing: notification.margins
563  layoutDirection: Qt.RightToLeft
564 
565  Loader {
566  id: notifySwipeButtonLoader
567  active: notification.hints["x-lomiri-snap-decisions-swipe"] === "true"
568 
569  sourceComponent: SwipeToAct {
570  objectName: "notify_swipe_button"
571  width: buttonRow.width
572  leftIconName: "call-end"
573  rightIconName: "call-start"
574  clickToAct: notification.hasMouse
575  onRightTriggered: {
576  notification.notification.invokeAction(notification.actions.data(0, ActionModel.RoleActionId))
577  }
578 
579  onLeftTriggered: {
580  notification.notification.invokeAction(notification.actions.data(1, ActionModel.RoleActionId))
581  }
582  }
583  }
584 
585  Repeater {
586  id: actionRepeater
587  model: notification.actions
588  delegate: Loader {
589  id: loader
590 
591  property string actionId: id
592  property string actionLabel: label
593  active: !notifySwipeButtonLoader.active
594 
595  Component {
596  id: actionButton
597 
598  NotificationButton {
599  objectName: "notify_button" + index
600  width: buttonRow.width / 2 - spacing / 2
601  text: loader.actionLabel
602  outline: (index == 0 && notification.hints["x-lomiri-private-affirmative-tint"] !== "true") ||
603  (index == 1 && notification.hints["x-lomiri-private-rejection-tint"] !== "true")
604  color: {
605  var result = "#666";
606  if (theme.name == "Lomiri.Components.Themes.SuruDark") {
607  result = "#888"
608  }
609  if (index == 0 && notification.hints["x-lomiri-private-affirmative-tint"] === "true") {
610  result = theme.palette.normal.positive;
611  }
612  if (index == 1 && notification.hints["x-lomiri-private-rejection-tint"] === "true") {
613  result = theme.palette.normal.negative;
614  }
615  return result;
616  }
617  onClicked: notification.notification.invokeAction(loader.actionId)
618  }
619  }
620  sourceComponent: (index == 0 || index == 1) ? actionButton : undefined
621  }
622  }
623  }
624 
625  OptionToggle {
626  id: optionToggle
627  objectName: "notify_button2"
628  width: parent.width
629  anchors {
630  left: parent.left
631  right: parent.right
632  }
633 
634  visible: notification.type === Notification.SnapDecision && actionRepeater.count > 3 && !oneOverTwoCase.visible && notification.expanded
635  model: notification.actions
636  expanded: false
637  startIndex: 2
638  onTriggered: {
639  notification.notification.invokeAction(id)
640  }
641  }
642  }
643  }
644 }