From 1c8db801407ba2aa0cedaafcbbd04ca5323b5378 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 22 Apr 2026 19:18:59 -0300 Subject: [PATCH] feat(amm/ui): add mock confirmation flow (#64) Fixes #64 --- amm-ui/qml/Main.qml | 73 +----- amm-ui/qml/components/AddLiquidityForm.qml | 156 ++++++++---- amm-ui/qml/components/LiquidityActionTabs.qml | 18 +- .../LiquidityConfirmationDialog.qml | 238 ++++++++++++++++++ amm-ui/qml/components/PoolPositionSummary.qml | 140 +++++------ amm-ui/qml/components/RemoveLiquidityForm.qml | 134 ++++++---- .../components/SlippageToleranceControl.qml | 80 +++--- amm-ui/qml/components/SuccessToast.qml | 103 ++++++++ amm-ui/qml/components/SummaryRow.qml | 2 +- amm-ui/qml/pages/LiquidityPage.qml | 188 ++++++++++++++ amm-ui/qml/state/DummyPoolState.qml | 18 ++ 11 files changed, 826 insertions(+), 324 deletions(-) create mode 100644 amm-ui/qml/components/LiquidityConfirmationDialog.qml create mode 100644 amm-ui/qml/components/SuccessToast.qml create mode 100644 amm-ui/qml/pages/LiquidityPage.qml diff --git a/amm-ui/qml/Main.qml b/amm-ui/qml/Main.qml index 0dece81..c1ff8dd 100644 --- a/amm-ui/qml/Main.qml +++ b/amm-ui/qml/Main.qml @@ -3,6 +3,7 @@ import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import "components" import "state" +import "pages" Item { id: root @@ -167,79 +168,9 @@ Item { } // ── Liquidity view ──────────────────────────────────────────────────── - Item { - id: liquidityView + LiquidityPage { anchors.fill: parent visible: navbar.currentIndex === 1 - - property int activeLiquidityTab: 0 - property real slippageTolerancePercent: 0.5 - - DummyPoolState { - id: poolState - } - - Rectangle { - anchors.fill: parent - color: "#151515" - } - - Flickable { - id: scroll - - 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 - } - - LiquidityActionTabs { - currentIndex: liquidityView.activeLiquidityTab - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - - onTabRequested: function(index) { - liquidityView.activeLiquidityTab = index - } - } - - AddLiquidityForm { - poolState: poolState - slippageTolerancePercent: liquidityView.slippageTolerancePercent - visible: liquidityView.activeLiquidityTab === 0 - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 - - onSlippageToleranceChangeRequested: function(tolerancePercent) { - liquidityView.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent) - } - } - - RemoveLiquidityForm { - poolState: poolState - slippageTolerancePercent: liquidityView.slippageTolerancePercent - visible: liquidityView.activeLiquidityTab === 1 - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 - - onSlippageToleranceChangeRequested: function(tolerancePercent) { - liquidityView.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent) - } - } - } - } } } } diff --git a/amm-ui/qml/components/AddLiquidityForm.qml b/amm-ui/qml/components/AddLiquidityForm.qml index 01a5a27..2a1de26 100644 --- a/amm-ui/qml/components/AddLiquidityForm.qml +++ b/amm-ui/qml/components/AddLiquidityForm.qml @@ -1,4 +1,5 @@ import QtQuick 2.15 +import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import "../state" @@ -21,70 +22,24 @@ Rectangle { readonly property bool minReceivedIsZero: root.hasAnyAmount && root.minLpReceived === 0 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 bool canSubmit: root.hasAnyAmount && !root.amountAOverBalance && !root.amountBOverBalance && !root.minReceivedIsZero && !root.zeroTokenDeposit && !root.zeroLpDeposit + readonly property string submitButtonText: !root.hasAnyAmount ? qsTr("Enter an amount") : root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : root.zeroTokenDeposit ? qsTr("Amount rounds to zero") : root.zeroLpDeposit ? qsTr("LP output is 0") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Add Liquidity") 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") : "" signal slippageToleranceChangeRequested(real tolerancePercent) + signal addLiquidityRequested(var snapshot) - 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); - } + color: "#00000000" + implicitHeight: content.implicitHeight + radius: 0 + border.width: 0 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) : "" @@ -118,8 +73,8 @@ Rectangle { } 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) + label: qsTr("Current price") + value: qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA) Layout.fillWidth: true } @@ -129,6 +84,7 @@ Rectangle { 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) + visible: root.hasAnyAmount Layout.fillWidth: true } @@ -146,6 +102,7 @@ Rectangle { SummaryRow { label: qsTr("Min LP received") value: root.poolState.formatLpAmount(root.minLpReceived) + visible: root.hasAnyAmount Layout.fillWidth: true } @@ -171,5 +128,94 @@ Rectangle { Layout.fillWidth: true } + + Button { + id: submitButton + + activeFocusOnTab: true + enabled: root.canSubmit + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: root.submitButtonText + + Accessible.name: submitButton.text + + Layout.fillWidth: true + Layout.minimumHeight: 44 + Layout.preferredHeight: 44 + + onClicked: root.addLiquidityRequested(root.submitSnapshot()) + + contentItem: Text { + color: submitButton.enabled ? "#151515" : "#7D756E" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + text: submitButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: submitButton.enabled ? "#F26A21" : "#343434" + border.width: 1 + color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" + radius: 6 + } + } + } + + 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); + } + + function resetForm() { + root.amountA = ""; + root.amountB = ""; + root.lastEditedToken = "A"; + } + + function submitSnapshot() { + return { + "action": "add", + "actualA": root.preview.actualA, + "actualB": root.preview.actualB, + "currentRatio": qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA), + "deltaLp": root.preview.deltaLp, + "depositA": root.poolState.formatTokenAmount(root.preview.actualA, root.poolState.tokenA), + "depositB": root.poolState.formatTokenAmount(root.preview.actualB, root.poolState.tokenB), + "feeTier": root.poolState.feeTier, + "minLpReceived": root.poolState.formatLpAmount(root.minLpReceived), + "slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent), + "tokenA": root.poolState.tokenA, + "tokenB": root.poolState.tokenB + }; } } diff --git a/amm-ui/qml/components/LiquidityActionTabs.qml b/amm-ui/qml/components/LiquidityActionTabs.qml index d4b2d32..5a7c1d1 100644 --- a/amm-ui/qml/components/LiquidityActionTabs.qml +++ b/amm-ui/qml/components/LiquidityActionTabs.qml @@ -9,10 +9,10 @@ Rectangle { signal tabRequested(int index) - color: "#1D1D1D" - implicitHeight: 44 + color: "#181818" + implicitHeight: 42 radius: 8 - border.color: "#343434" + border.color: "#303030" border.width: 1 RowLayout { @@ -37,7 +37,7 @@ Rectangle { onClicked: root.tabRequested(0) contentItem: Text { - color: root.currentIndex === 0 || addTab.hovered || addTab.activeFocus ? "#151515" : "#A9A098" + color: root.currentIndex === 0 ? "#F2D8C7" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#8E8780" elide: Text.ElideRight font.bold: true font.pixelSize: 12 @@ -47,9 +47,9 @@ Rectangle { } background: Rectangle { - border.color: addTab.activeFocus ? "#F26A21" : root.currentIndex === 0 ? "#F26A21" : "#151515" + border.color: addTab.activeFocus || root.currentIndex === 0 ? "#F26A21" : "#181818" border.width: 1 - color: addTab.pressed ? "#D95C1E" : root.currentIndex === 0 ? "#F26A21" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#151515" + color: addTab.pressed ? "#2A1D16" : root.currentIndex === 0 ? "#211914" : addTab.hovered || addTab.activeFocus ? "#202020" : "#121212" radius: 6 } } @@ -71,7 +71,7 @@ Rectangle { onClicked: root.tabRequested(1) contentItem: Text { - color: root.currentIndex === 1 || removeTab.hovered || removeTab.activeFocus ? "#151515" : "#A9A098" + color: root.currentIndex === 1 ? "#F2D8C7" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#8E8780" elide: Text.ElideRight font.bold: true font.pixelSize: 12 @@ -81,9 +81,9 @@ Rectangle { } background: Rectangle { - border.color: removeTab.activeFocus ? "#F26A21" : root.currentIndex === 1 ? "#F26A21" : "#151515" + border.color: removeTab.activeFocus || root.currentIndex === 1 ? "#F26A21" : "#181818" border.width: 1 - color: removeTab.pressed ? "#D95C1E" : root.currentIndex === 1 ? "#F26A21" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#151515" + color: removeTab.pressed ? "#2A1D16" : root.currentIndex === 1 ? "#211914" : removeTab.hovered || removeTab.activeFocus ? "#202020" : "#121212" radius: 6 } } diff --git a/amm-ui/qml/components/LiquidityConfirmationDialog.qml b/amm-ui/qml/components/LiquidityConfirmationDialog.qml new file mode 100644 index 0000000..16b0dc6 --- /dev/null +++ b/amm-ui/qml/components/LiquidityConfirmationDialog.qml @@ -0,0 +1,238 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +FocusScope { + id: root + + property var snapshot: ({}) + property bool open: false + readonly property bool isAdd: root.snapshot.action === "add" + + signal canceled + signal confirmed(var snapshot) + + visible: root.open + z: 20 + + Keys.onEscapePressed: root.cancel() + + function openWithSnapshot(nextSnapshot) { + root.snapshot = nextSnapshot; + root.open = true; + root.forceActiveFocus(); + cancelButton.forceActiveFocus(); + } + + function cancel() { + root.open = false; + root.canceled(); + } + + function confirm() { + const confirmedSnapshot = root.snapshot; + root.open = false; + root.confirmed(confirmedSnapshot); + } + + Rectangle { + anchors.fill: parent + color: "#99000000" + + MouseArea { + anchors.fill: parent + } + } + + Rectangle { + id: panel + + anchors.centerIn: parent + color: "#1D1D1D" + implicitHeight: dialogContent.implicitHeight + 24 + radius: 8 + width: Math.max(0, Math.min(360, root.width - 32)) + border.color: "#343434" + border.width: 1 + + ColumnLayout { + id: dialogContent + + anchors.fill: parent + anchors.margins: 12 + spacing: 12 + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 16 + text: root.isAdd ? qsTr("Confirm add liquidity") : qsTr("Confirm remove liquidity") + + Layout.fillWidth: true + } + + ColumnLayout { + spacing: 8 + visible: root.isAdd + + Layout.fillWidth: true + + SummaryRow { + label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.depositA || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.depositB || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Receive at least") + value: root.snapshot.minLpReceived || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Current ratio") + value: root.snapshot.currentRatio || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Fee tier") + value: root.snapshot.feeTier || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Slippage tolerance") + value: root.snapshot.slippageTolerance || "" + + Layout.fillWidth: true + } + } + + ColumnLayout { + spacing: 8 + visible: !root.isAdd + + Layout.fillWidth: true + + SummaryRow { + label: qsTr("Burn LP") + value: qsTr("%1 (%2)").arg(root.snapshot.burnText || "").arg(root.snapshot.burnPercent || "") + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.minTokenAReceived || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.minTokenBReceived || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Slippage tolerance") + value: root.snapshot.slippageTolerance || "" + + Layout.fillWidth: true + } + + SummaryRow { + label: qsTr("Post-removal share") + value: root.snapshot.postRemovalShare || "" + + Layout.fillWidth: true + } + } + + RowLayout { + spacing: 8 + + Layout.fillWidth: true + + Button { + id: cancelButton + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("Cancel") + + Accessible.name: cancelButton.text + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.cancel() + + contentItem: Text { + color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + text: cancelButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: cancelButton.activeFocus ? "#F26A21" : "#343434" + border.width: 1 + color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515" + radius: 6 + } + } + + Button { + id: confirmButton + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("Confirm") + + Accessible.name: confirmButton.text + + Layout.fillWidth: true + Layout.minimumHeight: 44 + + onClicked: root.confirm() + + contentItem: Text { + color: "#151515" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + text: confirmButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: "#F26A21" + border.width: 1 + color: confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" + radius: 6 + } + } + } + } + } +} diff --git a/amm-ui/qml/components/PoolPositionSummary.qml b/amm-ui/qml/components/PoolPositionSummary.qml index c336f6e..599aeba 100644 --- a/amm-ui/qml/components/PoolPositionSummary.qml +++ b/amm-ui/qml/components/PoolPositionSummary.qml @@ -8,10 +8,10 @@ Rectangle { required property DummyPoolState poolState readonly property string estimateHelp: qsTr("This value is an estimate from the current dummy reserves and your share of total LP supply.") - color: "#1D1D1D" + color: "#151515" implicitHeight: content.implicitHeight + 20 radius: 8 - border.color: "#343434" + border.color: "#303030" border.width: 1 ColumnLayout { @@ -19,104 +19,80 @@ Rectangle { anchors.fill: parent anchors.margins: 10 - spacing: 4 + spacing: 6 - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 16 - text: qsTr("Pool position") + RowLayout { + spacing: 10 Layout.fillWidth: true + + ColumnLayout { + spacing: 2 + + Layout.fillWidth: true + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 13 + text: root.poolState.userLpBalance > 0 ? qsTr("Your position") : qsTr("No position") + + Layout.fillWidth: true + } + + Text { + color: "#8E8780" + font.pixelSize: 11 + text: qsTr("%1 LP tokens").arg(root.poolState.formatInteger(root.poolState.userLpBalance)) + visible: root.poolState.userLpBalance > 0 + + Layout.fillWidth: true + } + } + + Rectangle { + color: "#211914" + radius: 10 + border.color: "#49301F" + border.width: 1 + + Layout.preferredHeight: 24 + Layout.preferredWidth: shareText.implicitWidth + 18 + + Text { + id: shareText + + anchors.centerIn: parent + color: "#F2D8C7" + font.bold: true + font.pixelSize: 11 + text: root.poolState.userLpBalance > 0 ? root.poolState.formatPoolShare(root.poolState.poolShare) : root.poolState.feeTier + } + } } - Text { - color: "#F26A21" - font.pixelSize: 12 - text: qsTr("You have no position in this pool") - visible: root.poolState.userLpBalance === 0 + SummaryRow { + estimated: true + estimateHelp: root.estimateHelp + label: qsTr("Owned") + value: qsTr("%1 + %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedB, root.poolState.tokenB)) + visible: root.poolState.userLpBalance > 0 Layout.fillWidth: true } SummaryRow { - label: qsTr("Token A") - value: root.poolState.tokenA + label: qsTr("Pool") + value: qsTr("%1 / %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveB, root.poolState.tokenB)) Layout.fillWidth: true } SummaryRow { - label: qsTr("Token B") - value: root.poolState.tokenB - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Fee tier") + label: qsTr("Fee") value: root.poolState.feeTier Layout.fillWidth: true } - - SummaryRow { - label: qsTr("Your LP tokens") - value: root.poolState.formatInteger(root.poolState.userLpBalance) - visible: root.poolState.userLpBalance > 0 - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Pool share") - value: root.poolState.formatPoolShare(root.poolState.poolShare) - visible: root.poolState.userLpBalance > 0 - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Your Token A") - value: "\u2248 " + root.poolState.formatTokenAmount(root.poolState.userOwnedA, root.poolState.tokenA) - visible: root.poolState.userLpBalance > 0 - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Your Token B") - value: "\u2248 " + root.poolState.formatTokenAmount(root.poolState.userOwnedB, root.poolState.tokenB) - visible: root.poolState.userLpBalance > 0 - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Total reserve A") - value: root.poolState.formatTokenAmount(root.poolState.reserveA, root.poolState.tokenA) - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Total reserve B") - value: root.poolState.formatTokenAmount(root.poolState.reserveB, root.poolState.tokenB) - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Total LP supply") - value: root.poolState.formatInteger(root.poolState.totalLpSupply) - - Layout.fillWidth: true - } } } diff --git a/amm-ui/qml/components/RemoveLiquidityForm.qml b/amm-ui/qml/components/RemoveLiquidityForm.qml index 9032bc1..47fbbbf 100644 --- a/amm-ui/qml/components/RemoveLiquidityForm.qml +++ b/amm-ui/qml/components/RemoveLiquidityForm.qml @@ -20,15 +20,17 @@ Rectangle { readonly property int minTokenAReceived: root.poolState.minReceivedAmount(root.preview.withdrawA, root.slippageTolerancePercent) readonly property int minTokenBReceived: root.poolState.minReceivedAmount(root.preview.withdrawB, root.slippageTolerancePercent) readonly property bool minReceivedIsZero: root.burnAmount > 0 && (root.minTokenAReceived === 0 || root.minTokenBReceived === 0) + readonly property bool canSubmit: root.hasLpTokens && root.burnAmount > 0 && !root.minReceivedIsZero readonly property string estimateHelp: qsTr("Estimated with the same integer floor math used by the remove-liquidity contract path.") + readonly property string submitButtonText: !root.hasLpTokens ? qsTr("No LP balance") : root.burnAmount === 0 ? qsTr("Enter an amount") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Remove Liquidity") signal slippageToleranceChangeRequested(real tolerancePercent) + signal removeLiquidityRequested(var snapshot) - color: "#1D1D1D" - implicitHeight: content.implicitHeight + 20 - radius: 8 - border.color: "#343434" - border.width: 1 + color: "#00000000" + implicitHeight: content.implicitHeight + radius: 0 + border.width: 0 onMaxBurnAmountChanged: { if (root.burnAmount > root.maxBurnAmount) { @@ -36,40 +38,12 @@ Rectangle { } } - 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 @@ -386,6 +360,7 @@ Rectangle { estimateHelp: root.estimateHelp label: qsTr("Withdraw %1").arg(root.poolState.tokenA) value: root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA) + visible: root.burnAmount > 0 Layout.fillWidth: true } @@ -395,6 +370,7 @@ Rectangle { estimateHelp: root.estimateHelp label: qsTr("Withdraw %1").arg(root.poolState.tokenB) value: root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB) + visible: root.burnAmount > 0 Layout.fillWidth: true } @@ -412,6 +388,7 @@ Rectangle { SummaryRow { label: qsTr("Min %1 received").arg(root.poolState.tokenA) value: root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA) + visible: root.burnAmount > 0 Layout.fillWidth: true } @@ -419,6 +396,7 @@ Rectangle { SummaryRow { label: qsTr("Min %1 received").arg(root.poolState.tokenB) value: root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB) + visible: root.burnAmount > 0 Layout.fillWidth: true } @@ -434,34 +412,80 @@ Rectangle { 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") + label: qsTr("Position after") value: root.poolState.formatPoolShare(root.preview.newUserShare) + visible: root.burnAmount > 0 Layout.fillWidth: true } + + Button { + id: submitButton + + activeFocusOnTab: root.hasLpTokens + enabled: root.canSubmit + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: root.submitButtonText + + Accessible.name: submitButton.text + + Layout.fillWidth: true + Layout.minimumHeight: 44 + Layout.preferredHeight: 44 + + onClicked: root.removeLiquidityRequested(root.submitSnapshot()) + + contentItem: Text { + color: submitButton.enabled ? "#151515" : "#7D756E" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + text: submitButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: submitButton.enabled ? "#F26A21" : "#343434" + border.width: 1 + color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" + radius: 6 + } + } + } + + function setBurnAmount(value) { + root.burnAmount = root.poolState.clampBurnAmount(value); + } + + function setBurnPercent(percent) { + root.setBurnAmount(root.poolState.burnAmountForPercent(percent)); + } + + function resetForm() { + root.setBurnAmount(0); + } + + function submitSnapshot() { + return { + "action": "remove", + "burnAmount": root.preview.burnedLp, + "burnPercent": root.poolState.formatPercent(root.removePercent), + "burnText": root.poolState.formatLpAmount(root.preview.burnedLp), + "minTokenAReceived": root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA), + "minTokenBReceived": root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB), + "postRemovalShare": root.poolState.formatPoolShare(root.preview.newUserShare), + "slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent), + "tokenA": root.poolState.tokenA, + "tokenB": root.poolState.tokenB, + "withdrawA": root.preview.withdrawA, + "withdrawB": root.preview.withdrawB, + "withdrawAText": root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA), + "withdrawBText": root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB) + }; } } diff --git a/amm-ui/qml/components/SlippageToleranceControl.qml b/amm-ui/qml/components/SlippageToleranceControl.qml index 92a7fc8..08b6dd7 100644 --- a/amm-ui/qml/components/SlippageToleranceControl.qml +++ b/amm-ui/qml/components/SlippageToleranceControl.qml @@ -2,7 +2,7 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 -Rectangle { +Item { id: root property real tolerancePercent: 0.5 @@ -12,11 +12,7 @@ Rectangle { signal toleranceChangeRequested(real tolerancePercent) - color: "#151515" - implicitHeight: content.implicitHeight + 20 - radius: 8 - border.color: customField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 + implicitHeight: content.implicitHeight Component.onCompleted: root.restoreCustomText() @@ -60,15 +56,30 @@ Rectangle { id: content anchors.fill: parent - anchors.margins: 10 - spacing: 8 + spacing: 6 - Text { - color: "#A9A098" - font.pixelSize: 12 - text: qsTr("Slippage tolerance") + RowLayout { + spacing: 8 Layout.fillWidth: true + + Text { + color: "#A9A098" + font.pixelSize: 12 + text: qsTr("Slippage tolerance") + + Layout.fillWidth: true + } + + Text { + color: root.tolerancePercent <= 1 ? "#8FD6A4" : root.tolerancePercent <= 5 ? "#F2B366" : "#F08A76" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + text: root.thresholdText + + Layout.maximumWidth: 150 + } } RowLayout { @@ -95,7 +106,7 @@ Rectangle { onClicked: root.commitPreset(presetValue) contentItem: Text { - color: preset01.hovered || preset01.activeFocus || preset01.selected ? "#151515" : "#A9A098" + color: preset01.selected ? "#F2D8C7" : preset01.hovered || preset01.activeFocus ? "#E7E1D8" : "#A9A098" font.bold: true font.pixelSize: 11 horizontalAlignment: Text.AlignHCenter @@ -106,7 +117,7 @@ Rectangle { background: Rectangle { border.color: preset01.activeFocus || preset01.selected ? "#F26A21" : "#343434" border.width: 1 - color: preset01.pressed ? "#D95C1E" : preset01.selected ? "#F26A21" : preset01.hovered || preset01.activeFocus ? "#E7E1D8" : "#101010" + color: preset01.pressed ? "#2A1D16" : preset01.selected ? "#211914" : preset01.hovered || preset01.activeFocus ? "#202020" : "#101010" radius: 6 } } @@ -130,7 +141,7 @@ Rectangle { onClicked: root.commitPreset(presetValue) contentItem: Text { - color: preset05.hovered || preset05.activeFocus || preset05.selected ? "#151515" : "#A9A098" + color: preset05.selected ? "#F2D8C7" : preset05.hovered || preset05.activeFocus ? "#E7E1D8" : "#A9A098" font.bold: true font.pixelSize: 11 horizontalAlignment: Text.AlignHCenter @@ -141,7 +152,7 @@ Rectangle { background: Rectangle { border.color: preset05.activeFocus || preset05.selected ? "#F26A21" : "#343434" border.width: 1 - color: preset05.pressed ? "#D95C1E" : preset05.selected ? "#F26A21" : preset05.hovered || preset05.activeFocus ? "#E7E1D8" : "#101010" + color: preset05.pressed ? "#2A1D16" : preset05.selected ? "#211914" : preset05.hovered || preset05.activeFocus ? "#202020" : "#101010" radius: 6 } } @@ -165,7 +176,7 @@ Rectangle { onClicked: root.commitPreset(presetValue) contentItem: Text { - color: preset10.hovered || preset10.activeFocus || preset10.selected ? "#151515" : "#A9A098" + color: preset10.selected ? "#F2D8C7" : preset10.hovered || preset10.activeFocus ? "#E7E1D8" : "#A9A098" font.bold: true font.pixelSize: 11 horizontalAlignment: Text.AlignHCenter @@ -176,7 +187,7 @@ Rectangle { background: Rectangle { border.color: preset10.activeFocus || preset10.selected ? "#F26A21" : "#343434" border.width: 1 - color: preset10.pressed ? "#D95C1E" : preset10.selected ? "#F26A21" : preset10.hovered || preset10.activeFocus ? "#E7E1D8" : "#101010" + color: preset10.pressed ? "#2A1D16" : preset10.selected ? "#211914" : preset10.hovered || preset10.activeFocus ? "#202020" : "#101010" radius: 6 } } @@ -244,38 +255,5 @@ Rectangle { } } } - - RowLayout { - spacing: 6 - - Layout.fillWidth: true - - Text { - color: root.tolerancePercent <= 1 ? "#8FD6A4" : root.tolerancePercent <= 5 ? "#F2B366" : "#F08A76" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: root.thresholdIcon - - Layout.preferredWidth: 18 - } - - Text { - color: root.tolerancePercent <= 1 ? "#8FD6A4" : root.tolerancePercent <= 5 ? "#F2B366" : "#F08A76" - font.pixelSize: 11 - text: root.thresholdText - - Layout.fillWidth: true - } - } - - Text { - color: "#A9A098" - font.pixelSize: 11 - text: qsTr("Allowed range: 0.01% to 50%.") - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } } } diff --git a/amm-ui/qml/components/SuccessToast.qml b/amm-ui/qml/components/SuccessToast.qml new file mode 100644 index 0000000..0c24253 --- /dev/null +++ b/amm-ui/qml/components/SuccessToast.qml @@ -0,0 +1,103 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 + +Item { + id: root + + property string message: "" + property string detail: "" + property bool open: false + property int duration: 3600 + + height: implicitHeight + implicitHeight: toast.implicitHeight + opacity: root.open ? 1 : 0 + visible: root.open || fadeOut.running + z: 30 + + function show(nextMessage, nextDetail) { + root.message = nextMessage; + root.detail = nextDetail || ""; + root.open = true; + dismissTimer.restart(); + } + + Timer { + id: dismissTimer + + interval: root.duration + repeat: false + + onTriggered: root.open = false + } + + Behavior on opacity { + NumberAnimation { + id: fadeOut + + duration: 160 + easing.type: Easing.OutCubic + } + } + + Rectangle { + id: toast + + anchors.fill: parent + color: "#20201F" + implicitHeight: Math.max(50, toastContent.implicitHeight + 18) + radius: 8 + border.color: "#4D3A2E" + border.width: 1 + + RowLayout { + id: toastContent + + spacing: 8 + + anchors { + fill: parent + leftMargin: 14 + rightMargin: 14 + } + + Rectangle { + color: "#78C88D" + radius: 6 + + Layout.alignment: Qt.AlignTop + Layout.topMargin: 3 + Layout.preferredHeight: 12 + Layout.preferredWidth: 12 + } + + ColumnLayout { + spacing: 2 + + Layout.fillWidth: true + + Text { + id: toastText + + color: "#E7E1D8" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 14 + text: root.message + + Layout.fillWidth: true + } + + Text { + color: "#B8ADA3" + elide: Text.ElideRight + font.pixelSize: 12 + text: root.detail + visible: root.detail.length > 0 + + Layout.fillWidth: true + } + } + } + } +} diff --git a/amm-ui/qml/components/SummaryRow.qml b/amm-ui/qml/components/SummaryRow.qml index 29f8999..0c34046 100644 --- a/amm-ui/qml/components/SummaryRow.qml +++ b/amm-ui/qml/components/SummaryRow.qml @@ -43,7 +43,7 @@ Item { text: root.value verticalAlignment: Text.AlignVCenter - Layout.maximumWidth: 178 + Layout.maximumWidth: Math.max(178, root.width * 0.55) } EstimateInfoButton { diff --git a/amm-ui/qml/pages/LiquidityPage.qml b/amm-ui/qml/pages/LiquidityPage.qml new file mode 100644 index 0000000..c2aa033 --- /dev/null +++ b/amm-ui/qml/pages/LiquidityPage.qml @@ -0,0 +1,188 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import "../components" +import "../state" + +Item { + id: root + + property int activeLiquidityTab: 0 + property real slippageTolerancePercent: 0.5 + readonly property int pageMargin: 16 + readonly property int preferredCardWidth: 492 + readonly property int pageCardY: pageCard.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.round((scroll.height - pageCard.implicitHeight) / 2) : root.pageMargin + + width: parent ? parent.width : implicitWidth + height: parent ? parent.height : implicitHeight + implicitWidth: root.preferredCardWidth + root.pageMargin * 2 + implicitHeight: pageCard.implicitHeight + root.pageMargin * 2 + + DummyPoolState { + id: poolState + } + + Rectangle { + anchors.fill: parent + color: "#151515" + } + + Flickable { + id: scroll + + anchors.fill: parent + clip: true + contentHeight: Math.max(height, pageCard.y + pageCard.implicitHeight + root.pageMargin) + contentWidth: width + enabled: !confirmationDialog.visible + flickableDirection: Flickable.VerticalFlick + + Rectangle { + id: pageCard + + color: "#1B1B1B" + implicitHeight: shellContent.implicitHeight + 24 + radius: 16 + border.color: "#303030" + border.width: 1 + width: Math.max(0, Math.min(scroll.width - root.pageMargin * 2, root.preferredCardWidth)) + x: Math.max(root.pageMargin, (scroll.width - width) / 2) + y: root.pageCardY + + ColumnLayout { + id: shellContent + + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + + RowLayout { + spacing: 10 + + Layout.fillWidth: true + + Text { + color: "#E7E1D8" + font.bold: true + font.pixelSize: 18 + text: qsTr("Liquidity") + + Layout.fillWidth: true + } + + Rectangle { + color: "#211914" + radius: 12 + border.color: "#49301F" + border.width: 1 + + Layout.preferredHeight: 26 + Layout.preferredWidth: pairText.implicitWidth + 20 + + Text { + id: pairText + + anchors.centerIn: parent + color: "#F2D8C7" + font.bold: true + font.pixelSize: 12 + text: qsTr("%1 / %2").arg(poolState.tokenA).arg(poolState.tokenB) + } + } + } + + LiquidityActionTabs { + currentIndex: root.activeLiquidityTab + + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + + onTabRequested: function (index) { + root.activeLiquidityTab = index; + } + } + + PoolPositionSummary { + poolState: poolState + + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + } + + AddLiquidityForm { + id: addLiquidityForm + + poolState: poolState + slippageTolerancePercent: root.slippageTolerancePercent + visible: root.activeLiquidityTab === 0 + + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + + onSlippageToleranceChangeRequested: function (tolerancePercent) { + root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent); + } + + onAddLiquidityRequested: function (snapshot) { + confirmationDialog.openWithSnapshot(snapshot); + } + } + + RemoveLiquidityForm { + id: removeLiquidityForm + + poolState: poolState + slippageTolerancePercent: root.slippageTolerancePercent + visible: root.activeLiquidityTab === 1 + + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + + onSlippageToleranceChangeRequested: function (tolerancePercent) { + root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent); + } + + onRemoveLiquidityRequested: function (snapshot) { + confirmationDialog.openWithSnapshot(snapshot); + } + } + } + + SuccessToast { + id: successToast + + width: Math.max(0, Math.min(380, parent.width - 24)) + + anchors { + bottom: parent.bottom + bottomMargin: 14 + horizontalCenter: parent.horizontalCenter + } + } + } + } + + LiquidityConfirmationDialog { + id: confirmationDialog + + anchors.fill: parent + + onConfirmed: function (snapshot) { + root.confirmLiquidityAction(snapshot); + } + } + + function confirmLiquidityAction(snapshot) { + if (snapshot.action === "add") { + poolState.applyAddLiquidity(snapshot.actualA, snapshot.actualB, snapshot.deltaLp); + addLiquidityForm.resetForm(); + successToast.show(qsTr("Liquidity added"), qsTr("Position updated")); + return; + } + + if (snapshot.action === "remove") { + poolState.applyRemoveLiquidity(snapshot.withdrawA, snapshot.withdrawB, snapshot.burnAmount); + removeLiquidityForm.resetForm(); + successToast.show(qsTr("Liquidity removed"), qsTr("Position updated")); + } + } +} diff --git a/amm-ui/qml/state/DummyPoolState.qml b/amm-ui/qml/state/DummyPoolState.qml index 6874e6f..7e4d573 100644 --- a/amm-ui/qml/state/DummyPoolState.qml +++ b/amm-ui/qml/state/DummyPoolState.qml @@ -159,6 +159,20 @@ QtObject { return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); } + function formatCompactDecimal(value) { + const amount = Number(value) || 0; + + if (Math.abs(amount) >= 1000 || Math.abs(amount - Math.round(amount)) < 0.000001) { + return formatInteger(amount); + } + + if (Math.abs(amount) >= 1) { + return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, ""); + } + + return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); + } + function formatInputAmount(value) { return formatDecimal(value); } @@ -167,6 +181,10 @@ QtObject { return formatDecimal(value) + " " + token; } + function formatCompactTokenAmount(value, token) { + return formatCompactDecimal(value) + " " + token; + } + function formatLpAmount(value) { return formatInteger(value) + " LP"; }