diff --git a/amm-ui/qml/Main.qml b/amm-ui/qml/Main.qml index b1975f8..7db0671 100644 --- a/amm-ui/qml/Main.qml +++ b/amm-ui/qml/Main.qml @@ -9,12 +9,12 @@ Item { id: root property var tokenData: [ - { symbol: "TOK1", name: "Token 1", color: "#627eea", letter: "E", address: "0x0000000000000000000000000000000000000000", usdPrice: 2392.70 }, - { symbol: "TOK2", name: "Token 2", color: "#2775ca", letter: "$", address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", usdPrice: 1.00 }, - { symbol: "TOK3", name: "Token 3", color: "#26a17b", letter: "T", address: "0xdac17f958d2ee523a2206206994597c13d831ec7", usdPrice: 1.00 }, - { symbol: "TOK4", name: "Token 4", color: "#f7931a", letter: "B", address: "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", usdPrice: 63500 }, - { symbol: "TOK5", name: "Token 5", color: "#627eea", letter: "E", address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", usdPrice: 2392.70 }, - { symbol: "TOK6", name: "Token 6", color: "#9b59b6", letter: "L", address: "0x1337000000000000000000000000000000000cafe", usdPrice: 0.42 } + { symbol: "TOK1", name: "Token 1", color: "#627eea", letter: "E", address: "0x0000000000000000000000000000000000000000", usdPrice: 2392.70, balance: 4.25, reserve: 850 }, + { symbol: "TOK2", name: "Token 2", color: "#2775ca", letter: "$", address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", usdPrice: 1.00, balance: 12480, reserve: 2400000 }, + { symbol: "TOK3", name: "Token 3", color: "#26a17b", letter: "T", address: "0xdac17f958d2ee523a2206206994597c13d831ec7", usdPrice: 1.00, balance: 320, reserve: 1800000 }, + { symbol: "TOK4", name: "Token 4", color: "#f7931a", letter: "B", address: "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", usdPrice: 63500, balance: 0.18, reserve: 42 }, + { symbol: "TOK5", name: "Token 5", color: "#627eea", letter: "E", address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", usdPrice: 2392.70, balance: 0, reserve: 600 }, + { symbol: "TOK6", name: "Token 6", color: "#9b59b6", letter: "L", address: "0x1337000000000000000000000000000000000cafe", usdPrice: 0.42, balance: 5400, reserve: 950000 } ] // ── Navigation bar ──────────────────────────────────────────────────────── @@ -124,6 +124,10 @@ Item { tokenModal.targetSide = side tokenModal.open() } + + onSubmitRequested: function(snapshot) { + swapConfirmationDialog.openWithSnapshot(snapshot) + } } Text { @@ -150,6 +154,34 @@ Item { tokenModal.close() } } + + SuccessToast { + id: swapToast + + width: Math.max(0, Math.min(380, parent.width - 32)) + + anchors { + bottom: parent.bottom + bottomMargin: 24 + horizontalCenter: parent.horizontalCenter + } + } + + SwapConfirmationDialog { + id: swapConfirmationDialog + anchors.fill: parent + theme: theme + + onConfirmed: function(snapshot) { + swapCard.resetAmounts() + swapToast.show(qsTr("Swap submitted"), + qsTr("%1 %2 → %3 %4") + .arg(snapshot.sellAmount) + .arg(snapshot.sellToken) + .arg(snapshot.minReceived) + .arg(snapshot.buyToken)) + } + } } } diff --git a/amm-ui/qml/SwapCard.qml b/amm-ui/qml/SwapCard.qml index 9b72e2f..9419246 100644 --- a/amm-ui/qml/SwapCard.qml +++ b/amm-ui/qml/SwapCard.qml @@ -10,38 +10,91 @@ Rectangle { property var sellToken: null property var buyToken: null property string sellAmount: "" + property real slippageTolerancePercent: 0.5 + readonly property real feePercent: 0.30 signal requestTokenSelect(string side) + signal submitRequested(var snapshot) function setToken(side, token) { if (side === "sell") root.sellToken = token else root.buyToken = token } + function resetAmounts() { + root.sellAmount = "" + } + + readonly property real parsedSellAmount: { + var amt = parseFloat(sellAmount) + 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 minReceivedAmount: parsedBuyAmount * (1 - slippageTolerancePercent / 100) + + 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 bool hasAmount: parsedSellAmount > 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 string submitButtonText: { + if (!hasAmount || !tokensSelected) return qsTr("Enter an amount") + if (insufficientBalance) return qsTr("Insufficient balance") + if (insufficientLiquidity) return qsTr("Insufficient liquidity") + return qsTr("Swap") + } + + function formatAmountValue(val) { + if (val >= 1) return val.toFixed(2) + if (val >= 0.0001) return val.toFixed(6) + return val.toFixed(8) + } + readonly property string buyAmount: { if (!sellToken || !buyToken || sellAmount === "") return "" - var amt = parseFloat(sellAmount) - if (isNaN(amt) || amt <= 0) return "" - var result = amt * sellToken.usdPrice / buyToken.usdPrice - return result >= 1 ? result.toFixed(2) : result.toFixed(6) + if (parsedSellAmount <= 0) return "" + return formatAmountValue(parsedBuyAmount) } readonly property string sellUsd: { if (!sellToken || sellAmount === "") return "" - var amt = parseFloat(sellAmount) - if (isNaN(amt)) return "" - var val = amt * sellToken.usdPrice + if (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 "" - var amt = parseFloat(buyAmount) - if (isNaN(amt)) return "" - var val = amt * buyToken.usdPrice + var val = parsedBuyAmount * buyToken.usdPrice return "~$" + val.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",") } + function buildSnapshot() { + return { + "sellToken": sellToken ? sellToken.symbol : "", + "buyToken": buyToken ? buyToken.symbol : "", + "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(/[.]$/, "") + "%" + } + } + radius: 24 color: theme.colors.cardBg border.color: theme.colors.border @@ -124,6 +177,7 @@ Rectangle { } Rectangle { + id: ctaBox Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 8 @@ -131,13 +185,15 @@ Rectangle { Layout.rightMargin: 8 Layout.preferredHeight: 56 radius: 20 - color: ctaHover.containsMouse ? theme.colors.ctaHoverBg : theme.colors.ctaBg + color: !root.canSubmit ? theme.colors.panelBg + : ctaHover.containsMouse ? theme.colors.ctaHoverBg + : theme.colors.ctaBg Behavior on color { ColorAnimation { duration: 120 } } Text { anchors.centerIn: parent - text: "Swap" - color: "#ffffff" + text: root.submitButtonText + color: root.canSubmit ? "#ffffff" : theme.colors.textSecondary font.pixelSize: 17 font.weight: Font.Medium } @@ -146,7 +202,11 @@ Rectangle { id: ctaHover anchors.fill: parent hoverEnabled: true - cursorShape: Qt.PointingHandCursor + enabled: root.canSubmit + cursorShape: root.canSubmit ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (root.canSubmit) root.submitRequested(root.buildSnapshot()) + } } } } diff --git a/amm-ui/qml/components/SwapConfirmationDialog.qml b/amm-ui/qml/components/SwapConfirmationDialog.qml new file mode 100644 index 0000000..c43204c --- /dev/null +++ b/amm-ui/qml/components/SwapConfirmationDialog.qml @@ -0,0 +1,272 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +FocusScope { + id: root + + property var theme + property var snapshot: ({}) + property bool open: false + + 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: root.theme.colors.cardBg + implicitHeight: dialogContent.implicitHeight + 32 + radius: 16 + width: Math.max(0, Math.min(380, root.width - 32)) + border.color: root.theme.colors.border + border.width: 1 + + MouseArea { + anchors.fill: parent + } + + ColumnLayout { + id: dialogContent + + anchors.fill: parent + anchors.margins: 16 + spacing: 14 + + Text { + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 17 + text: qsTr("Confirm swap") + Layout.fillWidth: true + } + + ColumnLayout { + spacing: 10 + Layout.fillWidth: true + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 12 + implicitHeight: payColumn.implicitHeight + 24 + + ColumnLayout { + id: payColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + text: qsTr("You pay") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + } + Text { + text: qsTr("%1 %2") + .arg(root.snapshot.sellAmount || "") + .arg(root.snapshot.sellToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 12 + implicitHeight: receiveColumn.implicitHeight + 24 + + ColumnLayout { + id: receiveColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + text: qsTr("You receive at least") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + } + Text { + text: qsTr("%1 %2") + .arg(root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + } + + ColumnLayout { + spacing: 8 + 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 + } + } + } + + RowLayout { + spacing: 10 + Layout.fillWidth: true + Layout.topMargin: 4 + + Button { + id: cancelButton + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("Cancel") + Layout.fillWidth: true + Layout.minimumHeight: 48 + onClicked: root.cancel() + + contentItem: Text { + color: root.theme.colors.textPrimary + elide: Text.ElideRight + font.bold: true + font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + text: cancelButton.text + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + border.color: root.theme.colors.borderStrong + border.width: 1 + color: cancelButton.pressed + ? root.theme.colors.panelHoverBg + : cancelButton.hovered || cancelButton.activeFocus + ? root.theme.colors.panelBg + : "transparent" + radius: 14 + } + } + + Button { + id: confirmButton + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + text: qsTr("Confirm Swap") + Layout.fillWidth: true + Layout.minimumHeight: 48 + onClicked: root.confirm() + + contentItem: Text { + color: "#ffffff" + elide: Text.ElideRight + font.bold: true + font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + text: confirmButton.text + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + border.width: 0 + color: confirmButton.pressed + ? "#D95C1E" + : confirmButton.hovered || confirmButton.activeFocus + ? root.theme.colors.ctaHoverBg + : root.theme.colors.ctaBg + radius: 14 + } + } + } + } + } +}