Lomiri
ClockPinPrompt.qml
1/*
2 * Copyright 2022 UBports Foundation
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
17import QtQuick 2.12
18import Lomiri.Components 1.3
19
20Item {
21 id: root
22 objectName: "ClockPinPrompt"
23
24 property string text
25 property bool isSecret
26 property bool interactive: true
27 property bool loginError: false
28 property bool hasKeyboard: false //unused
29 property string enteredText: ""
30
31 property int previousNumber: -1
32 property var currentCode: []
33 property int maxnum: 10
34 readonly property int pincodeSize: 4 // hard coded pin code length for now
35 readonly property int minPinCodeDigits: 4
36 readonly property bool validCode: enteredText.length >= minPinCodeDigits
37 property bool isLandscape: width > height
38
39 signal clicked()
40 signal canceled()
41 signal accepted(string response)
42
43 onCurrentCodeChanged: {
44 let tmpText = ""
45 let tmpCode = ""
46 const maxDigits = Math.max(root.minPinCodeDigits, currentCode.length)
47 for( let i = 0; i < maxDigits; i++) {
48 if (i < currentCode.length) {
49 tmpText += '●'
50 tmpCode += currentCode[i]
51 } else {
52 tmpText += '○'
53 }
54 }
55
56 pinHint.text = tmpText
57 root.enteredText = tmpCode
58
59 if (root.enteredText.length >= pincodeSize) {
60 root.accepted(root.enteredText);
61 }
62 }
63
64 function addNumber (number, fromKeyboard) {
65 let tmpCodes = currentCode
66 tmpCodes.push(number)
67 currentCode = tmpCodes
68 // don't animate digits while with keyboard
69 if (!fromKeyboard) {
70 repeater.itemAt(number).animation.restart()
71 }
72 root.previousNumber = number
73 }
74
75 function removeOne() {
76 let tmpCodes = currentCode
77
78 tmpCodes.pop()
79 currentCode = tmpCodes
80 }
81
82 function reset() {
83 currentCode = []
84 loginError = false;
85 }
86
87 StyledItem {
88 id: d
89
90 readonly property color normal: theme.palette.normal.raisedText
91 readonly property color selected: theme.palette.normal.raisedSecondaryText
92 readonly property color selectedCircle: Qt.rgba(selected.r, selected.g, selected.b, 0.2)
93 readonly property color disabled:theme.palette.disabled.raisedSecondaryText
94 }
95
96 TextField {
97 id: pinHint
98
99 anchors.horizontalCenter: parent.horizontalCenter
100 width: Math.max(units.gu(16), contentWidth + units.gu(3))
101
102 readOnly: true
103 color: d.selected
104 font {
105 pixelSize: units.gu(3)
106 letterSpacing: units.gu(1.75)
107 }
108 secondaryItem: Icon {
109 name: "erase"
110 objectName: "EraseBtn"
111 height: units.gu(3)
112 width: units.gu(3)
113 color: enabled ? d.selected : d.disabled
114 enabled: root.currentCode.length > 0
115 anchors.verticalCenter: parent.verticalCenter
116 MouseArea {
117 anchors.fill: parent
118 onClicked: root.removeOne()
119 onPressAndHold: root.reset()
120 }
121 }
122
123 inputMethodHints: Qt.ImhDigitsOnly
124
125 Keys.onEscapePressed: {
126 root.canceled();
127 event.accepted = true;
128 }
129
130 Keys.onPressed: {
131 if(event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
132 root.addNumber(event.text, true)
133 event.accepted = true;
134 }
135 }
136 Keys.onReturnPressed: root.accepted(root.enteredText);
137 Keys.onEnterPressed: root.accepted(root.enteredText);
138
139 Keys.onBackPressed: {
140 root.removeOne()
141 }
142
143 }
144
145 Rectangle {
146 id: main
147 objectName: "SelectArea"
148
149 height: Math.min(parent.height, parent.width)
150 width: parent.width
151 anchors.bottom:parent.bottom
152 // in landscape, let the clock being close to the bottom
153 anchors.bottomMargin: root.isLandscape ? -units.gu(4) : undefined
154 anchors.horizontalCenter: parent.horizontalCenter
155 color: "transparent"
156
157 MouseArea {
158 id: mouseArea
159 anchors.fill: parent
160
161 function reEvaluate() {
162 var child = main.childAt(mouseX, mouseY)
163
164 if (child !== null && child.number !== undefined) {
165 var number = child.number
166 if (number > -1 && ( root.previousNumber === -1 || number !== root.previousNumber)) {
167 root.addNumber(number)
168 }
169 } else {
170 // outside
171 root.previousNumber = -1
172 }
173 }
174
175 onPressed: {
176 if (state !== "ENTRY_MODE") {
177 root.state = "ENTRY_MODE"
178 }
179 }
180
181 onPositionChanged: {
182 if (pressed)
183 reEvaluate()
184 }
185 }
186
187 Rectangle {
188 id: center
189
190 objectName: "CenterCircle"
191 height: main.height / 3
192 width: height
193 radius: height / 2
194 property int radiusSquared: radius * radius
195 property alias locker: centerImg.source
196 property alias animation: challengeAnim
197 anchors.centerIn: parent
198 color: "transparent"
199 property int number: -1
200
201 Icon {
202 id: centerImg
203 source: "image://theme/lock"
204 anchors.centerIn: parent
205 width: units.gu(4)
206 height: width
207 color: root.validCode ? d.selected : d.disabled
208 onSourceChanged: imgAnim.start()
209 }
210
211 SequentialAnimation {
212 id: challengeAnim
213 ParallelAnimation {
214 PropertyAnimation {
215 target: centerImg
216 property: "color"
217 to: d.selected
218 duration: 100
219 }
220 PropertyAnimation {
221 target: center
222 property: "color"
223 to: d.selectedCircle
224 duration: 100
225 }
226 }
227
228 PropertyAnimation {
229 target: center
230 property: "color"
231 to: "transparent"
232 duration: 400
233 }
234 }
235
236 SequentialAnimation {
237 id: imgAnim
238 NumberAnimation { target: centerImg; property: "opacity"; from: 0; to: 1; duration: 1000 }
239 }
240 }
241
242 // dots
243 Repeater {
244 id: repeater
245
246 objectName: "dotRepeater"
247 model: root.maxnum
248
249 Rectangle {
250 id: selectionRect
251 height: bigR / 2.2
252 width: height
253 radius: height / 2
254 color: "transparent"
255 property int number: index
256 property alias dot: point
257 property alias animation: anim
258
259 property int bigR: root.state === "ENTRY_MODE" ? main.height / 3 : 0
260 property int offsetRadius: radius
261 x: (main.width / 2) + bigR * Math.sin(2 * Math.PI * index / root.maxnum) - offsetRadius
262 y: (main.height / 2) - bigR * Math.cos(2 * Math.PI * index / root.maxnum) - offsetRadius
263
264 Text {
265 id: point
266 font.pixelSize: main.height / 10
267 anchors.centerIn: parent
268 color: d.disabled
269 text: index
270 opacity: root.state === "ENTRY_MODE" ? 1 : 0
271 property bool selected: false
272
273 Behavior on opacity {
274 LomiriNumberAnimation{ duration: 500 }
275 }
276 }
277
278 MouseArea {
279 anchors.fill: parent
280 onPressed: {
281 root.addNumber(index)
282 mouse.accepted = false
283 }
284 }
285
286 Behavior on bigR {
287 LomiriNumberAnimation { duration: 500 }
288 }
289
290 SequentialAnimation {
291 id: anim
292 ParallelAnimation {
293 PropertyAnimation {
294 target: point
295 property: "color"
296 to: d.selected
297 duration: 100
298 }
299 PropertyAnimation {
300 target: selectionRect
301 property: "color"
302 to: Qt.rgba(d.selected.r, d.selected.g, d.selected.b, 0.3)
303 duration: 100
304 }
305 }
306 ParallelAnimation {
307 PropertyAnimation {
308 target: point
309 property: "color"
310 to: d.disabled
311 duration: 400
312 }
313 PropertyAnimation {
314 target: selectionRect
315 property: "color"
316 to: "transparent"
317 duration: 400
318 }
319 }
320 }
321 }
322 }
323 }
324
325 states: [
326 State{
327 name: "ENTRY_MODE"
328 StateChangeScript {
329 script: root.reset();
330 }
331 },
332 State{
333 name: "WRONG_PASSWORD"
334 when: root.loginError
335 PropertyChanges {
336 target: center
337 locker: "image://theme/dialog-warning-symbolic"
338 }
339 }
340 ]
341
342 transitions: Transition {
343 from: "WRONG_PASSWORD"; to: "ENTRY_MODE";
344 PropertyAction { target: center; property: "locker"; value: "image://theme/dialog-warning-symbolic" }
345 PauseAnimation { duration: 1000 }
346 }
347
348 onActiveFocusChanged: {
349 if (!activeFocus && !pinHint.activeFocus) {
350 root.state = ""
351 } else {
352 root.state = "ENTRY_MODE"
353 pinHint.forceActiveFocus()
354 }
355 }
356}