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