on xenial help sign in
Replace ComboButton in snap-decision notifications, used for additional actions (> 3 actions), with custom widget immune to accidental taps. Fixes: 1384730

Approved by: Albert Astals Cid

commite5613f8ed79a8f67fb8c97c85b52e96999ce5739 authorMirco Müller <mirco.mueller@ubuntu.com> dateFri 7 Nov 2014, 14:31 (12 years ago) parent022cf0e24a6b1f
5 files changed+418−153
qml/Notifications/Notification.qml+8−99 qml/Notifications/OptionToggle.qml+123 tests/qmltests/CMakeLists.txt+1 tests/qmltests/Notifications/tst_Notifications.qml−54 tests/qmltests/Notifications/tst_OptionToggle.qml+286
qml/Notifications/Notification.qml+8−99
@@ -101,13 +101,8 @@ Item {
101101
102102 //enabled: menuItemFactory.progress == 1
103103 enabled: true
104− SequentialAnimation {
105− PauseAnimation {
106− duration: UbuntuAnimation.SnapDuration
107− }
108− UbuntuNumberAnimation {
109− duration: UbuntuAnimation.SnapDuration
110− }
104+ UbuntuNumberAnimation {
105+ duration: UbuntuAnimation.SnapDuration
111106 }
112107 }
113108
@@ -181,18 +176,6 @@ Item {
181176 }
182177 }
183178
184− Behavior on implicitHeight {
185− id: heightBehavior
186−
187− enabled: false
188− UbuntuNumberAnimation {
189− duration: UbuntuAnimation.SnapDuration
190− }
191− }
192−
193− // delay enabling height behavior until the add transition is complete
194− onOpacityChanged: if (opacity == 1) heightBehavior.enabled = true
195−
196179 MouseArea {
197180 id: interactiveArea
198181
@@ -522,9 +505,8 @@ Item {
522505 }
523506 }
524507
525− ComboButton {
526− id: comboButton
527−
508+ OptionToggle {
509+ id: optionToggle
528510 objectName: "notify_button2"
529511 width: parent.width
530512 anchors {
@@ -534,84 +516,11 @@ Item {
534516 }
535517
536518 visible: notification.type == Notification.SnapDecision && actionRepeater.count > 3 && !oneOverTwoCase.visible
537− color: sdDarkGrey
538− onClicked: notification.notification.invokeAction(comboRepeater.itemAt(2).actionId)
519+ model: notification.actions
539520 expanded: false
540− expandedHeight: (comboRepeater.count - 2) * units.gu(4) + units.gu(.5)
541− comboList: Flickable {
542− // this has to be wrapped inside a flickable
543− // to work around a feature/bug? of the
544− // ComboButton SDK-element, making a regular
545− // unwrapped Column item flickable
546− // see LP: #1332590
547− interactive: false
548− Column {
549− Repeater {
550− id: comboRepeater
551−
552− onVisibleChanged: {
553− comboButton.text = comboRepeater.count >= 3 ? comboRepeater.itemAt(2).actionLabel : ""
554− }
555−
556− model: notification.actions
557− delegate: Loader {
558− id: comboLoader
559−
560− asynchronous: true
561− visible: status == Loader.Ready
562− property string actionId: id
563− property string actionLabel: label
564− readonly property var splitLabel: actionLabel.match(/(^([-a-z0-9]+):)?(.*)$/)
565− Component {
566− id: comboEntry
567−
568− MouseArea {
569− id: comboInputArea
570−
571− objectName: "notify_button" + index
572− width: comboButton.width
573− height: comboIcon.height + units.gu(2)
574−
575− onClicked: {
576− notification.notification.invokeAction(actionId)
577− }
578−
579− ListItem.ThinDivider {
580− visible: index > 3
581− }
582−
583− Icon {
584− id: comboIcon
585−
586− anchors {
587− left: parent.left
588− leftMargin: units.gu(.5)
589− verticalCenter: parent.verticalCenter
590− }
591− width: units.gu(2)
592− height: units.gu(2)
593− color: sdFontColor
594− name: splitLabel[2]
595− }
596−
597− Label {
598− id: comboLabel
599−
600− anchors {
601− left: comboIcon.right
602− leftMargin: units.gu(1)
603− verticalCenter: comboIcon.verticalCenter
604− }
605− fontSize: "small"
606− color: sdFontColor
607− text: splitLabel[3]
608− }
609− }
610− }
611− sourceComponent: (index > 2) ? comboEntry : undefined
612− }
613− }
614− }
521+ startIndex: 2
522+ onTriggered: {
523+ notification.notification.invokeAction(id)
615524 }
616525 }
617526 }
qml/Notifications/OptionToggle.qmlnew file+123
@@ -0,0 +1,123 @@
1+/*
2+ * Copyright (C) 2014 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.0
18+import Ubuntu.Components 0.1
19+import Ubuntu.Components.ListItems 0.1 as ListItem
20+import Ubuntu.Components.Popups 0.1
21+
22+UbuntuShape {
23+ id: optionToggle
24+
25+ property bool expanded
26+ property var model
27+ property int startIndex
28+ readonly property double itemHeight: units.gu(5)
29+
30+ signal triggered(string id)
31+
32+ color: Theme.palette.normal.base
33+ borderSource: "none"
34+ height: expanded ? (optionToggleRepeater.count - startIndex) * itemHeight : itemHeight
35+ width: parent.width
36+ radius: "medium"
37+ clip: true
38+
39+ Column {
40+ id: optionToggleContent
41+ width: parent.width
42+
43+ Repeater {
44+ id: optionToggleRepeater
45+ model: optionToggle.model
46+
47+ delegate: Loader {
48+ asynchronous: true
49+ visible: status === Loader.Ready
50+ property string actionLabel: label
51+ property string actionId: id
52+ readonly property var splitLabel: actionLabel.match(/(^([-a-z0-9]+):)?(.*)$/)
53+
54+ Component {
55+ id: optionToggleEntry
56+
57+ AbstractButton {
58+ objectName: "notify_button" + index
59+ width: optionToggleContent.width
60+ height: optionToggle.itemHeight
61+
62+ onClicked: {
63+ if (index === startIndex) {
64+ optionToggle.expanded = !optionToggle.expanded
65+ } else {
66+ optionToggle.triggered(actionId)
67+ }
68+ }
69+
70+ ListItem.ThinDivider {
71+ visible: index > startIndex
72+ }
73+
74+ Icon {
75+ id: delegateIcon
76+ anchors {
77+ left: parent.left
78+ leftMargin: units.gu(2)
79+ verticalCenter: parent.verticalCenter
80+ }
81+ visible: index !== startIndex
82+ width: units.gu(2)
83+ height: width
84+ name: splitLabel[2] !== undefined ? splitLabel[2] : ""
85+ }
86+
87+ Label {
88+ id: delegateLabel
89+ anchors {
90+ left: delegateIcon.visible ? delegateIcon.right : parent.left
91+ leftMargin: delegateIcon.visible ? units.gu(1) : units.gu(2)
92+ right: parent.right
93+ rightMargin: units.gu(2)
94+ verticalCenter: delegateIcon.visible ? delegateIcon.verticalCenter : parent.verticalCenter
95+ }
96+
97+ width: parent.width
98+ text: splitLabel[3]
99+ color:"#5d5d5d"
100+ fontSize: "small"
101+ maximumLineCount: 1
102+ elide: Text.ElideRight
103+ }
104+
105+ Icon {
106+ anchors {
107+ right: parent.right
108+ rightMargin: units.gu(2)
109+ verticalCenter: delegateIcon.verticalCenter
110+ }
111+
112+ visible: index === startIndex
113+ name: optionToggle.height === optionToggle.itemHeight ? "down" : "up"
114+ width: units.gu(2)
115+ height: width
116+ }
117+ }
118+ }
119+ sourceComponent: (index >= startIndex) ? optionToggleEntry : undefined
120+ }
121+ }
122+ }
123+}
tests/qmltests/CMakeLists.txt+1
@@ -70,6 +70,7 @@ add_qml_test(Hud Result)
7070add_qml_test(Launcher Launcher)
7171add_qml_test(Notifications Notifications)
7272add_qml_test(Notifications VisualSnapDecisionsQueue)
73+add_qml_test(Notifications OptionToggle)
7374add_qml_test(Notifications SwipeToAct)
7475add_qml_test(Panel ActiveCallHint)
7576add_qml_test(Panel IndicatorItem)
tests/qmltests/Notifications/tst_Notifications.qml−54
@@ -66,24 +66,6 @@ Item {
6666 }
6767 }
6868
69− function addSnapDecisionNotification() {
70− var n = {
71− type: Notification.SnapDecision,
72− hints: {"x-canonical-private-affirmative-tint": "true"},
73− summary: "Tom Ato",
74− body: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.",
75− icon: "../graphics/avatars/funky.png",
76− secondaryIcon: "../graphics/applicationIcons/facebook.png",
77− actions: [{ id: "ok_id", label: "Ok"},
78− { id: "cancel_id", label: "Cancel"},
79− { id: "notreally_id", label: "Not really"},
80− { id: "noway_id", label: "messages:No way"},
81− { id: "nada_id", label: "messages:Nada"}]
82− }
83−
84− mockModel.append(n)
85− }
86−
8769 function add2over1SnapDecisionNotification() {
8870 var n = {
8971 type: Notification.SnapDecision,
@@ -230,12 +212,6 @@ Item {
230212 anchors.fill: parent
231213 anchors.margins: units.gu(1)
232214
233− Button {
234− width: parent.width
235− text: "add a snap-decision"
236− onClicked: rootRow.addSnapDecisionNotification()
237− }
238−
239215 Button {
240216 width: parent.width
241217 text: "add a 2over1 snap-decision"
@@ -292,10 +268,6 @@ Item {
292268 }
293269 }
294270
295− ActionModel {
296− id: myActionModel
297− }
298−
299271 UnityTestCase {
300272 id: root
301273 name: "NotificationRendererTest"
@@ -303,32 +275,6 @@ Item {
303275
304276 function test_NotificationRenderer_data() {
305277 return [
306− {
307− tag: "Snap Decision with secondary icon and button-tint",
308− type: Notification.SnapDecision,
309− hints: {"x-canonical-private-affirmative-tint": "true"},
310− summary: "Tom Ato",
311− body: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.",
312− icon: "../graphics/avatars/funky.png",
313− secondaryIcon: "../graphics/applicationIcons/facebook.png",
314− actions: [{ id: "ok_id", label: "Ok"},
315− { id: "cancel_id", label: "Cancel"},
316− { id: "notreally_id", label: "Not really"},
317− { id: "noway_id", label: "messages:No way"},
318− { id: "nada_id", label: "messages:Nada"}],
319− summaryVisible: true,
320− bodyVisible: true,
321− iconVisible: true,
322− centeredIconVisible: false,
323− shaped: true,
324− secondaryIconVisible: true,
325− buttonRowVisible: true,
326− buttonTinted: true,
327− hasSound: false,
328− valueVisible: false,
329− valueLabelVisible: false,
330− valueTinted: false
331− },
332278 {
333279 tag: "2-over-1 Snap Decision with button-tint",
334280 type: Notification.SnapDecision,
tests/qmltests/Notifications/tst_OptionToggle.qmlnew file+286
@@ -0,0 +1,286 @@
1+/*
2+ * Copyright (C) 2014 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.0
18+import QtTest 1.0
19+import ".."
20+import "../../../qml/Notifications"
21+import Ubuntu.Components 0.1
22+import Unity.Test 0.1
23+import Unity.Notifications 1.0
24+import QtMultimedia 5.0
25+
26+Row {
27+ id: rootRow
28+
29+ Component {
30+ id: mockNotification
31+
32+ QtObject {
33+ function invokeAction(actionId) {
34+ console.log("Action invoked", actionId);
35+ mockModel.actionInvoked(actionId)
36+ }
37+ }
38+ }
39+
40+ ListModel {
41+ id: mockModel
42+
43+ signal actionInvoked(string actionId)
44+
45+ function getRaw(id) {
46+ return mockNotification.createObject(mockModel)
47+ }
48+
49+ // add the default/PlaceHolder notification to the model
50+ Component.onCompleted: {
51+ var n = {
52+ type: Notification.PlaceHolder,
53+ hints: {},
54+ summary: "",
55+ body: "",
56+ icon: "",
57+ secondaryIcon: "",
58+ actions: []
59+ }
60+
61+ append(n)
62+ }
63+ }
64+
65+ function addSnapDecisionNotification() {
66+ var n = {
67+ type: Notification.SnapDecision,
68+ hints: {"x-canonical-private-affirmative-tint": "true"},
69+ summary: "Incoming call",
70+ body: "Frank Zappa\n+44 (0)7736 027340",
71+ icon: "../graphics/avatars/funky.png",
72+ secondaryIcon: "image://theme/incoming-call",
73+ actions: [{ id: "ok_id", label: "Ok"},
74+ { id: "cancel_id", label: "Cancel"},
75+ { id: "dummy_id", label: "Quick reply"},
76+ { id: "late_id", label: "messages:I'm running late. I'm on my way."},
77+ { id: "later_id", label: "messages:I'm busy at the moment. I'll call later."}]
78+ }
79+
80+ mockModel.append(n)
81+ }
82+
83+ function clearNotifications() {
84+ mockModel.clear()
85+ }
86+
87+ function remove1stNotification() {
88+ if (mockModel.count > 0)
89+ mockModel.remove(0)
90+ }
91+
92+ Rectangle {
93+ id: notificationsRect
94+
95+ width: units.gu(40)
96+ height: units.gu(81)
97+ color: "#cccccc"
98+
99+ MouseArea{
100+ id: clickThroughCatcher
101+
102+ anchors.fill: parent
103+ }
104+
105+ Notifications {
106+ id: notifications
107+
108+ margin: units.gu(1)
109+
110+ anchors.fill: parent
111+ model: mockModel
112+ }
113+ }
114+
115+ Rectangle {
116+ id: interactiveControls
117+
118+ width: units.gu(30)
119+ height: units.gu(81)
120+ color: "grey"
121+
122+ Column {
123+ spacing: units.gu(1)
124+ anchors.fill: parent
125+ anchors.margins: units.gu(1)
126+
127+ Button {
128+ width: parent.width
129+ text: "add a snap-decision"
130+ onClicked: addSnapDecisionNotification()
131+ }
132+
133+ Button {
134+ width: parent.width
135+ text: "remove 1st notification"
136+ onClicked: remove1stNotification()
137+ }
138+
139+ Button {
140+ width: parent.width
141+ text: "clear model"
142+ onClicked: clearNotifications()
143+ }
144+ }
145+ }
146+
147+ UnityTestCase {
148+ id: root
149+ name: "NotificationRendererTest"
150+ when: windowShown
151+
152+ function test_NotificationRenderer_data() {
153+ return [
154+ {
155+ tag: "Snap Decision with secondary icon, button-tint and OptionToggle",
156+ type: Notification.SnapDecision,
157+ hints: {"x-canonical-private-affirmative-tint": "true"},
158+ summary: "Incoming call",
159+ body: "Frank Zappa\n+44 (0)7736 027340",
160+ icon: "../graphics/avatars/funky.png",
161+ secondaryIcon: "image://theme/incoming-call",
162+ actions: [{ id: "ok_id", label: "Ok"},
163+ { id: "cancel_id", label: "Cancel"},
164+ { id: "dummy_id", label: "Quick reply"},
165+ { id: "late_id", label: "messages:I'm running late. I'm on my way."},
166+ { id: "later_id", label: "messages:I'm busy at the moment. I'll call later."}],
167+ summaryVisible: true,
168+ bodyVisible: true,
169+ iconVisible: true,
170+ shaped: true,
171+ secondaryIconVisible: true,
172+ buttonRowVisible: true,
173+ buttonTinted: true,
174+ hasSound: false
175+ }
176+ ]
177+ }
178+
179+ SignalSpy {
180+ id: clickThroughSpy
181+
182+ target: clickThroughCatcher
183+ signalName: "clicked"
184+ }
185+
186+ SignalSpy {
187+ id: actionSpy
188+
189+ target: mockModel
190+ signalName: "actionInvoked"
191+ }
192+
193+ function cleanup() {
194+ clickThroughSpy.clear()
195+ actionSpy.clear()
196+ }
197+
198+ function test_NotificationRenderer(data) {
199+ // populate model with some mock notifications
200+ mockModel.append(data)
201+
202+ // make sure the view is properly updated before going on
203+ notifications.forceLayout();
204+ waitForRendering(notifications);
205+
206+ var notification = findChild(notifications, "notification" + (mockModel.count - 1))
207+ verify(notification !== undefined, "notification wasn't found");
208+
209+ waitForRendering(notification);
210+
211+ var icon = findChild(notification, "icon")
212+ var shapedIcon = findChild(notification, "shapedIcon")
213+ var nonShapedIcon = findChild(notification, "nonShapedIcon")
214+ var interactiveArea = findChild(notification, "interactiveArea")
215+ var secondaryIcon = findChild(notification, "secondaryIcon")
216+ var summaryLabel = findChild(notification, "summaryLabel")
217+ var bodyLabel = findChild(notification, "bodyLabel")
218+ var buttonRow = findChild(notification, "buttonRow")
219+
220+ compare(icon.visible, data.iconVisible, "avatar-icon visibility is incorrect")
221+ if (icon.visible) {
222+ compare(icon.shaped, data.shaped, "shaped-status is incorrect")
223+ }
224+
225+ // test input does not fall through
226+ mouseClick(notification, notification.width / 2, notification.height / 2)
227+ if(data.type == Notification.Interactive) {
228+ actionSpy.wait()
229+ compare(actionSpy.signalArguments[0][0], data.actions[0]["id"], "got wrong id for interactive action")
230+ }
231+ compare(clickThroughSpy.count, 0, "click on notification fell through")
232+
233+ compare(secondaryIcon.visible, data.secondaryIconVisible, "secondary-icon visibility is incorrect")
234+ compare(summaryLabel.visible, data.summaryVisible, "summary-text visibility is incorrect")
235+ compare(bodyLabel.visible, data.bodyVisible, "body-text visibility is incorrect")
236+ compare(buttonRow.visible, data.buttonRowVisible, "button visibility is incorrect")
237+
238+ var audioItem = findInvisibleChild(notification, "sound")
239+ compare(audioItem.playbackState, data.hasSound ? Audio.PlayingState : Audio.StoppedState, "Audio has wrong state")
240+
241+ if(data.buttonRowVisible) {
242+ var buttonCancel = findChild(buttonRow, "notify_button1")
243+ var buttonAccept = findChild(buttonRow, "notify_button0")
244+
245+ // only test the left/cancel-button if two actions have been passed in
246+ if (data.actions.length == 2) {
247+ tryCompareFunction(function() { mouseClick(buttonCancel, buttonCancel.width / 2, buttonCancel.height / 2); return actionSpy.signalArguments.length > 0; }, true);
248+ compare(actionSpy.signalArguments[0][0], data.actions[1]["id"], "got wrong id for negative action")
249+ actionSpy.clear()
250+ }
251+
252+ // check the tinting of the positive/right button
253+ verify(buttonAccept.color === data.buttonTinted ? "#3fb24f" : "#dddddd", "button has the wrong color-tint")
254+
255+ // click the positive/right button
256+ tryCompareFunction(function() { mouseClick(buttonAccept, buttonAccept.width / 2, buttonAccept.height / 2); return actionSpy.signalArguments.length > 0; }, true);
257+ compare(actionSpy.signalArguments[0][0], data.actions[0]["id"], "got wrong id positive action")
258+ actionSpy.clear()
259+ waitForRendering(notification)
260+
261+ // check if there's a OptionToggle created due to more actions being passed
262+ if (data.actions.length > 2) {
263+ var optionToggle = findChild(notification, "notify_button2")
264+ tryCompareFunction(function() { return optionToggle.expanded == false; }, true);
265+
266+ // click to expand
267+ tryCompareFunction(function() { mouseClick(optionToggle, optionToggle.width / 2, optionToggle.height / 2); return optionToggle.expanded == true; }, true);
268+
269+ // try clicking on choices in expanded comboList
270+ var choiceButton1 = findChild(notification, "notify_button3")
271+ tryCompareFunction(function() { mouseClick(choiceButton1, choiceButton1.width / 2, choiceButton1.height / 2); return actionSpy.signalArguments.length > 0; }, true);
272+ compare(actionSpy.signalArguments[0][0], data.actions[3]["id"], "got wrong id choice action 1")
273+ actionSpy.clear()
274+
275+ var choiceButton2 = findChild(notification, "notify_button4")
276+ tryCompareFunction(function() { mouseClick(choiceButton2, choiceButton2.width / 2, choiceButton2.height / 2); return actionSpy.signalArguments.length > 0; }, true);
277+ compare(actionSpy.signalArguments[0][0], data.actions[4]["id"], "got wrong id choice action 2")
278+ actionSpy.clear()
279+ } else {
280+ mouseClick(buttonCancel, buttonCancel.width / 2, buttonCancel.height / 2)
281+ compare(actionSpy.signalArguments[0][0], data.actions[1]["id"], "got wrong id for negative action")
282+ }
283+ }
284+ }
285+ }
286+}