diff --git a/nymea-app/resources.qrc b/nymea-app/resources.qrc index 6a377420..9ebcc1b3 100644 --- a/nymea-app/resources.qrc +++ b/nymea-app/resources.qrc @@ -257,5 +257,11 @@ ui/devicepages/FingerprintReaderDevicePage.qml ui/images/account.svg ui/images/contact-new.svg + ui/components/SegmentedImage.qml + ui/components/SegmentRenderer.qml + ui/components/SegmentBoundingBoxes.qml + ui/components/FingerprintVisual.qml + ui/images/fingerprint/fingerprint_boxes.json + ui/images/fingerprint/fingerprint_segmented.png diff --git a/nymea-app/ui/components/FingerprintVisual.qml b/nymea-app/ui/components/FingerprintVisual.qml new file mode 100644 index 00000000..ab474788 --- /dev/null +++ b/nymea-app/ui/components/FingerprintVisual.qml @@ -0,0 +1,54 @@ +import QtQuick 2.9 + +SegmentedImage { + id: segmentedImage + + property var masks: [] + + property bool debug: false + + // http://stackoverflow.com/a/1830844/538866 + function isNumeric (n) { + return !isNaN(parseFloat(n)) && isFinite(n); + } + + function getMasksToEnroll () { + var outMasks = []; + if (masks && masks.length) { + masks.forEach(function (mask, i) { + // Format is "/[x1,y1,w1,h1],…,[xn,yn,wn,hn]" + // If any value is non-numeric, we drop the mask. + if (!isNumeric(mask.x) || !isNumeric(mask.y) || !isNumeric(mask.width) + || !isNumeric(mask.height)) + return; + + // Translate the box so as to mirror the mask + mask.x = (1 - (mask.x + mask.width)); + + outMasks.push(mask); + }); + } + return outMasks; + } + + onMasksChanged: segmentedImage.enrollMasks(getMasksToEnroll()) + + textureSource: "../images/fingerprint/fingerprint_segmented.png" + boxesSource: "../images/fingerprint/fingerprint_boxes.json" + + Repeater { + model: segmentedImage.masks + + Rectangle { + visible: segmentedImage.debug + color: "red" + opacity: 0.25 + x: modelData.x * segmentedImage.implicitWidth + y: modelData.y * segmentedImage.implicitHeight + width: modelData.width * segmentedImage.implicitWidth + height: modelData.height * segmentedImage.implicitHeight + + Component.onCompleted: console.log('Scanner mask (x, y, w, h):', x, y, width, height) + } + } +} diff --git a/nymea-app/ui/components/SegmentBoundingBoxes.qml b/nymea-app/ui/components/SegmentBoundingBoxes.qml new file mode 100644 index 00000000..2ae27aa2 --- /dev/null +++ b/nymea-app/ui/components/SegmentBoundingBoxes.qml @@ -0,0 +1,81 @@ +/* + * Copyright 2016 Canonical Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + * Authored by Florian Boucault + */ + +import QtQuick 2.4 + +QtObject { + id: segmentBoundingBoxes + + property string source + onSourceChanged: parseBoundingBoxes(source) + + property var boundingBoxes: [] + property real width + property real height + property int count: boundingBoxes.length + + // The API cannot be used reliably before this signal has been emitted. + signal ready() + + function parseBoundingBoxes(source) { + var xhr = new XMLHttpRequest; + xhr.open("GET", source); + xhr.onreadystatechange = function() { + if (xhr.readyState == XMLHttpRequest.DONE) { + var b = []; + var json = JSON.parse(xhr.responseText); + boundingBoxes = json["boxes"]; + width = json["width"]; + height = json["height"]; + ready(); + } + } + xhr.send(); + } + + function intersects(box1, box2) { + // TODO: optimize + var x11 = box1[0]; + var y11 = box1[1]; + var x12 = box1[0] + box1[2]; + var y12 = box1[1] + box1[3]; + var x21 = box2[0]; + var y21 = box2[1]; + var x22 = box2[0] + box2[2]; + var y22 = box2[1] + box2[3]; + var x_overlap = Math.max(0, Math.min(x12,x22) - Math.max(x11,x21)); + var y_overlap = Math.max(0, Math.min(y12,y22) - Math.max(y11,y21)); + return (x_overlap / Math.min(box1[2], box2[2]) > 0.25 + && y_overlap / Math.min(box1[3], box2[3]) > 0.25); + } + + function computeIntersections(hitBox) { + var absoluteHitBox = [hitBox[0] * width, hitBox[1] * height, + hitBox[2] * width, hitBox[3] * height]; + + var intersections = []; + for (var i in boundingBoxes) { + var boundingBox = boundingBoxes[i]; + if (intersects(absoluteHitBox, boundingBox)) { + intersections.push(i); + } + } + + return intersections; + } +} diff --git a/nymea-app/ui/components/SegmentRenderer.qml b/nymea-app/ui/components/SegmentRenderer.qml new file mode 100644 index 00000000..ab612a04 --- /dev/null +++ b/nymea-app/ui/components/SegmentRenderer.qml @@ -0,0 +1,76 @@ +import QtQuick 2.9 + +ShaderEffect { + id: segmentRenderer + + implicitWidth: texture.width + implicitHeight: texture.height + + function animate(segments) { + for (var i in segments) { + var progressPixel = progressTexture.children[segments[i]]; + if (progressPixel.progress == 0.0) { + progressPixel.animation.start(); + } + } + } + + property string source + property int segmentsCount + property color backgroundColor: app.foregroundColor + property color fillColor: app.accentColor + property Image texture: Image { + source: segmentRenderer.source + } + property var progressTexture: progressTexture + property int progressTextureSize: progressTexture.size + + fragmentShader: " + varying mediump vec2 qt_TexCoord0; + uniform lowp float qt_Opacity; + uniform lowp vec4 backgroundColor; + uniform lowp vec4 fillColor; + uniform lowp sampler2D texture; + uniform lowp sampler2D progressTexture; + uniform lowp int progressTextureSize; + + void main() { + lowp vec4 p = texture2D(texture, qt_TexCoord0); + lowp float segment = p.r * 255.0; + lowp vec4 segmentProgress = step(0.9, segment) * texture2D(progressTexture, vec2((segment - 1.0 + 0.5) / float(progressTextureSize), 0.5)); + lowp vec4 color = mix(fillColor, backgroundColor, step(segmentProgress.r, p.g)); + gl_FragColor = vec4(color.rgb, 1.0) * p.b * qt_Opacity; + } + " + + // TODO: not the most efficient; could be replaced with an image provider + Row { + id: progressTexture + + property int size: 128 + layer.enabled: true + layer.sourceRect: Qt.rect(0, 0, size, 1) + layer.textureSize: Qt.size(size, 1) + layer.wrapMode: ShaderEffectSource.ClampToEdge + visible: false + + Repeater { + model: segmentRenderer.segmentsCount + Rectangle { + id: progressPixel + width: 1 + height: 1 + color: Qt.rgba(progress, progress, progress, 1.0) + property real progress + property NumberAnimation animation: NumberAnimation { + target: progressPixel + property: "progress" + from: 0.0 + to: 1.0 + duration: 1000 + easing.type: Easing.InOutQuad + } + } + } + } +} diff --git a/nymea-app/ui/components/SegmentedImage.qml b/nymea-app/ui/components/SegmentedImage.qml new file mode 100644 index 00000000..77c4ea85 --- /dev/null +++ b/nymea-app/ui/components/SegmentedImage.qml @@ -0,0 +1,52 @@ +/* + * Copyright 2016 Canonical Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + * Authored by Florian Boucault + */ + +import QtQuick 2.4 + +Item { + id: segImg + property alias textureSource: segmentRenderer.source + property alias boxesSource: segmentBoundingBoxes.source + + implicitWidth: segmentRenderer.implicitWidth + implicitHeight: segmentRenderer.implicitHeight + + // Ready to enroll. + signal ready() + + function enrollMasks(masks) { + if (masks && masks.length) { + var segments = []; + masks.forEach(function (mask, i) { + var hitBox = [mask.x, mask.y, mask.width, mask.height]; + segments = segments.concat(segmentBoundingBoxes.computeIntersections(hitBox)); + }); + segmentRenderer.animate(segments); + } + } + + SegmentRenderer { + id: segmentRenderer + segmentsCount: segmentBoundingBoxes.count + } + + SegmentBoundingBoxes { + id: segmentBoundingBoxes + onReady: segImg.ready() + } +} diff --git a/nymea-app/ui/customviews/GenericTypeLogView.qml b/nymea-app/ui/customviews/GenericTypeLogView.qml index b713b0e3..dc136765 100644 --- a/nymea-app/ui/customviews/GenericTypeLogView.qml +++ b/nymea-app/ui/customviews/GenericTypeLogView.qml @@ -15,6 +15,8 @@ Item { property alias delegate: listView.delegate + property bool autoscroll: true + ColumnLayout { anchors.fill: parent @@ -40,7 +42,11 @@ Item { Layout.fillHeight: true model: logsModel clip: true -// onCountChanged: positionViewAtEnd() + onCountChanged: { + if (root.autoscroll) { + positionViewAtEnd() + } + } onContentYChanged: { if (!logsModel.busy && contentY - originY < 5 * height) { diff --git a/nymea-app/ui/devicepages/FingerprintReaderDevicePage.qml b/nymea-app/ui/devicepages/FingerprintReaderDevicePage.qml index b248a147..f5f97ec2 100644 --- a/nymea-app/ui/devicepages/FingerprintReaderDevicePage.qml +++ b/nymea-app/ui/devicepages/FingerprintReaderDevicePage.qml @@ -17,39 +17,9 @@ DevicePageBase { ColumnLayout { anchors.fill: parent -// Item { -// Layout.fillWidth: true -// Layout.preferredHeight: root.inputVisible ? inputColumn.implicitHeight : 0 -// Behavior on Layout.preferredHeight { NumberAnimation { duration: 130; easing.type: Easing.InOutQuad } } - -// ColumnLayout { -// id: inputColumn -// anchors { left: parent.left; bottom: parent.bottom; right: parent.right } - -// TextField { -// id: titleTextField -// Layout.fillWidth: true -// Layout.topMargin: app.margins -// Layout.leftMargin: app.margins; Layout.rightMargin: app.margins -// placeholderText: qsTr("Title") -// } - -// TextArea { -// id: bodyTextField -// Layout.fillWidth: true -// Layout.leftMargin: app.margins; Layout.rightMargin: app.margins -// placeholderText: qsTr("Text") -// wrapMode: Text.WordWrap -// } -// } -// } - - - - Button { Layout.fillWidth: true - Layout.margins: app.margins + Layout.leftMargin: app.margins; Layout.topMargin: app.margins; Layout.rightMargin: app.margins text: qsTr("Manage access") onClicked: { pageStack.push(manageUsersComponent) @@ -69,12 +39,12 @@ DevicePageBase { live: true Component.onCompleted: update() typeIds: [root.accessGrantedEventType.id, root.accessDeniedEventType.id]; - } delegate: MeaListItemDelegate { width: parent.width - iconName: "../images/notification.svg" + iconName: model.typeId === root.accessGrantedEventType.id ? "../images/tick.svg" : "../images/dialog-error-symbolic.svg" + iconColor: model.typeId === root.accessGrantedEventType.id ? "green" : "red" text: model.typeId === root.accessGrantedEventType.id ? qsTr("Access granted for user %1").arg(model.value) : qsTr("Access denied") subText: Qt.formatDateTime(model.timestamp) progressive: false @@ -134,6 +104,28 @@ DevicePageBase { engine.deviceManager.executeAction(root.device.id, actionType.id, params) } } + + ColumnLayout { + anchors.centerIn: parent + width: 200 + spacing: app.margins * 2 + visible: parent.count === 0 + Item { + Layout.fillWidth: true + Layout.preferredHeight: width + FingerprintVisual { + id: fingerprintVisual + anchors.centerIn: parent + scale: parent.height / implicitHeight + } + } + + Button { + text: qsTr("Add a fingerprint") + onClicked: pageStack.push(addUserComponent) + Layout.fillWidth: true + } + } } } } @@ -153,8 +145,8 @@ DevicePageBase { Connections { target: engine.deviceManager onExecuteActionReply: { - addUserPage.error = params["deviceError"] !== DeviceManager.DeviceErrorNoError - print("Execute action reply:", params); + addUserPage.error = params["deviceError"] !== "DeviceErrorNoError" + print("Execute action reply:", params["deviceError"]); addUserSwipeView.currentIndex++ } } @@ -164,6 +156,7 @@ DevicePageBase { SwipeView { id: addUserSwipeView Layout.fillWidth: true + Layout.topMargin: app.margins * 2 Layout.preferredHeight: 200 Layout.alignment: Qt.AlignTop Item { @@ -182,6 +175,27 @@ DevicePageBase { id: userIdTextField Layout.fillWidth: true } + Label { + Layout.fillWidth: true + text: qsTr("Finger") + } + ComboBox { + id: fingerComboBox + Layout.fillWidth: true + model: ListModel { + ListElement { modelData: qsTr("Left thumb"); enumValue: "ThumbLeft" } + ListElement { modelData: qsTr("Left index finger"); enumValue: "IndexFingerLeft" } + ListElement { modelData: qsTr("Left middle finger"); enumValue: "MiddleFingerLeft" } + ListElement { modelData: qsTr("Left ring finger"); enumValue: "RingFingerLeft" } + ListElement { modelData: qsTr("Left pinky finger"); enumValue: "PinkyLeft" } + ListElement { modelData: qsTr("Right thumb"); enumValue: "ThumbRight" } + ListElement { modelData: qsTr("Right index finger"); enumValue: "IndexFingerRight" } + ListElement { modelData: qsTr("Right middle finger"); enumValue: "MiddleFingerRight" } + ListElement { modelData: qsTr("Right ring finger"); enumValue: "RingFingerRight" } + ListElement { modelData: qsTr("Right pinky finger"); enumValue: "PinkyRight" } + } + } + Button { text: qsTr("Add user") Layout.fillWidth: true @@ -192,6 +206,10 @@ DevicePageBase { titleParam["paramTypeId"] = actionType.paramTypes.findByName("userId").id titleParam["value"] = userIdTextField.displayText params.push(titleParam) + var fingerParam = {} + fingerParam["paramTypeId"] = actionType.paramTypes.findByName("finger").id + fingerParam["value"] = fingerComboBox.model.get(fingerComboBox.currentIndex).enumValue + params.push(fingerParam) engine.deviceManager.executeAction(root.device.id, actionType.id, params) addUserSwipeView.currentIndex++ } @@ -206,9 +224,43 @@ DevicePageBase { ColumnLayout { anchors.fill: parent anchors.margins: app.margins + spacing: app.margins * 2 Label { text: qsTr("Please scan the fingerprint now") Layout.fillWidth: true + font.pixelSize: app.largeFont + color: app.accentColor + horizontalAlignment: Text.AlignHCenter + } + + Item { + Layout.preferredWidth: 200 + Layout.preferredHeight: 200 + Layout.alignment: Qt.AlignCenter + + FingerprintVisual { + id: fingerprintVisual + scale: parent.height / implicitHeight + + anchors.centerIn: parent + Timer { + interval: 500 + property real position: 0 + running: addUserSwipeView.currentIndex == 1 + repeat: true + onTriggered: { + var masks = []; + masks.push({x: 0, y: 0, width: 1, height: position}) + position += 0.1 + if (position < 1.1) { + fingerprintVisual.masks = masks + } else { + position = 0 + fingerprintVisual.masks = [] + } + } + } + } } } } diff --git a/nymea-app/ui/images/fingerprint/fingerprint_boxes.json b/nymea-app/ui/images/fingerprint/fingerprint_boxes.json new file mode 100644 index 00000000..657afc9b --- /dev/null +++ b/nymea-app/ui/images/fingerprint/fingerprint_boxes.json @@ -0,0 +1 @@ +{"width": 451, "boxes": [[291, 515, 104, 50], [269, 510, 96, 34], [227, 506, 62, 16], [198, 505, 72, 60], [144, 498, 46, 67], [200, 467, 133, 32], [214, 449, 87, 28], [67, 432, 25, 104], [396, 420, 31, 89], [177, 395, 22, 103], [133, 390, 15, 96], [266, 375, 8, 31], [1, 356, 7, 42], [336, 355, 27, 114], [221, 350, 95, 105], [243, 323, 17, 106], [155, 317, 68, 248], [110, 317, 43, 243], [22, 314, 12, 155], [199, 310, 13, 127], [302, 296, 83, 223], [276, 295, 20, 136], [311, 275, 30, 189], [177, 266, 8, 114], [88, 263, 34, 287], [221, 249, 9, 86], [441, 238, 9, 169], [110, 231, 9, 70], [44, 226, 16, 280], [310, 225, 8, 181], [199, 223, 53, 86], [370, 217, 37, 288], [249, 208, 25, 153], [177, 201, 61, 49], [281, 195, 15, 85], [133, 183, 16, 196], [177, 178, 96, 29], [155, 167, 35, 134], [345, 166, 18, 173], [200, 156, 81, 31], [1, 149, 17, 192], [386, 145, 18, 60], [305, 142, 36, 118], [66, 139, 26, 277], [149, 134, 168, 77], [111, 132, 45, 85], [88, 130, 36, 120], [350, 118, 35, 162], [165, 111, 134, 28], [262, 92, 85, 64], [414, 91, 35, 133], [129, 89, 119, 36], [22, 88, 42, 211], [92, 67, 256, 62], [47, 64, 75, 149], [349, 47, 80, 360], [132, 44, 257, 90], [13, 41, 56, 95], [66, 22, 273, 60], [198, 1, 215, 76], [74, 1, 109, 39]], "height": 566} diff --git a/nymea-app/ui/images/fingerprint/fingerprint_segmented.png b/nymea-app/ui/images/fingerprint/fingerprint_segmented.png new file mode 100644 index 00000000..b14e672f Binary files /dev/null and b/nymea-app/ui/images/fingerprint/fingerprint_segmented.png differ