From 613fa4d19fd4dfd193b208d3843db9db6052bfe4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Cie=C5=9Blak?= <michalcieslak@status.im>
Date: Wed, 17 Jul 2024 17:46:10 +0200
Subject: [PATCH] feat(SendModal): New AmountToSend component

In comparison to the previous version it provides simpler API
and better validation based on AmountValidator.

Old version will be removed after full integration of the new version.
---
 storybook/pages/AmountToSendNewPage.qml       | 138 ++++++++++
 .../qmlTests/tests/tst_AmountToSendNew.qml    | 173 ++++++++++++
 .../popups/send/views/AmountToSendNew.qml     | 255 ++++++++++++++++++
 ui/imports/shared/popups/send/views/qmldir    |   1 +
 4 files changed, 567 insertions(+)
 create mode 100644 storybook/pages/AmountToSendNewPage.qml
 create mode 100644 storybook/qmlTests/tests/tst_AmountToSendNew.qml
 create mode 100644 ui/imports/shared/popups/send/views/AmountToSendNew.qml

diff --git a/storybook/pages/AmountToSendNewPage.qml b/storybook/pages/AmountToSendNewPage.qml
new file mode 100644
index 0000000000..5b0ccda851
--- /dev/null
+++ b/storybook/pages/AmountToSendNewPage.qml
@@ -0,0 +1,138 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import Qt.labs.settings 1.0
+
+import shared.popups.send.views 1.0
+
+SplitView {
+    orientation: Qt.Vertical
+    SplitView.fillWidth: true
+
+    Item {
+        SplitView.fillWidth: true
+        SplitView.fillHeight: true
+
+        AmountToSendNew {
+            id: amountToSend
+
+            anchors.centerIn: parent
+
+            interactive: interactiveCheckBox.checked
+            markAsInvalid: markAsInvalidCheckBox.checked
+
+            caption: "Amount to send"
+
+            decimalPoint: decimalPointRadioButton.checked ? "." : ","
+            price: parseFloat(priceTextField.text)
+
+            multiplierIndex: multiplierIndexSpinBox.value
+
+            formatFiat: balance => `${balance.toLocaleString(Qt.locale())} USD`
+            formatBalance: balance => `${balance.toLocaleString(Qt.locale())} ETH`
+        }
+    }
+
+    Pane {
+        id: logsAndControlsPanel
+
+        SplitView.minimumHeight: 350
+
+        ColumnLayout {
+            spacing: 15
+
+            RowLayout {
+                Label {
+                    text: "Price"
+                }
+
+                TextField {
+                    id: priceTextField
+
+                    text: "812.323"
+                }
+            }
+
+            RowLayout {
+                Label {
+                    text: "Decimal point"
+                }
+
+                RadioButton {
+                    id: decimalPointRadioButton
+
+                    text: "."
+                }
+
+                RadioButton {
+                    text: ","
+                    checked: true
+                }
+            }
+
+            RowLayout {
+                Label {
+                    text: "Multiplier index"
+                }
+
+                SpinBox {
+                    id: multiplierIndexSpinBox
+
+                    editable: true
+                    value: 18
+                    to: 30
+                }
+            }
+
+            RowLayout {
+                CheckBox {
+                    id: interactiveCheckBox
+
+                    text: "Interactive"
+                    checked: true
+                }
+
+                CheckBox {
+                    id: markAsInvalidCheckBox
+
+                    text: "Mark as invalid"
+                }
+            }
+
+            Label {
+                font.bold: true
+                text: `fiat mode: ${amountToSend.fiatMode}, ` +
+                      `valid: ${amountToSend.valid}, ` +
+                      `empty: ${amountToSend.empty}, ` +
+                      `amount: ${amountToSend.amount}`
+            }
+
+            RowLayout {
+                Label {
+                    text: `Set value`
+                }
+
+                TextField {
+                    id: amountTextField
+
+                    text: "0.0012"
+                }
+
+                Button {
+                    text: "SET"
+
+                    onClicked: {
+                        amountToSend.setValue(amountTextField.text)
+                    }
+                }
+            }
+        }
+    }
+
+    Settings {
+        property alias multiplier: multiplierIndexSpinBox.value
+    }
+}
+
+// category: Components
diff --git a/storybook/qmlTests/tests/tst_AmountToSendNew.qml b/storybook/qmlTests/tests/tst_AmountToSendNew.qml
new file mode 100644
index 0000000000..d8ea4be81b
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_AmountToSendNew.qml
@@ -0,0 +1,173 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+
+import QtTest 1.15
+
+import shared.popups.send.views 1.0
+
+Item {
+    id: root
+
+    Component {
+        id: componentUnderTest
+
+        AmountToSendNew {
+            decimalPoint: "."
+        }
+    }
+
+    property AmountToSendNew amountToSend
+
+    TestCase {
+        name: "AmountToSendNew"
+        when: windowShown
+
+        function type(key, times = 1) {
+            for (let i = 0; i < times; i++) {
+                keyPress(key)
+                keyRelease(key)
+            }
+        }
+
+        function init() {
+            amountToSend = createTemporaryObject(componentUnderTest, root)
+        }
+
+        function test_empty() {
+            compare(amountToSend.valid, false)
+            compare(amountToSend.empty, true)
+            compare(amountToSend.amount, "0")
+            compare(amountToSend.fiatMode, false)
+        }
+
+        function test_settingValueInCryptoMode() {
+            const textField = findChild(amountToSend, "amountToSend_textField")
+
+            amountToSend.multiplierIndex = 3
+            amountToSend.setValue("2.5")
+
+            compare(textField.text, "2.5")
+            compare(amountToSend.amount, "2500")
+            compare(amountToSend.valid, true)
+
+            amountToSend.setValue("2.12345678")
+
+            compare(textField.text, "2.123")
+            compare(amountToSend.amount, "2123")
+            compare(amountToSend.valid, true)
+
+            amountToSend.setValue("2.1239")
+
+            compare(textField.text, "2.124")
+            compare(amountToSend.amount, "2124")
+            compare(amountToSend.valid, true)
+
+            amountToSend.setValue(".1239")
+
+            compare(textField.text, "0.124")
+            compare(amountToSend.amount, "124")
+            compare(amountToSend.valid, true)
+
+            amountToSend.setValue("1.0000")
+
+            compare(textField.text, "1")
+            compare(amountToSend.amount, "1000")
+            compare(amountToSend.valid, true)
+
+            amountToSend.setValue("0.0000")
+
+            compare(textField.text, "0")
+            compare(amountToSend.amount, "0")
+            compare(amountToSend.valid, true)
+
+            amountToSend.setValue("x")
+
+            compare(textField.text, "NaN")
+            compare(amountToSend.amount, "0")
+            compare(amountToSend.valid, false)
+
+            // exceeding maxium allowed integral part
+            amountToSend.setValue("1234567890000")
+            compare(textField.text, "1234567890000")
+            compare(amountToSend.amount, "0")
+            compare(amountToSend.valid, false)
+        }
+
+        function test_settingValueInFiatMode() {
+            const textField = findChild(amountToSend, "amountToSend_textField")
+            const mouseArea = findChild(amountToSend, "amountToSend_mouseArea")
+
+            amountToSend.price = 0.5
+            amountToSend.multiplierIndex = 3
+
+            mouseClick(mouseArea)
+            compare(amountToSend.fiatMode, true)
+
+            amountToSend.setValue("2.5")
+
+            compare(textField.text, "2.50")
+            compare(amountToSend.amount, "5000")
+            compare(amountToSend.valid, true)
+
+            amountToSend.setValue("2.12345678")
+
+            compare(textField.text, "2.12")
+            compare(amountToSend.amount, "4240")
+            compare(amountToSend.valid, true)
+
+            amountToSend.setValue("2.129")
+
+            compare(textField.text, "2.13")
+            compare(amountToSend.amount, "4260")
+            compare(amountToSend.valid, true)
+
+            // exceeding maxium allowed integral part
+            amountToSend.setValue("1234567890000")
+            compare(textField.text, "1234567890000.00")
+            compare(amountToSend.amount, "0")
+            compare(amountToSend.valid, false)
+        }
+
+        function test_switchingMode() {
+            const textField = findChild(amountToSend, "amountToSend_textField")
+            const mouseArea = findChild(amountToSend, "amountToSend_mouseArea")
+
+            amountToSend.price = 0.5
+            amountToSend.multiplierIndex = 3
+
+            amountToSend.setValue("10.5")
+            compare(amountToSend.amount, "10500")
+
+            mouseClick(mouseArea)
+            compare(amountToSend.fiatMode, true)
+            compare(textField.text, "5.25")
+            compare(amountToSend.amount, "10500")
+
+            mouseClick(mouseArea)
+            compare(amountToSend.fiatMode, false)
+            compare(textField.text, "10.5")
+            compare(amountToSend.amount, "10500")
+
+            mouseClick(mouseArea)
+            compare(amountToSend.fiatMode, true)
+            amountToSend.price = 0.124
+            amountToSend.setValue("1")
+            compare(textField.text, "1.00")
+
+            mouseClick(mouseArea)
+            compare(amountToSend.fiatMode, false)
+            compare(textField.text, "8.065")
+            compare(amountToSend.amount, "8065")
+        }
+
+        function test_clear() {
+            const textField = findChild(amountToSend, "amountToSend_textField")
+
+            amountToSend.setValue("10.5")
+            amountToSend.clear()
+
+            compare(amountToSend.amount, "0")
+            compare(textField.text, "")
+        }
+    }
+}
diff --git a/ui/imports/shared/popups/send/views/AmountToSendNew.qml b/ui/imports/shared/popups/send/views/AmountToSendNew.qml
new file mode 100644
index 0000000000..af284a1c62
--- /dev/null
+++ b/ui/imports/shared/popups/send/views/AmountToSendNew.qml
@@ -0,0 +1,255 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import StatusQ.Core 0.1
+import StatusQ.Core.Theme 0.1
+import StatusQ.Core.Utils 0.1 as SQUtils
+
+import StatusQ.Validators 0.1
+
+import utils 1.0
+import shared.controls 1.0
+
+Control {
+    id: root
+
+    /* Crypto value in a base unit as a string integer, e.g. 1000000000000000000
+     * for 1 ETH */
+    readonly property alias amount: d.amountBaseUnit
+
+    /* In fiat mode the input value is meant to be a fiat value, conversely,
+     * crypto value otherwise. */
+    readonly property alias fiatMode: d.fiatMode
+
+    /* Indicates if input represent valid number. E.g. empty input or containing
+     * only decimal point is not valid. */
+    readonly property alias valid: textField.acceptableInput
+    readonly property bool empty: textField.length === 0
+
+    // TODO: remove, temporarily for backward compatibility. External components
+    // should not rely on formatted amount because formatting rules are internal
+    // detail of that component.
+    readonly property alias text: textField.text
+
+    /* Decimal point character to be dispalyed. Both "." and "," will be
+     * replaced by the provided decimal point on the fly */
+    property alias decimalPoint: validator.decimalPoint
+
+    /* Number of fiat decimal places used to limit allowed decimal places in
+     * fiatMode */
+    property int fiatDecimalPlaces: 2
+
+    /* Specifies how divisible given cryptocurrency is, e.g. 18 for ETH. Used
+     * for limiting allowed decimal places and computing final amout as an
+     * integer value */
+    property int multiplierIndex: 18
+
+    /* Price of one unit of given cryptocurrency (e.g. price for 1 ETH) */
+    property real price: 1.0
+
+    property alias caption: captionText.text
+    property bool interactive: true
+
+    /* Allows mark input as invalid when it's valid number but doesn't satisfy
+     * arbitrary external criteria, e.g. is higher than maximum expected value. */
+    property bool markAsInvalid: false
+
+    /* Methods for formatting crypto and fiat value expecting double values,
+       e.g. 1.0 for 1 ETH or 1.0 for 1 USD. */
+    property var formatFiat: balance =>
+                             `${balance.toLocaleString(Qt.locale())} FIAT`
+    property var formatBalance: balance =>
+                                `${balance.toLocaleString(Qt.locale())} CRYPTO`
+
+    /* Allows to set value to be displayed. The value is expected to be a not
+       localized string like "1", "1.1" or "0.000000023400234222". Provided
+       value will be formatted and displayed. Depending on the fiatMode flag
+       it will affect output amount appropriately. */
+    function setValue(valueString) {
+        if (!valueString)
+            valueString = "0"
+
+        const decimalPlaces = d.fiatMode ? root.fiatDecimalPlaces
+                                         : root.multiplierIndex
+
+        const stringNumber = SQUtils.AmountsArithmetic.fromString(
+                               valueString).toFixed(decimalPlaces)
+
+        const trimmed = d.fiatMode
+                      ? stringNumber
+                      : d.removeDecimalTrailingZeros(stringNumber)
+
+        textField.text = d.localize(trimmed)
+    }
+
+    function clear() {
+        textField.clear()
+    }
+
+    function forceActiveFocus() {
+        textField.forceActiveFocus()
+    }
+
+    QtObject {
+        id: d
+
+        property bool fiatMode: false
+
+        readonly property string inputDelocalized:
+            root.valid && textField.length !== 0
+                ? textField.text.replace(",", ".") : "0"
+
+        function removeDecimalTrailingZeros(num) {
+            if (!num.includes("."))
+                return num
+
+            return num.replace(/\.?0*$/g, "")
+        }
+
+        function localize(num) {
+            return num.replace(".", root.decimalPoint)
+        }
+
+        readonly property string amountBaseUnit: {
+            if (d.fiatMode)
+                return secondaryValue
+
+            const multiplier = SQUtils.AmountsArithmetic.fromExponent(
+                                 root.multiplierIndex)
+
+            return SQUtils.AmountsArithmetic.times(
+                        SQUtils.AmountsArithmetic.fromString(inputDelocalized),
+                        multiplier).toFixed()
+        }
+
+        readonly property string secondaryValue: {
+            const price = isNaN(root.price) ? 0 : root.price
+
+            if (!d.fiatMode)
+                return SQUtils.AmountsArithmetic.times(
+                            SQUtils.AmountsArithmetic.fromString(inputDelocalized),
+                            SQUtils.AmountsArithmetic.fromNumber(
+                                price * (10 ** root.fiatDecimalPlaces))).round().toFixed()
+
+            const multiplier = SQUtils.AmountsArithmetic.fromExponent(
+                                 root.multiplierIndex)
+
+            return SQUtils.AmountsArithmetic.div(
+                        SQUtils.AmountsArithmetic.times(
+                            SQUtils.AmountsArithmetic.fromString(inputDelocalized),
+                            multiplier),
+                        SQUtils.AmountsArithmetic.fromNumber(price)).round().toFixed()
+        }
+    }
+
+    contentItem: ColumnLayout {
+        StatusBaseText {
+            id: captionText
+
+            Layout.fillWidth: true
+
+            visible: text.length > 0
+
+            font.pixelSize: 13
+            lineHeight: 18
+            lineHeightMode: Text.FixedHeight
+            color: Theme.palette.directColor1
+            elide: Text.ElideRight
+        }
+
+        RowLayout {
+            StyledTextField {
+                id: textField
+
+                objectName: "amountToSend_textField"
+
+                Layout.fillWidth: true
+
+                implicitHeight: 44
+                padding: 0
+                background: null
+
+                readOnly: !root.interactive
+
+                color: text.length === 0 || (root.valid && !root.markAsInvalid)
+                       ? Theme.palette.directColor1
+                       : Theme.palette.dangerColor1
+
+                placeholderText: {
+                    if (!d.fiatMode || root.fiatDecimalPlaces === 0)
+                        return "0"
+
+                    return "0" + root.decimalPoint
+                            + "0".repeat(root.fiatDecimalPlaces)
+                }
+
+                font.pixelSize: Utils.getFontSizeBasedOnLetterCount(text)
+
+                validator: AmountValidator {
+                    id: validator
+
+                    maxDecimalDigits: d.fiatMode ? root.fiatDecimalPlaces
+                                                 : root.multiplierIndex
+                }
+            }
+        }
+
+        StatusBaseText {
+            id: bottomItem
+
+            objectName: "bottomItemText"
+
+            Layout.fillWidth: true
+
+            text: {
+                const divisor = SQUtils.AmountsArithmetic.fromExponent(
+                                  d.fiatMode ? root.multiplierIndex
+                                             : root.fiatDecimalPlaces)
+                const divided = SQUtils.AmountsArithmetic.div(
+                                  SQUtils.AmountsArithmetic.fromString(
+                                      d.secondaryValue), divisor)
+                const asNumber = SQUtils.AmountsArithmetic.toNumber(divided)
+
+                return d.fiatMode ? root.formatBalance(asNumber)
+                                  : root.formatFiat(asNumber)
+            }
+
+            elide: Text.ElideMiddle
+            font.pixelSize: 13
+            color: Theme.palette.directColor5
+
+            MouseArea {
+                objectName: "amountToSend_mouseArea"
+
+                anchors.fill: parent
+                cursorShape: enabled ? Qt.PointingHandCursor : undefined
+                enabled: root.interactive
+
+                onClicked: {
+                    const secondaryValue = d.secondaryValue
+
+                    d.fiatMode = !d.fiatMode
+
+                    if (textField.length === 0)
+                        return
+
+                    const decimalPlaces = d.fiatMode ? root.fiatDecimalPlaces
+                                                     : root.multiplierIndex
+                    const divisor = SQUtils.AmountsArithmetic.fromExponent(
+                                      decimalPlaces)
+
+                    const stringNumber = SQUtils.AmountsArithmetic.div(
+                        SQUtils.AmountsArithmetic.fromString(secondaryValue),
+                        divisor).toFixed(decimalPlaces)
+
+                    const trimmed = d.fiatMode
+                                  ? stringNumber
+                                  : d.removeDecimalTrailingZeros(stringNumber)
+
+                    textField.text = d.localize(trimmed)
+                }
+            }
+        }
+    }
+}
diff --git a/ui/imports/shared/popups/send/views/qmldir b/ui/imports/shared/popups/send/views/qmldir
index 7cfecdf98b..4c4cfba2ca 100644
--- a/ui/imports/shared/popups/send/views/qmldir
+++ b/ui/imports/shared/popups/send/views/qmldir
@@ -1,5 +1,6 @@
 AmountToReceive 1.0 AmountToReceive.qml
 AmountToSend 1.0 AmountToSend.qml
+AmountToSendNew 1.0 AmountToSendNew.qml
 FeesView 1.0 FeesView.qml
 NetworkCardsComponent 1.0 NetworkCardsComponent.qml
 NetworkSelector 1.0 NetworkSelector.qml