From bf9001c363a22e54ec3d00acfbfe4987cfb0e73c Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Apr 2026 18:51:27 -0300 Subject: [PATCH] feat(amm/ui): add remove-liquidity preview (#62) Fixes #62 --- amm-ui/qml/Main.qml | 25 +- amm-ui/qml/components/LiquidityActionTabs.qml | 91 ++++ amm-ui/qml/components/RemoveLiquidityForm.qml | 426 ++++++++++++++++++ amm-ui/qml/state/DummyPoolState.qml | 44 ++ 4 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 amm-ui/qml/components/LiquidityActionTabs.qml create mode 100644 amm-ui/qml/components/RemoveLiquidityForm.qml diff --git a/amm-ui/qml/Main.qml b/amm-ui/qml/Main.qml index ae59e14..cbf16ec 100644 --- a/amm-ui/qml/Main.qml +++ b/amm-ui/qml/Main.qml @@ -168,9 +168,12 @@ Item { // ── Liquidity view ──────────────────────────────────────────────────── Item { + id: liquidityView anchors.fill: parent visible: navbar.currentIndex === 1 + property int activeLiquidityTab: 0 + DummyPoolState { id: poolState } @@ -201,10 +204,28 @@ Item { Layout.preferredHeight: implicitHeight } - AddLiquidityForm { - poolState: poolState + LiquidityActionTabs { + currentIndex: liquidityView.activeLiquidityTab Layout.fillWidth: true Layout.preferredHeight: implicitHeight + + onTabRequested: function(index) { + liquidityView.activeLiquidityTab = index + } + } + + AddLiquidityForm { + poolState: poolState + visible: liquidityView.activeLiquidityTab === 0 + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + } + + RemoveLiquidityForm { + poolState: poolState + visible: liquidityView.activeLiquidityTab === 1 + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 } } } diff --git a/amm-ui/qml/components/LiquidityActionTabs.qml b/amm-ui/qml/components/LiquidityActionTabs.qml new file mode 100644 index 0000000..d4b2d32 --- /dev/null +++ b/amm-ui/qml/components/LiquidityActionTabs.qml @@ -0,0 +1,91 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Rectangle { + id: root + + property int currentIndex: 0 + + signal tabRequested(int index) + + color: "#1D1D1D" + implicitHeight: 44 + radius: 8 + border.color: "#343434" + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.margins: 4 + spacing: 4 + + Button { + id: addTab + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("Add liquidity") + + Accessible.name: addTab.text + Accessible.role: Accessible.PageTab + + Layout.fillHeight: true + Layout.fillWidth: true + + onClicked: root.tabRequested(0) + + contentItem: Text { + color: root.currentIndex === 0 || addTab.hovered || addTab.activeFocus ? "#151515" : "#A9A098" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + text: addTab.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: addTab.activeFocus ? "#F26A21" : root.currentIndex === 0 ? "#F26A21" : "#151515" + border.width: 1 + color: addTab.pressed ? "#D95C1E" : root.currentIndex === 0 ? "#F26A21" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#151515" + radius: 6 + } + } + + Button { + id: removeTab + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("Remove liquidity") + + Accessible.name: removeTab.text + Accessible.role: Accessible.PageTab + + Layout.fillHeight: true + Layout.fillWidth: true + + onClicked: root.tabRequested(1) + + contentItem: Text { + color: root.currentIndex === 1 || removeTab.hovered || removeTab.activeFocus ? "#151515" : "#A9A098" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + text: removeTab.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: removeTab.activeFocus ? "#F26A21" : root.currentIndex === 1 ? "#F26A21" : "#151515" + border.width: 1 + color: removeTab.pressed ? "#D95C1E" : root.currentIndex === 1 ? "#F26A21" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#151515" + radius: 6 + } + } + } +} diff --git a/amm-ui/qml/components/RemoveLiquidityForm.qml b/amm-ui/qml/components/RemoveLiquidityForm.qml new file mode 100644 index 0000000..76284ef --- /dev/null +++ b/amm-ui/qml/components/RemoveLiquidityForm.qml @@ -0,0 +1,426 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import "../state" + +Rectangle { + id: root + + required property DummyPoolState poolState + + property int burnAmount: 0 + readonly property int maxBurnAmount: root.poolState.clampBurnAmount(root.poolState.userLpBalance) + readonly property bool hasLpTokens: root.maxBurnAmount > 0 + readonly property int preset25Amount: root.poolState.burnAmountForPercent(25) + readonly property int preset50Amount: root.poolState.burnAmountForPercent(50) + readonly property int preset75Amount: root.poolState.burnAmountForPercent(75) + readonly property real removePercent: root.maxBurnAmount > 0 ? root.burnAmount * 100 / root.maxBurnAmount : 0 + readonly property var preview: root.poolState.removeLiquidityPreview(root.burnAmount) + readonly property string estimateHelp: qsTr("Estimated with the same integer floor math used by the remove-liquidity contract path.") + + color: "#1D1D1D" + implicitHeight: content.implicitHeight + 20 + radius: 8 + border.color: "#343434" + border.width: 1 + + onMaxBurnAmountChanged: { + if (root.burnAmount > root.maxBurnAmount) { + root.setBurnAmount(root.maxBurnAmount); + } + } + + function setBurnAmount(value) { + root.burnAmount = root.poolState.clampBurnAmount(value); + } + + function setBurnPercent(percent) { + root.setBurnAmount(root.poolState.burnAmountForPercent(percent)); + } + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 16 + text: qsTr("Remove liquidity") + + Layout.fillWidth: true + } + + Text { + color: "#A9A098" + font.pixelSize: 12 + lineHeight: 1.25 + text: qsTr("Burn LP tokens to withdraw your proportional share of both pool tokens.") + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + + Text { + color: "#F26A21" + font.pixelSize: 12 + text: qsTr("No LP tokens") + visible: !root.hasLpTokens + + Layout.fillWidth: true + } + + Text { + color: "#A9A098" + font.pixelSize: 12 + lineHeight: 1.25 + text: qsTr("Add liquidity first to receive LP tokens before removing from this pool.") + visible: !root.hasLpTokens + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + + Rectangle { + color: root.hasLpTokens ? "#151515" : "#121212" + radius: 8 + border.color: burnField.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + + Layout.fillWidth: true + Layout.preferredHeight: inputContent.implicitHeight + 20 + + ColumnLayout { + id: inputContent + + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + RowLayout { + spacing: 8 + + Layout.fillWidth: true + + Text { + color: "#A9A098" + elide: Text.ElideRight + font.pixelSize: 12 + text: qsTr("LP tokens to burn") + + Layout.fillWidth: true + } + + Text { + color: "#A9A098" + elide: Text.ElideRight + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + text: qsTr("Available LP: %1").arg(root.poolState.formatInteger(root.poolState.userLpBalance)) + + Layout.maximumWidth: 170 + } + } + + TextField { + id: burnField + + activeFocusOnTab: root.hasLpTokens + color: "#E7E1D8" + enabled: root.hasLpTokens + font.bold: true + font.pixelSize: 18 + inputMethodHints: Qt.ImhDigitsOnly + placeholderText: qsTr("0") + selectByMouse: true + selectedTextColor: "#151515" + selectionColor: "#F26A21" + text: root.burnAmount > 0 ? String(root.burnAmount) : "" + validator: RegularExpressionValidator { + regularExpression: /[0-9]*/ + } + + Accessible.name: qsTr("LP tokens to burn") + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onTextEdited: root.setBurnAmount(text) + + background: Rectangle { + border.color: burnField.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + color: burnField.activeFocus ? "#1F1B18" : "#101010" + radius: 6 + } + } + } + } + + RowLayout { + spacing: 6 + + Layout.fillWidth: true + + Button { + id: preset25 + + activeFocusOnTab: root.hasLpTokens + enabled: root.hasLpTokens + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("25%") + + Accessible.name: qsTr("Remove 25 percent") + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.setBurnPercent(25) + + contentItem: Text { + color: preset25.enabled && (preset25.hovered || preset25.activeFocus || root.preset25Amount === root.burnAmount) ? "#151515" : "#A9A098" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: preset25.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: preset25.activeFocus || root.preset25Amount === root.burnAmount ? "#F26A21" : "#343434" + border.width: 1 + color: preset25.pressed ? "#D95C1E" : root.preset25Amount === root.burnAmount ? "#F26A21" : preset25.hovered || preset25.activeFocus ? "#E7E1D8" : "#151515" + radius: 6 + } + } + + Button { + id: preset50 + + activeFocusOnTab: root.hasLpTokens + enabled: root.hasLpTokens + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("50%") + + Accessible.name: qsTr("Remove 50 percent") + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.setBurnPercent(50) + + contentItem: Text { + color: preset50.enabled && (preset50.hovered || preset50.activeFocus || root.preset50Amount === root.burnAmount) ? "#151515" : "#A9A098" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: preset50.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: preset50.activeFocus || root.preset50Amount === root.burnAmount ? "#F26A21" : "#343434" + border.width: 1 + color: preset50.pressed ? "#D95C1E" : root.preset50Amount === root.burnAmount ? "#F26A21" : preset50.hovered || preset50.activeFocus ? "#E7E1D8" : "#151515" + radius: 6 + } + } + + Button { + id: preset75 + + activeFocusOnTab: root.hasLpTokens + enabled: root.hasLpTokens + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("75%") + + Accessible.name: qsTr("Remove 75 percent") + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.setBurnPercent(75) + + contentItem: Text { + color: preset75.enabled && (preset75.hovered || preset75.activeFocus || root.preset75Amount === root.burnAmount) ? "#151515" : "#A9A098" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: preset75.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: preset75.activeFocus || root.preset75Amount === root.burnAmount ? "#F26A21" : "#343434" + border.width: 1 + color: preset75.pressed ? "#D95C1E" : root.preset75Amount === root.burnAmount ? "#F26A21" : preset75.hovered || preset75.activeFocus ? "#E7E1D8" : "#151515" + radius: 6 + } + } + + Button { + id: presetMax + + activeFocusOnTab: root.hasLpTokens + enabled: root.hasLpTokens + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("MAX") + + Accessible.name: qsTr("Remove maximum LP balance") + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.setBurnAmount(root.maxBurnAmount) + + contentItem: Text { + color: presetMax.enabled && (presetMax.hovered || presetMax.activeFocus || root.burnAmount === root.maxBurnAmount) ? "#151515" : "#A9A098" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: presetMax.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: presetMax.activeFocus || root.burnAmount === root.maxBurnAmount ? "#F26A21" : "#343434" + border.width: 1 + color: presetMax.pressed ? "#D95C1E" : root.burnAmount === root.maxBurnAmount ? "#F26A21" : presetMax.hovered || presetMax.activeFocus ? "#E7E1D8" : "#151515" + radius: 6 + } + } + } + + ColumnLayout { + spacing: 6 + + Layout.fillWidth: true + + RowLayout { + spacing: 8 + + Layout.fillWidth: true + + Text { + color: "#A9A098" + font.pixelSize: 12 + text: qsTr("Pool share to remove") + + Layout.fillWidth: true + } + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + text: root.poolState.formatPercent(root.removePercent) + + Layout.maximumWidth: 72 + } + } + + Slider { + id: burnSlider + + activeFocusOnTab: root.hasLpTokens + enabled: root.hasLpTokens + from: 0 + stepSize: 1 + to: 100 + value: root.removePercent + + Accessible.name: qsTr("Pool share to remove") + Accessible.role: Accessible.Slider + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onMoved: root.setBurnPercent(Math.round(value)) + + background: Rectangle { + color: "#343434" + implicitHeight: 4 + radius: 2 + x: burnSlider.leftPadding + y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2 + + width: burnSlider.availableWidth + + Rectangle { + color: burnSlider.enabled ? "#F26A21" : "#56504A" + height: parent.height + radius: 2 + width: burnSlider.visualPosition * parent.width + } + } + + handle: Rectangle { + border.color: burnSlider.activeFocus ? "#E7E1D8" : "#F26A21" + border.width: 1 + color: burnSlider.enabled ? "#F26A21" : "#56504A" + height: 18 + radius: 9 + width: 18 + x: burnSlider.leftPadding + burnSlider.visualPosition * (burnSlider.availableWidth - width) + y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2 + } + } + } + + SummaryRow { + estimated: true + estimateHelp: root.estimateHelp + label: qsTr("Withdraw %1").arg(root.poolState.tokenA) + value: root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA) + + Layout.fillWidth: true + } + + SummaryRow { + estimated: true + estimateHelp: root.estimateHelp + label: qsTr("Withdraw %1").arg(root.poolState.tokenB) + value: root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB) + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("New reserve A") + value: root.poolState.formatTokenAmount(root.preview.newReserveA, root.poolState.tokenA) + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("New reserve B") + value: root.poolState.formatTokenAmount(root.preview.newReserveB, root.poolState.tokenB) + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("New LP supply") + value: root.poolState.formatInteger(root.preview.newTotalLpSupply) + + Layout.fillWidth: true + } + + SummaryRow { + estimated: true + estimateHelp: root.estimateHelp + label: qsTr("New user share") + value: root.poolState.formatPoolShare(root.preview.newUserShare) + + Layout.fillWidth: true + } + } +} diff --git a/amm-ui/qml/state/DummyPoolState.qml b/amm-ui/qml/state/DummyPoolState.qml index 350974f..98f854d 100644 --- a/amm-ui/qml/state/DummyPoolState.qml +++ b/amm-ui/qml/state/DummyPoolState.qml @@ -12,6 +12,7 @@ QtObject { property real totalLpSupply: 22360679 property real walletBalanceA: 60000 property real walletBalanceB: 20 + readonly property real minimumLiquidity: 1000 readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0 readonly property real userOwnedA: reserveA * poolShare @@ -99,6 +100,39 @@ QtObject { return addLiquidityPreview(walletBalanceA, walletBalanceB); } + function clampBurnAmount(value) { + return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance))); + } + + function burnAmountForPercent(percent) { + const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); + + if (safePercent === 100) { + return clampBurnAmount(userLpBalance); + } + + return clampBurnAmount(Math.floor(userLpBalance * safePercent / 100)); + } + + function removeLiquidityPreview(burnedLp) { + const safeBurnedLp = totalLpSupply > 0 ? Math.min(clampBurnAmount(burnedLp), floorAmount(totalLpSupply)) : 0; + const withdrawA = totalLpSupply > 0 ? Math.floor(reserveA * safeBurnedLp / totalLpSupply) : 0; + const withdrawB = totalLpSupply > 0 ? Math.floor(reserveB * safeBurnedLp / totalLpSupply) : 0; + const newTotalLpSupply = Math.max(0, floorAmount(totalLpSupply) - safeBurnedLp); + const newUserLpBalance = Math.max(0, floorAmount(userLpBalance) - safeBurnedLp); + + return { + "burnedLp": safeBurnedLp, + "newReserveA": Math.max(0, reserveA - withdrawA), + "newReserveB": Math.max(0, reserveB - withdrawB), + "newTotalLpSupply": newTotalLpSupply, + "newUserLpBalance": newUserLpBalance, + "newUserShare": newTotalLpSupply > 0 ? newUserLpBalance / newTotalLpSupply : 0, + "withdrawA": withdrawA, + "withdrawB": withdrawB + }; + } + function formatInteger(value) { const rounded = Math.round(Number(value) || 0); return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); @@ -129,4 +163,14 @@ QtObject { function formatPoolShare(value) { return "\u2248 " + (Math.max(0, Number(value) || 0) * 100).toFixed(2) + "%"; } + + function formatPercent(value) { + const amount = Math.max(0, Number(value) || 0); + + if (Math.abs(amount - Math.round(amount)) < 0.000001) { + return Math.round(amount).toString() + "%"; + } + + return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "") + "%"; + } }