diff --git a/amm-ui/qml/SwapCard.qml b/amm-ui/qml/SwapCard.qml index 9419246..2b62c48 100644 --- a/amm-ui/qml/SwapCard.qml +++ b/amm-ui/qml/SwapCard.qml @@ -1,6 +1,8 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import "components" +import "state" Rectangle { id: root @@ -9,9 +11,15 @@ Rectangle { property var tokens: [] property var sellToken: null property var buyToken: null - property string sellAmount: "" + property string sellInput: "" + property string buyInput: "" + property string editingSide: "sell" property real slippageTolerancePercent: 0.5 - readonly property real feePercent: 0.30 + + DummySwapState { + id: swapState + feeBps: 30 + } signal requestTokenSelect(string side) signal submitRequested(var snapshot) @@ -22,33 +30,44 @@ Rectangle { } function resetAmounts() { - root.sellAmount = "" + root.sellInput = "" + root.buyInput = "" + root.editingSide = "sell" } - readonly property real parsedSellAmount: { - var amt = parseFloat(sellAmount) + readonly property real sellReserve: sellToken ? (sellToken.reserve || 0) : 0 + readonly property real buyReserve: buyToken ? (buyToken.reserve || 0) : 0 + + readonly property real parsedSellInput: { + var amt = parseFloat(sellInput) return isNaN(amt) || amt < 0 ? 0 : amt } - readonly property real parsedBuyAmount: { - if (!sellToken || !buyToken || parsedSellAmount <= 0) return 0 - return parsedSellAmount * sellToken.usdPrice / buyToken.usdPrice + readonly property real parsedBuyInput: { + var amt = parseFloat(buyInput) + return isNaN(amt) || amt < 0 ? 0 : amt } - readonly property real minReceivedAmount: parsedBuyAmount * (1 - slippageTolerancePercent / 100) + readonly property real parsedSellAmount: editingSide === "sell" + ? parsedSellInput + : swapState.amountInFor(parsedBuyInput, sellReserve, buyReserve) - readonly property real priceImpactPercent: { - if (!sellToken || parsedSellAmount <= 0) return 0 - var reserve = sellToken.reserve || 0 - if (reserve <= 0) return 0 - return parsedSellAmount / (reserve + parsedSellAmount) * 100 - } + readonly property real parsedBuyAmount: editingSide === "buy" + ? parsedBuyInput + : swapState.amountOutFor(parsedSellInput, sellReserve, buyReserve) - readonly property bool hasAmount: parsedSellAmount > 0 + readonly property real feeAmount: swapState.feeAmount(parsedSellAmount) + readonly property real minReceivedAmount: swapState.minReceived(parsedBuyAmount, slippageTolerancePercent) + readonly property real priceImpactPercent: swapState.priceImpactPercent(parsedSellAmount, parsedBuyAmount, sellReserve, buyReserve) + + readonly property string swapMode: editingSide === "buy" ? "swap-exact-output" : "swap-exact-input" + readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output") : qsTr("Exact input") + + readonly property bool hasAmount: editingSide === "sell" ? parsedSellInput > 0 : parsedBuyInput > 0 readonly property bool tokensSelected: sellToken !== null && buyToken !== null readonly property bool insufficientBalance: hasAmount && sellToken !== null && parsedSellAmount > (sellToken.balance || 0) readonly property bool insufficientLiquidity: hasAmount && buyToken !== null && parsedBuyAmount > (buyToken.reserve || 0) - readonly property bool canSubmit: tokensSelected && hasAmount && !insufficientBalance && !insufficientLiquidity + readonly property bool canSubmit: tokensSelected && hasAmount && parsedSellAmount > 0 && parsedBuyAmount > 0 && !insufficientBalance && !insufficientLiquidity readonly property string submitButtonText: { if (!hasAmount || !tokensSelected) return qsTr("Enter an amount") @@ -63,21 +82,22 @@ Rectangle { return val.toFixed(8) } - readonly property string buyAmount: { - if (!sellToken || !buyToken || sellAmount === "") return "" - if (parsedSellAmount <= 0) return "" - return formatAmountValue(parsedBuyAmount) - } + readonly property string sellDisplay: editingSide === "sell" + ? sellInput + : (parsedSellAmount > 0 ? formatAmountValue(parsedSellAmount) : "") + + readonly property string buyDisplay: editingSide === "buy" + ? buyInput + : (parsedBuyAmount > 0 ? formatAmountValue(parsedBuyAmount) : "") readonly property string sellUsd: { - if (!sellToken || sellAmount === "") return "" - if (parsedSellAmount <= 0) return "" + if (!sellToken || parsedSellAmount <= 0) return "" var val = parsedSellAmount * sellToken.usdPrice return "~$" + val.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",") } readonly property string buyUsd: { - if (!buyToken || buyAmount === "") return "" + if (!buyToken || parsedBuyAmount <= 0) return "" var val = parsedBuyAmount * buyToken.usdPrice return "~$" + val.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",") } @@ -89,9 +109,12 @@ Rectangle { "sellAmount": formatAmountValue(parsedSellAmount), "buyAmount": formatAmountValue(parsedBuyAmount), "minReceived": formatAmountValue(minReceivedAmount), - "feePercent": feePercent.toFixed(2) + "%", - "priceImpactPercent": priceImpactPercent < 0.01 ? "<0.01%" : priceImpactPercent.toFixed(2) + "%", - "slippageTolerance": slippageTolerancePercent.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "") + "%" + "feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""), + "priceImpactPercent": swapState.formatPercent(priceImpactPercent), + "priceImpactPercentValue": priceImpactPercent, + "slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent), + "swapMode": swapMode, + "swapModeText": swapModeText } } @@ -116,11 +139,14 @@ Rectangle { Layout.fillWidth: true theme: root.theme label: "Sell" - amount: root.sellAmount + amount: root.sellDisplay usdValue: root.sellUsd token: root.sellToken - readOnly: false - onInputEdited: function(v) { root.sellAmount = v } + active: root.editingSide === "sell" + onInputEdited: function(v) { + root.sellInput = v + if (root.editingSide !== "sell") root.editingSide = "sell" + } onTokenClicked: root.requestTokenSelect("sell") } @@ -169,13 +195,44 @@ Rectangle { Layout.fillWidth: true theme: root.theme label: "Buy" - amount: root.buyAmount + amount: root.buyDisplay usdValue: root.buyUsd token: root.buyToken - readOnly: true + active: root.editingSide === "buy" + onInputEdited: function(v) { + root.buyInput = v + if (root.editingSide !== "buy") root.editingSide = "buy" + } onTokenClicked: root.requestTokenSelect("buy") } + SwapSummary { + Layout.fillWidth: true + Layout.topMargin: 12 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + theme: root.theme + visible: root.tokensSelected && root.hasAmount + swapModeText: root.swapModeText + feeText: swapState.formatTokenAmount(root.feeAmount, root.sellToken ? root.sellToken.symbol : "") + priceImpactText: swapState.formatPercent(root.priceImpactPercent) + priceImpactPercent: root.priceImpactPercent + minReceivedText: swapState.formatTokenAmount(root.minReceivedAmount, root.buyToken ? root.buyToken.symbol : "") + } + + SlippageToleranceControl { + Layout.fillWidth: true + Layout.topMargin: 12 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + tolerancePercent: root.slippageTolerancePercent + visible: root.tokensSelected && root.hasAmount + + onToleranceChangeRequested: function(tolerancePercent) { + root.slippageTolerancePercent = swapState.clampSlippagePercent(tolerancePercent); + } + } + Rectangle { id: ctaBox Layout.fillWidth: true diff --git a/amm-ui/qml/TokenInput.qml b/amm-ui/qml/TokenInput.qml index 86193b7..07b2362 100644 --- a/amm-ui/qml/TokenInput.qml +++ b/amm-ui/qml/TokenInput.qml @@ -9,7 +9,7 @@ Rectangle { property string amount: "" property string usdValue: "" property var token: null - property bool readOnly: false + property bool active: true signal tokenClicked() signal inputEdited(string newValue) @@ -18,11 +18,10 @@ Rectangle { target: tiInput property: "text" value: root.amount - when: root.readOnly } radius: 16 - color: theme.colors.inputBg + color: root.active ? theme.colors.inputBg : theme.colors.panelBg implicitHeight: 110 Behavior on color { ColorAnimation { duration: 300 } } @@ -52,13 +51,12 @@ Rectangle { TextInput { id: tiInput anchors.fill: parent - color: theme.colors.textPrimary + color: root.active ? theme.colors.textPrimary : theme.colors.textSecondary font.pixelSize: 36 font.weight: Font.Bold - readOnly: root.readOnly selectionColor: theme.colors.selection clip: true - onTextChanged: { if (!root.readOnly) root.inputEdited(text) } + onTextEdited: root.inputEdited(text) validator: RegularExpressionValidator { regularExpression: /^[0-9]*\.?[0-9]*$/ } diff --git a/amm-ui/qml/components/SwapConfirmationDialog.qml b/amm-ui/qml/components/SwapConfirmationDialog.qml index c43204c..54f61b4 100644 --- a/amm-ui/qml/components/SwapConfirmationDialog.qml +++ b/amm-ui/qml/components/SwapConfirmationDialog.qml @@ -141,64 +141,17 @@ FocusScope { } } - ColumnLayout { - spacing: 8 + SwapSummary { Layout.fillWidth: true - - Item { - Layout.fillWidth: true - implicitHeight: 18 - Text { - anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter - text: qsTr("Fee"); color: root.theme.colors.textSecondary; font.pixelSize: 12 - } - Text { - anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter - text: root.snapshot.feePercent || "" - color: root.theme.colors.textPrimary; font.pixelSize: 12; font.bold: true - } - } - Item { - Layout.fillWidth: true - implicitHeight: 18 - Text { - anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter - text: qsTr("Price impact"); color: root.theme.colors.textSecondary; font.pixelSize: 12 - } - Text { - anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter - text: root.snapshot.priceImpactPercent || "" - color: root.theme.colors.textPrimary; font.pixelSize: 12; font.bold: true - } - } - Item { - Layout.fillWidth: true - implicitHeight: 18 - Text { - anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter - text: qsTr("Slippage tolerance"); color: root.theme.colors.textSecondary; font.pixelSize: 12 - } - Text { - anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter - text: root.snapshot.slippageTolerance || "" - color: root.theme.colors.textPrimary; font.pixelSize: 12; font.bold: true - } - } - Item { - Layout.fillWidth: true - implicitHeight: 18 - Text { - anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter - text: qsTr("Min received"); color: root.theme.colors.textSecondary; font.pixelSize: 12 - } - Text { - anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter - text: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - color: root.theme.colors.textPrimary; font.pixelSize: 12; font.bold: true - } - } + theme: root.theme + swapModeText: root.snapshot.swapModeText || "" + feeText: root.snapshot.feeAmount || "" + priceImpactText: root.snapshot.priceImpactPercent || "" + priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 + slippageText: root.snapshot.slippageTolerance || "" + minReceivedText: qsTr("%1 %2") + .arg(root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") } RowLayout { diff --git a/amm-ui/qml/components/SwapSummary.qml b/amm-ui/qml/components/SwapSummary.qml new file mode 100644 index 0000000..6ca2285 --- /dev/null +++ b/amm-ui/qml/components/SwapSummary.qml @@ -0,0 +1,146 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 + +Item { + id: root + + property var theme + property string swapModeText: "" + property string feeText: "" + property string priceImpactText: "" + property real priceImpactPercent: 0 + property string slippageText: "" + property string minReceivedText: "" + + readonly property color priceImpactColor: { + if (root.priceImpactPercent > 5) return "#F08A76"; + if (root.priceImpactPercent > 1) return "#F2B366"; + return root.theme.colors.textPrimary; + } + + implicitHeight: column.implicitHeight + + ColumnLayout { + id: column + + anchors.fill: parent + spacing: 8 + + Item { + implicitHeight: 18 + visible: root.swapModeText.length > 0 + + Layout.fillWidth: true + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textSecondary + font.pixelSize: 12 + text: qsTr("Type of swap") + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 12 + text: root.swapModeText + } + } + + Item { + implicitHeight: 18 + + Layout.fillWidth: true + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textSecondary + font.pixelSize: 12 + text: qsTr("Fee") + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 12 + text: root.feeText + } + } + + Item { + implicitHeight: 18 + + Layout.fillWidth: true + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textSecondary + font.pixelSize: 12 + text: qsTr("Price impact") + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: root.priceImpactColor + font.bold: true + font.pixelSize: 12 + text: root.priceImpactText + } + } + + Item { + implicitHeight: 18 + visible: root.slippageText.length > 0 + + Layout.fillWidth: true + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textSecondary + font.pixelSize: 12 + text: qsTr("Slippage tolerance") + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 12 + text: root.slippageText + } + } + + Item { + implicitHeight: 18 + + Layout.fillWidth: true + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textSecondary + font.pixelSize: 12 + text: qsTr("Min received") + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 12 + text: root.minReceivedText + } + } + } +} diff --git a/amm-ui/qml/state/DummySwapState.qml b/amm-ui/qml/state/DummySwapState.qml new file mode 100644 index 0000000..3194620 --- /dev/null +++ b/amm-ui/qml/state/DummySwapState.qml @@ -0,0 +1,104 @@ +import QtQuick 2.15 + +QtObject { + id: root + + property int feeBps: 30 + + function parseAmount(value) { + return Math.max(0, Number(value) || 0); + } + + function clampSlippagePercent(value) { + return Math.max(0, Math.min(50, Number(value) || 0)); + } + + function feeAmount(amountIn) { + return parseAmount(amountIn) * root.feeBps / 10000; + } + + function amountOutFor(amountIn, reserveIn, reserveOut) { + const safeAmountIn = parseAmount(amountIn); + const safeReserveIn = parseAmount(reserveIn); + const safeReserveOut = parseAmount(reserveOut); + + if (safeAmountIn <= 0 || safeReserveIn <= 0 || safeReserveOut <= 0) { + return 0; + } + + const amountInAfterFee = safeAmountIn * (10000 - root.feeBps) / 10000; + return safeReserveOut * amountInAfterFee / (safeReserveIn + amountInAfterFee); + } + + function amountInFor(amountOut, reserveIn, reserveOut) { + const safeAmountOut = parseAmount(amountOut); + const safeReserveIn = parseAmount(reserveIn); + const safeReserveOut = parseAmount(reserveOut); + + if (safeAmountOut <= 0 || safeReserveIn <= 0 || safeReserveOut <= 0) { + return 0; + } + if (safeAmountOut >= safeReserveOut) { + return 0; + } + + const amountInAfterFee = safeAmountOut * safeReserveIn / (safeReserveOut - safeAmountOut); + return amountInAfterFee * 10000 / (10000 - root.feeBps); + } + + function priceImpactPercent(amountIn, amountOut, reserveIn, reserveOut) { + const safeAmountIn = parseAmount(amountIn); + const safeAmountOut = parseAmount(amountOut); + const safeReserveIn = parseAmount(reserveIn); + const safeReserveOut = parseAmount(reserveOut); + + if (safeAmountIn <= 0 || safeAmountOut <= 0) { + return 0; + } + if (safeReserveIn <= 0 || safeReserveOut <= 0) { + return 0; + } + if (safeReserveOut - safeAmountOut <= 0) { + return 0; + } + + const priceBefore = safeReserveIn / safeReserveOut; + const priceAfter = (safeReserveIn + safeAmountIn) / (safeReserveOut - safeAmountOut); + return (priceAfter - priceBefore) / priceBefore * 100; + } + + function minReceived(amountOut, slippagePercent) { + const safeAmount = parseAmount(amountOut); + const safeSlippage = clampSlippagePercent(slippagePercent); + return safeAmount * (1 - safeSlippage / 100); + } + + function maxSent(amountIn, slippagePercent) { + const safeAmount = parseAmount(amountIn); + const safeSlippage = clampSlippagePercent(slippagePercent); + return safeAmount * (1 + safeSlippage / 100); + } + + function formatAmountValue(value) { + const amount = Math.max(0, Number(value) || 0); + if (amount >= 1) return amount.toFixed(2); + if (amount >= 0.0001) return amount.toFixed(6); + return amount.toFixed(8); + } + + function formatTokenAmount(value, symbol) { + const formatted = formatAmountValue(value); + return symbol ? formatted + " " + symbol : formatted; + } + + function formatPercent(value) { + const amount = Number(value) || 0; + if (amount > 0 && amount < 0.01) return "<0.01%"; + return amount.toFixed(2) + "%"; + } + + function formatSlippagePercent(value) { + const amount = clampSlippagePercent(value); + return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "") + "%"; + } +}