From 67b1e501e84cdc14a6eb8663804ee73e8cd3cead Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Apr 2026 18:38:36 -0300 Subject: [PATCH] feat(amm/ui): add liquidity deposit preview (#61) Fixes #61 --- amm-ui/qml/Main.qml | 33 +++-- amm-ui/qml/components/AddLiquidityForm.qml | 142 +++++++++++++++++++ amm-ui/qml/components/TokenAmountInput.qml | 156 +++++++++++++++++++++ amm-ui/qml/state/DummyPoolState.qml | 68 ++++++++- 4 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 amm-ui/qml/components/AddLiquidityForm.qml create mode 100644 amm-ui/qml/components/TokenAmountInput.qml diff --git a/amm-ui/qml/Main.qml b/amm-ui/qml/Main.qml index 873eb02..ae59e14 100644 --- a/amm-ui/qml/Main.qml +++ b/amm-ui/qml/Main.qml @@ -180,15 +180,32 @@ Item { color: "#151515" } - ColumnLayout { - anchors.fill: parent - anchors.margins: 12 - spacing: 10 + Flickable { + id: scroll - PoolPositionSummary { - poolState: poolState - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight + anchors.fill: parent + clip: true + contentHeight: content.implicitHeight + 24 + contentWidth: width + + ColumnLayout { + id: content + spacing: 10 + width: scroll.width - 24 + x: 12 + y: 12 + + PoolPositionSummary { + poolState: poolState + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + } + + AddLiquidityForm { + poolState: poolState + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + } } } } diff --git a/amm-ui/qml/components/AddLiquidityForm.qml b/amm-ui/qml/components/AddLiquidityForm.qml new file mode 100644 index 0000000..814815b --- /dev/null +++ b/amm-ui/qml/components/AddLiquidityForm.qml @@ -0,0 +1,142 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import "../state" + +Rectangle { + id: root + + required property DummyPoolState poolState + + property string amountA: "" + property string amountB: "" + property string lastEditedToken: "A" + readonly property real parsedA: root.poolState.parseAmount(root.amountA) + readonly property real parsedB: root.poolState.parseAmount(root.amountB) + readonly property var preview: root.poolState.addLiquidityPreview(root.parsedA, root.parsedB) + readonly property bool hasAnyAmount: root.parsedA > 0 || root.parsedB > 0 + readonly property bool amountAOverBalance: root.parsedA > root.poolState.walletBalanceA + readonly property bool amountBOverBalance: root.parsedB > root.poolState.walletBalanceB + readonly property bool zeroTokenDeposit: root.hasAnyAmount && (root.preview.actualA === 0 || root.preview.actualB === 0) + readonly property bool zeroLpDeposit: root.preview.actualA > 0 && root.preview.actualB > 0 && root.preview.deltaLp === 0 + readonly property string warningText: root.zeroTokenDeposit ? qsTr("Deposit would be rejected because one token amount rounds to zero") : root.zeroLpDeposit ? qsTr("Deposit would mint 0 LP tokens") : "" + + color: "#1D1D1D" + implicitHeight: content.implicitHeight + 20 + radius: 8 + border.color: "#343434" + border.width: 1 + + function setAmounts(nextA, nextB, intentToken, showZero) { + root.lastEditedToken = intentToken; + root.amountA = nextA > 0 || showZero ? root.poolState.formatInputAmount(nextA) : ""; + root.amountB = nextB > 0 || showZero ? root.poolState.formatInputAmount(nextB) : ""; + } + + function updateFromTokenA(value) { + if (value.length === 0) { + setAmounts(0, 0, "A", false); + return; + } + + const nextA = root.poolState.parseAmount(value); + setAmounts(nextA, root.poolState.amountBForA(nextA), "A", true); + } + + function updateFromTokenB(value) { + if (value.length === 0) { + setAmounts(0, 0, "B", false); + return; + } + + const nextB = root.poolState.parseAmount(value); + setAmounts(root.poolState.amountAForB(nextB), nextB, "B", true); + } + + function useMax(intentToken) { + const capped = root.poolState.maxAddLiquidityForBalances(); + setAmounts(capped.actualA, capped.actualB, intentToken, false); + } + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 16 + text: qsTr("Add liquidity") + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Current ratio") + value: qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA) + + Layout.fillWidth: true + } + + TokenAmountInput { + balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceA, root.poolState.tokenA) + errorText: root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : "" + helperText: root.lastEditedToken === "B" && root.amountA.length > 0 ? qsTr("Calculated from current pool ratio") : "" + label: qsTr("Token A amount") + token: root.poolState.tokenA + text: root.amountA + + Layout.fillWidth: true + + onEditingChanged: function (value) { + root.updateFromTokenA(value); + } + onMaxClicked: root.useMax("A") + } + + TokenAmountInput { + balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceB, root.poolState.tokenB) + errorText: root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : "" + helperText: root.lastEditedToken === "A" && root.amountB.length > 0 ? qsTr("Calculated from current pool ratio") : "" + label: qsTr("Token B amount") + token: root.poolState.tokenB + text: root.amountB + + Layout.fillWidth: true + + onEditingChanged: function (value) { + root.updateFromTokenB(value); + } + onMaxClicked: root.useMax("B") + } + + SummaryRow { + label: qsTr("Required ratio") + value: qsTr("%1 %2 / 1 %3").arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA).arg(root.poolState.tokenB) + + Layout.fillWidth: true + } + + SummaryRow { + estimated: true + estimateHelp: qsTr("Estimated with the same integer floor math used by the add-liquidity contract path.") + label: qsTr("Estimated LP tokens") + value: root.poolState.formatLpAmount(root.preview.deltaLp) + + Layout.fillWidth: true + } + + Text { + color: "#F08A76" + font.pixelSize: 12 + lineHeight: 1.25 + text: root.warningText + visible: root.warningText.length > 0 + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + } +} diff --git a/amm-ui/qml/components/TokenAmountInput.qml b/amm-ui/qml/components/TokenAmountInput.qml new file mode 100644 index 0000000..120be57 --- /dev/null +++ b/amm-ui/qml/components/TokenAmountInput.qml @@ -0,0 +1,156 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Rectangle { + id: root + + property alias text: amountField.text + property string balance: "" + property string errorText: "" + property string helperText: "" + property string label: "" + property string token: "" + + signal editingChanged(string value) + signal maxClicked + + color: "#151515" + implicitHeight: content.implicitHeight + 20 + radius: 8 + border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + + Accessible.name: root.label + Accessible.role: Accessible.EditableText + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + RowLayout { + spacing: 8 + + Layout.fillWidth: true + + Text { + color: "#A9A098" + elide: Text.ElideRight + font.pixelSize: 12 + text: root.label + + Layout.fillWidth: true + } + + Text { + color: "#E7E1D8" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + text: root.token + + Layout.maximumWidth: 76 + } + } + + RowLayout { + spacing: 8 + + Layout.fillWidth: true + + TextField { + id: amountField + + activeFocusOnTab: true + color: "#E7E1D8" + font.bold: true + font.pixelSize: 18 + inputMethodHints: Qt.ImhFormattedNumbersOnly + placeholderText: qsTr("0") + selectByMouse: true + selectedTextColor: "#151515" + selectionColor: "#F26A21" + validator: RegularExpressionValidator { + regularExpression: /[0-9]*([.][0-9]*)?/ + } + + Accessible.name: root.label + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onTextEdited: root.editingChanged(text) + + background: Rectangle { + border.color: amountField.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + color: amountField.activeFocus ? "#1F1B18" : "#101010" + radius: 6 + } + } + + Button { + id: maxButton + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("MAX") + + Accessible.name: qsTr("Use maximum %1 balance").arg(root.token) + + Layout.minimumHeight: 44 + Layout.preferredWidth: 58 + + onClicked: root.maxClicked() + + contentItem: Text { + color: maxButton.activeFocus || maxButton.hovered ? "#151515" : "#F26A21" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: maxButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: "#F26A21" + border.width: 1 + color: maxButton.pressed ? "#D95C1E" : maxButton.hovered || maxButton.activeFocus ? "#F26A21" : "#201712" + radius: 6 + } + } + } + + RowLayout { + spacing: 8 + + Layout.fillWidth: true + + Text { + color: root.errorText.length > 0 ? "#F08A76" : root.helperText.length > 0 ? "#F26A21" : "#A9A098" + elide: Text.ElideRight + font.pixelSize: 11 + text: root.errorText.length > 0 ? root.errorText : root.helperText + visible: text.length > 0 + + Layout.fillWidth: true + } + + Text { + color: "#A9A098" + elide: Text.ElideRight + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + text: qsTr("Balance %1").arg(root.balance) + + Layout.alignment: Qt.AlignRight + Layout.maximumWidth: 150 + } + } + } +} diff --git a/amm-ui/qml/state/DummyPoolState.qml b/amm-ui/qml/state/DummyPoolState.qml index 570e738..350974f 100644 --- a/amm-ui/qml/state/DummyPoolState.qml +++ b/amm-ui/qml/state/DummyPoolState.qml @@ -10,10 +10,13 @@ QtObject { property real reserveA: 1000000 property real reserveB: 500 property real totalLpSupply: 22360679 + property real walletBalanceA: 60000 + property real walletBalanceB: 20 readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0 readonly property real userOwnedA: reserveA * poolShare readonly property real userOwnedB: reserveB * poolShare + readonly property real tokenAPerTokenB: reserveB > 0 ? Math.floor(reserveA / reserveB) : 0 function applyAddLiquidity(actualA, actualB, mintedLp) { const safeA = Math.max(0, Number(actualA) || 0); @@ -45,6 +48,55 @@ QtObject { reserveA = 1000000; reserveB = 500; totalLpSupply = 22360679; + walletBalanceA = 60000; + walletBalanceB = 20; + } + + function parseAmount(value) { + return Math.max(0, Number(value) || 0); + } + + function floorAmount(value) { + return Math.floor(parseAmount(value)); + } + + function amountBForA(amountA) { + if (reserveA <= 0) { + return 0; + } + + return reserveB * parseAmount(amountA) / reserveA; + } + + function amountAForB(amountB) { + if (reserveB <= 0) { + return 0; + } + + return reserveA * parseAmount(amountB) / reserveB; + } + + function addLiquidityPreview(maxA, maxB) { + const safeMaxA = parseAmount(maxA); + const safeMaxB = parseAmount(maxB); + const idealA = reserveB > 0 ? reserveA * safeMaxB / reserveB : 0; + const idealB = reserveA > 0 ? reserveB * safeMaxA / reserveA : 0; + const actualA = Math.min(idealA, safeMaxA); + const actualB = Math.min(idealB, safeMaxB); + const lpFromA = reserveA > 0 ? Math.floor(totalLpSupply * actualA / reserveA) : 0; + const lpFromB = reserveB > 0 ? Math.floor(totalLpSupply * actualB / reserveB) : 0; + + return { + "actualA": actualA, + "actualB": actualB, + "deltaLp": Math.min(lpFromA, lpFromB), + "idealA": idealA, + "idealB": idealB + }; + } + + function maxAddLiquidityForBalances() { + return addLiquidityPreview(walletBalanceA, walletBalanceB); } function formatInteger(value) { @@ -52,8 +104,22 @@ QtObject { return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } + function formatDecimal(value) { + const amount = Number(value) || 0; + + if (Math.abs(amount - Math.round(amount)) < 0.000001) { + return formatInteger(amount); + } + + return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); + } + + function formatInputAmount(value) { + return formatDecimal(value); + } + function formatTokenAmount(value, token) { - return formatInteger(value) + " " + token; + return formatDecimal(value) + " " + token; } function formatLpAmount(value) {