chore(amm-ui): add swap confirmation modal

closes #58
This commit is contained in:
Andrea Franz 2026-05-04 15:04:21 +02:00
parent 37fc2ea088
commit 5a61cf39f2
3 changed files with 384 additions and 20 deletions

View File

@ -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))
}
}
}
}

View File

@ -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())
}
}
}
}

View File

@ -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
}
}
}
}
}
}