diff --git a/amm-ui/qml/Main.qml b/amm-ui/qml/Main.qml
index 52fa1ba..c1ff8dd 100644
--- a/amm-ui/qml/Main.qml
+++ b/amm-ui/qml/Main.qml
@@ -1,6 +1,9 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
+import "components"
+import "state"
+import "pages"
Item {
id: root
@@ -14,139 +17,160 @@ Item {
{ symbol: "TOK6", name: "Token 6", color: "#9b59b6", letter: "L", address: "0x1337000000000000000000000000000000000cafe", usdPrice: 0.42 }
]
- // ── Theme ─────────────────────────────────────────────────────────────────
- QtObject {
- id: theme
- property bool isDark: false
- property var colors: isDark ? dark : light
-
- readonly property var light: ({
- background: "#f7f7f5",
- cardBg: "#ffffff",
- inputBg: "#f0f0ee",
- panelBg: "#e8e8e4",
- panelHoverBg: "#ddddd8",
- textPrimary: "#111111",
- textSecondary: "#777770",
- textPlaceholder: "#bbbbb5",
- border: Qt.rgba(0,0,0,0.08),
- borderStrong: Qt.rgba(0,0,0,0.10),
- divider: Qt.rgba(0,0,0,0.06),
- ctaBg: "#111111",
- ctaHoverBg: "#2a2a28",
- selection: "#b5c4a5",
- noTokenCircle: "#c8c8c4",
- orb1: "#7a8c6a",
- orb2: "#b5c4a5",
- orb3: "#7a8c6a",
- orb4: "#c8d4b8"
- })
-
- readonly property var dark: ({
- background: "#0d0d12",
- cardBg: "#1a1a22",
- inputBg: "#222230",
- panelBg: "#2a2a38",
- panelHoverBg: "#363650",
- textPrimary: "#ffffff",
- textSecondary: "#888899",
- textPlaceholder: "#444455",
- border: Qt.rgba(1,1,1,0.08),
- borderStrong: Qt.rgba(1,1,1,0.10),
- divider: Qt.rgba(1,1,1,0.06),
- ctaBg: "#2d1530",
- ctaHoverBg: "#3d1f40",
- selection: "#4c1d4b",
- noTokenCircle: "#444455",
- orb1: "#627eea",
- orb2: "#9b59b6",
- orb3: "#fc72ff",
- orb4: "#26a17b"
- })
+ // ── Navigation bar ────────────────────────────────────────────────────────
+ // Pinned to the top; self-contained styling, unaffected by view themes.
+ NavBar {
+ id: navbar
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ z: 100
}
- // ── Root background ───────────────────────────────────────────────────────
- Rectangle {
- anchors.fill: parent
- color: theme.colors.background
- Behavior on color { ColorAnimation { duration: 300 } }
-
- // Theme toggle
- Rectangle {
- anchors.top: parent.top
- anchors.right: parent.right
- anchors.margins: 16
- width: 44; height: 24; radius: 12
- color: theme.colors.panelBg
- border.color: theme.colors.border
- border.width: 1
- Text {
- anchors.centerIn: parent
- text: theme.isDark ? "☀" : "☾"
- font.pixelSize: 13
- color: theme.colors.textSecondary
- }
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: theme.isDark = !theme.isDark
- }
- }
+ // ── Content area (below the nav bar) ──────────────────────────────────────
+ Item {
+ anchors.top: navbar.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
- // Decorative orbs
- Rectangle { x: -180; y: -120; width: 560; height: 560; radius: 280; color: theme.colors.orb1; opacity: 0.07 }
- Rectangle { x: parent.width - 280; y: parent.height - 320; width: 480; height: 480; radius: 240; color: theme.colors.orb2; opacity: 0.09 }
- Rectangle { x: parent.width - 200; y: -80; width: 380; height: 380; radius: 190; color: theme.colors.orb3; opacity: 0.05 }
- Rectangle { x: 40; y: parent.height - 260; width: 320; height: 320; radius: 160; color: theme.colors.orb4; opacity: 0.08 }
-
- ColumnLayout {
- anchors.centerIn: parent
- spacing: 28
-
- Text {
- Layout.alignment: Qt.AlignHCenter
- text: "Logos AMM"
- color: theme.colors.textPrimary
- font.pixelSize: 48
- font.weight: Font.Bold
+ // ── Trade view ────────────────────────────────────────────────────────
+ Item {
+ anchors.fill: parent
+ visible: navbar.currentIndex === 0
+
+ // Trade view theme — scoped here, invisible to NavBar and LP view.
+ QtObject {
+ id: theme
+ property bool isDark: false
+ property var colors: isDark ? dark : light
+
+ readonly property var light: ({
+ background: "#f7f7f5",
+ cardBg: "#ffffff",
+ inputBg: "#f0f0ee",
+ panelBg: "#e8e8e4",
+ panelHoverBg: "#ddddd8",
+ textPrimary: "#111111",
+ textSecondary: "#777770",
+ textPlaceholder: "#bbbbb5",
+ border: Qt.rgba(0,0,0,0.08),
+ borderStrong: Qt.rgba(0,0,0,0.10),
+ divider: Qt.rgba(0,0,0,0.06),
+ ctaBg: "#111111",
+ ctaHoverBg: "#2a2a28",
+ selection: "#b5c4a5",
+ noTokenCircle: "#c8c8c4",
+ orb1: "#7a8c6a",
+ orb2: "#b5c4a5",
+ orb3: "#7a8c6a",
+ orb4: "#c8d4b8"
+ })
+
+ readonly property var dark: ({
+ background: "#0d0d12",
+ cardBg: "#1a1a22",
+ inputBg: "#222230",
+ panelBg: "#2a2a38",
+ panelHoverBg: "#363650",
+ textPrimary: "#ffffff",
+ textSecondary: "#888899",
+ textPlaceholder: "#444455",
+ border: Qt.rgba(1,1,1,0.08),
+ borderStrong: Qt.rgba(1,1,1,0.10),
+ divider: Qt.rgba(1,1,1,0.06),
+ ctaBg: "#2d1530",
+ ctaHoverBg: "#3d1f40",
+ selection: "#4c1d4b",
+ noTokenCircle: "#444455",
+ orb1: "#627eea",
+ orb2: "#9b59b6",
+ orb3: "#fc72ff",
+ orb4: "#26a17b"
+ })
}
- SwapCard {
- id: swapCard
- Layout.alignment: Qt.AlignHCenter
- theme: theme
- tokens: root.tokenData
- width: Math.min(480, root.width - 32)
+ Rectangle {
+ anchors.fill: parent
+ color: theme.colors.background
+ Behavior on color { ColorAnimation { duration: 300 } }
+
+ // Theme toggle
+ Rectangle {
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.margins: 16
+ width: 44; height: 24; radius: 12
+ color: theme.colors.panelBg
+ border.color: theme.colors.border
+ border.width: 1
+ Text {
+ anchors.centerIn: parent
+ text: theme.isDark ? "☀" : "☾"
+ font.pixelSize: 13
+ color: theme.colors.textSecondary
+ }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: theme.isDark = !theme.isDark
+ }
+ }
- onRequestTokenSelect: function(side) {
- tokenModal.targetSide = side
- tokenModal.open()
+ // Decorative orbs
+ Rectangle { x: -180; y: -120; width: 560; height: 560; radius: 280; color: theme.colors.orb1; opacity: 0.07 }
+ Rectangle { x: parent.width - 280; y: parent.height - 320; width: 480; height: 480; radius: 240; color: theme.colors.orb2; opacity: 0.09 }
+ Rectangle { x: parent.width - 200; y: -80; width: 380; height: 380; radius: 190; color: theme.colors.orb3; opacity: 0.05 }
+ Rectangle { x: 40; y: parent.height - 260; width: 320; height: 320; radius: 160; color: theme.colors.orb4; opacity: 0.08 }
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: 28
+
+ SwapCard {
+ id: swapCard
+ Layout.alignment: Qt.AlignHCenter
+ theme: theme
+ tokens: root.tokenData
+ width: Math.min(480, root.width - 32)
+
+ onRequestTokenSelect: function(side) {
+ tokenModal.targetSide = side
+ tokenModal.open()
+ }
+ }
+
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ text: "Buy and sell crypto on LEZ."
+ textFormat: Text.RichText
+ color: theme.colors.textSecondary
+ font.pixelSize: 15
+ horizontalAlignment: Text.AlignHCenter
+ }
}
- }
- Text {
- Layout.alignment: Qt.AlignHCenter
- text: "Buy and sell crypto on LEZ."
- textFormat: Text.RichText
- color: theme.colors.textSecondary
- font.pixelSize: 15
- horizontalAlignment: Text.AlignHCenter
+ TokenSelectorModal {
+ id: tokenModal
+ anchors.fill: parent
+ z: 10
+ theme: theme
+ tokens: root.tokenData
+
+ property string targetSide: "sell"
+
+ onTokenSelected: function(tok) {
+ swapCard.setToken(targetSide, tok)
+ tokenModal.close()
+ }
+ }
}
}
- TokenSelectorModal {
- id: tokenModal
+ // ── Liquidity view ────────────────────────────────────────────────────
+ LiquidityPage {
anchors.fill: parent
- z: 10
- theme: theme
- tokens: root.tokenData
-
- property string targetSide: "sell"
-
- onTokenSelected: function(tok) {
- swapCard.setToken(targetSide, tok)
- tokenModal.close()
- }
+ visible: navbar.currentIndex === 1
}
}
}
diff --git a/amm-ui/qml/NavBar.qml b/amm-ui/qml/NavBar.qml
new file mode 100644
index 0000000..4d8a105
--- /dev/null
+++ b/amm-ui/qml/NavBar.qml
@@ -0,0 +1,86 @@
+import QtQuick 2.15
+import QtQuick.Layouts 1.15
+
+// Self-contained navigation bar — styling is independent of any view's theme.
+// Use currentIndex to read the active tab; tabChanged(index) fires on selection.
+Item {
+ id: root
+
+ property int currentIndex: 0
+ readonly property var tabs: ["Trade", "Liquidity"]
+
+ signal tabChanged(int index)
+
+ implicitHeight: 56
+
+ Rectangle {
+ anchors.fill: parent
+ color: "#ffffff"
+
+ // Bottom separator
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: 1
+ color: Qt.rgba(0, 0, 0, 0.08)
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 20
+ anchors.rightMargin: 20
+ spacing: 4
+
+ // App identity
+ Text {
+ text: "Logos AMM"
+ color: "#111111"
+ font.pixelSize: 17
+ font.weight: Font.Bold
+ }
+
+ Item { Layout.fillWidth: true }
+
+ // Tab pills
+ Row {
+ spacing: 4
+
+ Repeater {
+ model: root.tabs
+
+ delegate: Rectangle {
+ readonly property bool active: root.currentIndex === index
+
+ height: 36
+ width: tabLabel.implicitWidth + 28
+ radius: 18
+ color: active ? "#111111" : "transparent"
+
+ Behavior on color { ColorAnimation { duration: 150 } }
+
+ Text {
+ id: tabLabel
+ anchors.centerIn: parent
+ text: modelData
+ color: active ? "#ffffff" : "#666666"
+ font.pixelSize: 14
+ font.weight: active ? Font.Medium : Font.Normal
+
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ root.currentIndex = index
+ root.tabChanged(index)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/amm-ui/qml/components/AddLiquidityForm.qml b/amm-ui/qml/components/AddLiquidityForm.qml
new file mode 100644
index 0000000..2a1de26
--- /dev/null
+++ b/amm-ui/qml/components/AddLiquidityForm.qml
@@ -0,0 +1,221 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "../state"
+
+Rectangle {
+ id: root
+
+ required property DummyPoolState poolState
+
+ property real slippageTolerancePercent: 0.5
+ property string amountA: ""
+ property string amountB: ""
+ property string lastEditedToken: "A"
+ readonly property real parsedA: root.poolState.parseAmount(root.amountA)
+ readonly property real parsedB: root.poolState.parseAmount(root.amountB)
+ readonly property var preview: root.poolState.addLiquidityPreview(root.parsedA, root.parsedB)
+ readonly property int minLpReceived: root.poolState.minReceivedAmount(root.preview.deltaLp, root.slippageTolerancePercent)
+ readonly property bool hasAnyAmount: root.parsedA > 0 || root.parsedB > 0
+ readonly property bool amountAOverBalance: root.parsedA > root.poolState.walletBalanceA
+ readonly property bool amountBOverBalance: root.parsedB > root.poolState.walletBalanceB
+ 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: "#00000000"
+ implicitHeight: content.implicitHeight
+ radius: 0
+ border.width: 0
+
+ ColumnLayout {
+ id: content
+
+ anchors.fill: parent
+ spacing: 10
+
+ TokenAmountInput {
+ balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceA, root.poolState.tokenA)
+ errorText: root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : ""
+ helperText: root.lastEditedToken === "B" && root.amountA.length > 0 ? qsTr("Calculated from current pool ratio") : ""
+ label: qsTr("Token A amount")
+ token: root.poolState.tokenA
+ text: root.amountA
+
+ Layout.fillWidth: true
+
+ onEditingChanged: function (value) {
+ root.updateFromTokenA(value);
+ }
+ onMaxClicked: root.useMax("A")
+ }
+
+ TokenAmountInput {
+ balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceB, root.poolState.tokenB)
+ errorText: root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : ""
+ helperText: root.lastEditedToken === "A" && root.amountB.length > 0 ? qsTr("Calculated from current pool ratio") : ""
+ label: qsTr("Token B amount")
+ token: root.poolState.tokenB
+ text: root.amountB
+
+ Layout.fillWidth: true
+
+ onEditingChanged: function (value) {
+ root.updateFromTokenB(value);
+ }
+ onMaxClicked: root.useMax("B")
+ }
+
+ SummaryRow {
+ 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
+ }
+
+ SummaryRow {
+ estimated: true
+ 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
+ }
+
+ SlippageToleranceControl {
+ tolerancePercent: root.slippageTolerancePercent
+
+ Layout.fillWidth: true
+
+ onToleranceChangeRequested: function (tolerancePercent) {
+ root.slippageToleranceChangeRequested(tolerancePercent);
+ }
+ }
+
+ SummaryRow {
+ label: qsTr("Min LP received")
+ value: root.poolState.formatLpAmount(root.minLpReceived)
+ visible: root.hasAnyAmount
+
+ Layout.fillWidth: true
+ }
+
+ Text {
+ color: "#F08A76"
+ font.pixelSize: 12
+ lineHeight: 1.25
+ text: qsTr("Minimum received is 0. Increase amount or lower slippage.")
+ visible: root.minReceivedIsZero
+ wrapMode: Text.WordWrap
+
+ Layout.fillWidth: true
+ }
+
+ Text {
+ color: "#F08A76"
+ font.pixelSize: 12
+ lineHeight: 1.25
+ text: root.warningText
+ visible: root.warningText.length > 0
+ wrapMode: Text.WordWrap
+
+ 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/EstimateInfoButton.qml b/amm-ui/qml/components/EstimateInfoButton.qml
new file mode 100644
index 0000000..1d3a109
--- /dev/null
+++ b/amm-ui/qml/components/EstimateInfoButton.qml
@@ -0,0 +1,64 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+
+Button {
+ id: root
+
+ property string helpText: qsTr("This value is derived from your LP token balance, total LP supply, and current pool reserves.")
+
+ activeFocusOnTab: true
+ focusPolicy: Qt.StrongFocus
+ hoverEnabled: true
+ text: qsTr("?")
+
+ Accessible.name: qsTr("Why this value is an estimate")
+
+ onClicked: estimatePopup.opened ? estimatePopup.close() : estimatePopup.open()
+
+ Keys.onEscapePressed: estimatePopup.close()
+
+ contentItem: Text {
+ color: root.activeFocus || root.hovered || estimatePopup.opened ? "#F26A21" : "#E7E1D8"
+ font.bold: true
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignHCenter
+ text: qsTr("i")
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ border.color: root.activeFocus || estimatePopup.opened ? "#F26A21" : "#343434"
+ border.width: 1
+ color: root.pressed ? "#2A221D" : "#1D1D1D"
+ radius: 8
+ }
+
+ Popup {
+ id: estimatePopup
+
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+ focus: true
+ modal: false
+ padding: 10
+ width: 224
+ x: Math.max(-width + root.width, -196)
+ y: root.height + 4
+
+ onClosed: root.forceActiveFocus()
+
+ background: Rectangle {
+ border.color: "#343434"
+ border.width: 1
+ color: "#1D1D1D"
+ radius: 8
+ }
+
+ contentItem: Text {
+ color: "#E7E1D8"
+ font.pixelSize: 12
+ lineHeight: 1.25
+ text: root.helpText
+ wrapMode: Text.WordWrap
+ }
+ }
+}
diff --git a/amm-ui/qml/components/LiquidityActionTabs.qml b/amm-ui/qml/components/LiquidityActionTabs.qml
new file mode 100644
index 0000000..5a7c1d1
--- /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: "#181818"
+ implicitHeight: 42
+ radius: 8
+ border.color: "#303030"
+ 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 ? "#F2D8C7" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#8E8780"
+ elide: Text.ElideRight
+ font.bold: true
+ font.pixelSize: 12
+ horizontalAlignment: Text.AlignHCenter
+ text: addTab.text
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ border.color: addTab.activeFocus || root.currentIndex === 0 ? "#F26A21" : "#181818"
+ border.width: 1
+ color: addTab.pressed ? "#2A1D16" : root.currentIndex === 0 ? "#211914" : addTab.hovered || addTab.activeFocus ? "#202020" : "#121212"
+ 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 ? "#F2D8C7" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#8E8780"
+ elide: Text.ElideRight
+ font.bold: true
+ font.pixelSize: 12
+ horizontalAlignment: Text.AlignHCenter
+ text: removeTab.text
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ border.color: removeTab.activeFocus || root.currentIndex === 1 ? "#F26A21" : "#181818"
+ border.width: 1
+ 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
new file mode 100644
index 0000000..599aeba
--- /dev/null
+++ b/amm-ui/qml/components/PoolPositionSummary.qml
@@ -0,0 +1,98 @@
+import QtQuick 2.15
+import QtQuick.Layouts 1.15
+import "../state"
+
+Rectangle {
+ id: root
+
+ 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: "#151515"
+ implicitHeight: content.implicitHeight + 20
+ radius: 8
+ border.color: "#303030"
+ border.width: 1
+
+ ColumnLayout {
+ id: content
+
+ anchors.fill: parent
+ anchors.margins: 10
+ spacing: 6
+
+ 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
+ }
+ }
+ }
+
+ 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("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("Fee")
+ value: root.poolState.feeTier
+
+ Layout.fillWidth: true
+ }
+ }
+}
diff --git a/amm-ui/qml/components/RemoveLiquidityForm.qml b/amm-ui/qml/components/RemoveLiquidityForm.qml
new file mode 100644
index 0000000..47fbbbf
--- /dev/null
+++ b/amm-ui/qml/components/RemoveLiquidityForm.qml
@@ -0,0 +1,491 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import "../state"
+
+Rectangle {
+ id: root
+
+ required property DummyPoolState poolState
+
+ property real slippageTolerancePercent: 0.5
+ 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 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: "#00000000"
+ implicitHeight: content.implicitHeight
+ radius: 0
+ border.width: 0
+
+ onMaxBurnAmountChanged: {
+ if (root.burnAmount > root.maxBurnAmount) {
+ root.setBurnAmount(root.maxBurnAmount);
+ }
+ }
+
+ ColumnLayout {
+ id: content
+
+ anchors.fill: parent
+ spacing: 10
+
+ 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)
+ visible: root.burnAmount > 0
+
+ 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)
+ visible: root.burnAmount > 0
+
+ Layout.fillWidth: true
+ }
+
+ SlippageToleranceControl {
+ tolerancePercent: root.slippageTolerancePercent
+
+ Layout.fillWidth: true
+
+ onToleranceChangeRequested: function (tolerancePercent) {
+ root.slippageToleranceChangeRequested(tolerancePercent);
+ }
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ Text {
+ color: "#F08A76"
+ font.pixelSize: 12
+ lineHeight: 1.25
+ text: qsTr("Minimum received is 0. Increase amount or lower slippage.")
+ visible: root.minReceivedIsZero
+ wrapMode: Text.WordWrap
+
+ Layout.fillWidth: true
+ }
+
+ SummaryRow {
+ estimated: true
+ estimateHelp: root.estimateHelp
+ 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
new file mode 100644
index 0000000..08b6dd7
--- /dev/null
+++ b/amm-ui/qml/components/SlippageToleranceControl.qml
@@ -0,0 +1,259 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+Item {
+ id: root
+
+ property real tolerancePercent: 0.5
+ property string customText: ""
+ readonly property string thresholdText: root.tolerancePercent <= 1 ? qsTr("Standard slippage") : root.tolerancePercent <= 5 ? qsTr("Higher slippage") : qsTr("High slippage risk")
+ readonly property string thresholdIcon: root.tolerancePercent <= 1 ? "i" : root.tolerancePercent <= 5 ? "!" : "!!"
+
+ signal toleranceChangeRequested(real tolerancePercent)
+
+ implicitHeight: content.implicitHeight
+
+ Component.onCompleted: root.restoreCustomText()
+
+ onTolerancePercentChanged: {
+ if (!customField.activeFocus) {
+ root.restoreCustomText();
+ }
+ }
+
+ function formatTolerance(value) {
+ const amount = Number(value) || 0;
+ return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "");
+ }
+
+ function restoreCustomText() {
+ root.customText = root.formatTolerance(root.tolerancePercent);
+ }
+
+ function clampTolerance(value) {
+ return Math.max(0.01, Math.min(50, Number(value) || 0));
+ }
+
+ function commitPreset(value) {
+ const nextValue = root.clampTolerance(value);
+ root.customText = root.formatTolerance(nextValue);
+ root.toleranceChangeRequested(nextValue);
+ }
+
+ function commitCustom() {
+ const parsed = Number(root.customText);
+
+ if (root.customText.length === 0 || !isFinite(parsed) || parsed < 0) {
+ root.restoreCustomText();
+ return;
+ }
+
+ root.commitPreset(parsed);
+ }
+
+ ColumnLayout {
+ id: content
+
+ anchors.fill: parent
+ spacing: 6
+
+ 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 {
+ spacing: 6
+
+ Layout.fillWidth: true
+
+ Button {
+ id: preset01
+
+ readonly property real presetValue: 0.1
+ readonly property bool selected: Math.abs(root.tolerancePercent - presetValue) < 0.000001
+
+ activeFocusOnTab: true
+ focusPolicy: Qt.StrongFocus
+ hoverEnabled: true
+ text: qsTr("0.1%")
+
+ Accessible.name: qsTr("Set slippage tolerance to 0.1 percent")
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: 44
+
+ onClicked: root.commitPreset(presetValue)
+
+ contentItem: Text {
+ color: preset01.selected ? "#F2D8C7" : preset01.hovered || preset01.activeFocus ? "#E7E1D8" : "#A9A098"
+ font.bold: true
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignHCenter
+ text: preset01.text
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ border.color: preset01.activeFocus || preset01.selected ? "#F26A21" : "#343434"
+ border.width: 1
+ color: preset01.pressed ? "#2A1D16" : preset01.selected ? "#211914" : preset01.hovered || preset01.activeFocus ? "#202020" : "#101010"
+ radius: 6
+ }
+ }
+
+ Button {
+ id: preset05
+
+ readonly property real presetValue: 0.5
+ readonly property bool selected: Math.abs(root.tolerancePercent - presetValue) < 0.000001
+
+ activeFocusOnTab: true
+ focusPolicy: Qt.StrongFocus
+ hoverEnabled: true
+ text: qsTr("0.5%")
+
+ Accessible.name: qsTr("Set slippage tolerance to 0.5 percent")
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: 44
+
+ onClicked: root.commitPreset(presetValue)
+
+ contentItem: Text {
+ color: preset05.selected ? "#F2D8C7" : preset05.hovered || preset05.activeFocus ? "#E7E1D8" : "#A9A098"
+ font.bold: true
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignHCenter
+ text: preset05.text
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ border.color: preset05.activeFocus || preset05.selected ? "#F26A21" : "#343434"
+ border.width: 1
+ color: preset05.pressed ? "#2A1D16" : preset05.selected ? "#211914" : preset05.hovered || preset05.activeFocus ? "#202020" : "#101010"
+ radius: 6
+ }
+ }
+
+ Button {
+ id: preset10
+
+ readonly property real presetValue: 1.0
+ readonly property bool selected: Math.abs(root.tolerancePercent - presetValue) < 0.000001
+
+ activeFocusOnTab: true
+ focusPolicy: Qt.StrongFocus
+ hoverEnabled: true
+ text: qsTr("1.0%")
+
+ Accessible.name: qsTr("Set slippage tolerance to 1.0 percent")
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: 44
+
+ onClicked: root.commitPreset(presetValue)
+
+ contentItem: Text {
+ color: preset10.selected ? "#F2D8C7" : preset10.hovered || preset10.activeFocus ? "#E7E1D8" : "#A9A098"
+ font.bold: true
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignHCenter
+ text: preset10.text
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ border.color: preset10.activeFocus || preset10.selected ? "#F26A21" : "#343434"
+ border.width: 1
+ color: preset10.pressed ? "#2A1D16" : preset10.selected ? "#211914" : preset10.hovered || preset10.activeFocus ? "#202020" : "#101010"
+ radius: 6
+ }
+ }
+
+ Rectangle {
+ color: customField.activeFocus ? "#1F1B18" : "#101010"
+ radius: 6
+ border.color: customField.activeFocus ? "#F26A21" : "#343434"
+ border.width: 1
+
+ Layout.minimumHeight: 44
+ Layout.preferredWidth: 88
+
+ RowLayout {
+ spacing: 4
+
+ anchors {
+ fill: parent
+ leftMargin: 8
+ rightMargin: 8
+ }
+
+ TextField {
+ id: customField
+
+ activeFocusOnTab: true
+ color: "#E7E1D8"
+ font.bold: true
+ font.pixelSize: 12
+ horizontalAlignment: Text.AlignRight
+ inputMethodHints: Qt.ImhFormattedNumbersOnly
+ placeholderText: qsTr("0.5")
+ selectByMouse: true
+ selectedTextColor: "#151515"
+ selectionColor: "#F26A21"
+ text: root.customText
+ validator: RegularExpressionValidator {
+ regularExpression: /[0-9]*([.][0-9]*)?/
+ }
+
+ Accessible.name: qsTr("Custom slippage tolerance percent")
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: 42
+
+ onEditingFinished: root.commitCustom()
+ onTextEdited: root.customText = text
+ Keys.onEscapePressed: {
+ root.restoreCustomText();
+ customField.focus = false;
+ }
+
+ background: Item {}
+ }
+
+ Text {
+ color: "#A9A098"
+ font.bold: true
+ font.pixelSize: 12
+ text: qsTr("%")
+ verticalAlignment: Text.AlignVCenter
+
+ Layout.preferredWidth: 10
+ }
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..0c34046
--- /dev/null
+++ b/amm-ui/qml/components/SummaryRow.qml
@@ -0,0 +1,60 @@
+import QtQuick 2.15
+import QtQuick.Layouts 1.15
+
+Item {
+ id: root
+
+ property string label: ""
+ property string value: ""
+ property bool estimated: false
+ property string estimateHelp: qsTr("This value is derived from your LP token balance, total LP supply, and current pool reserves.")
+
+ implicitHeight: Math.max(18, Math.max(labelText.implicitHeight, valueGroup.implicitHeight))
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 8
+
+ Text {
+ id: labelText
+
+ color: "#A9A098"
+ elide: Text.ElideRight
+ font.pixelSize: 12
+ text: root.label
+ verticalAlignment: Text.AlignVCenter
+
+ Layout.fillWidth: true
+ }
+
+ RowLayout {
+ id: valueGroup
+
+ spacing: 4
+
+ Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
+
+ Text {
+ color: "#E7E1D8"
+ elide: Text.ElideRight
+ font.bold: true
+ font.pixelSize: 12
+ horizontalAlignment: Text.AlignRight
+ text: root.value
+ verticalAlignment: Text.AlignVCenter
+
+ Layout.maximumWidth: Math.max(178, root.width * 0.55)
+ }
+
+ EstimateInfoButton {
+ enabled: root.estimated
+ helpText: root.estimateHelp
+ opacity: root.estimated ? 1 : 0
+ visible: root.estimated
+
+ Layout.preferredHeight: 18
+ Layout.preferredWidth: root.estimated ? 18 : 0
+ }
+ }
+ }
+}
diff --git a/amm-ui/qml/components/TokenAmountInput.qml b/amm-ui/qml/components/TokenAmountInput.qml
new file mode 100644
index 0000000..120be57
--- /dev/null
+++ b/amm-ui/qml/components/TokenAmountInput.qml
@@ -0,0 +1,156 @@
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+Rectangle {
+ id: root
+
+ property alias text: amountField.text
+ property string balance: ""
+ property string errorText: ""
+ property string helperText: ""
+ property string label: ""
+ property string token: ""
+
+ signal editingChanged(string value)
+ signal maxClicked
+
+ color: "#151515"
+ implicitHeight: content.implicitHeight + 20
+ radius: 8
+ border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434"
+ border.width: 1
+
+ Accessible.name: root.label
+ Accessible.role: Accessible.EditableText
+
+ ColumnLayout {
+ id: content
+
+ anchors.fill: parent
+ anchors.margins: 10
+ spacing: 8
+
+ RowLayout {
+ spacing: 8
+
+ Layout.fillWidth: true
+
+ Text {
+ color: "#A9A098"
+ elide: Text.ElideRight
+ font.pixelSize: 12
+ text: root.label
+
+ Layout.fillWidth: true
+ }
+
+ Text {
+ color: "#E7E1D8"
+ elide: Text.ElideRight
+ font.bold: true
+ font.pixelSize: 12
+ horizontalAlignment: Text.AlignRight
+ text: root.token
+
+ Layout.maximumWidth: 76
+ }
+ }
+
+ RowLayout {
+ spacing: 8
+
+ Layout.fillWidth: true
+
+ TextField {
+ id: amountField
+
+ activeFocusOnTab: true
+ color: "#E7E1D8"
+ font.bold: true
+ font.pixelSize: 18
+ inputMethodHints: Qt.ImhFormattedNumbersOnly
+ placeholderText: qsTr("0")
+ selectByMouse: true
+ selectedTextColor: "#151515"
+ selectionColor: "#F26A21"
+ validator: RegularExpressionValidator {
+ regularExpression: /[0-9]*([.][0-9]*)?/
+ }
+
+ Accessible.name: root.label
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: 44
+
+ onTextEdited: root.editingChanged(text)
+
+ background: Rectangle {
+ border.color: amountField.activeFocus ? "#F26A21" : "#343434"
+ border.width: 1
+ color: amountField.activeFocus ? "#1F1B18" : "#101010"
+ radius: 6
+ }
+ }
+
+ Button {
+ id: maxButton
+
+ activeFocusOnTab: true
+ focusPolicy: Qt.StrongFocus
+ hoverEnabled: true
+ text: qsTr("MAX")
+
+ Accessible.name: qsTr("Use maximum %1 balance").arg(root.token)
+
+ Layout.minimumHeight: 44
+ Layout.preferredWidth: 58
+
+ onClicked: root.maxClicked()
+
+ contentItem: Text {
+ color: maxButton.activeFocus || maxButton.hovered ? "#151515" : "#F26A21"
+ font.bold: true
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignHCenter
+ text: maxButton.text
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ border.color: "#F26A21"
+ border.width: 1
+ color: maxButton.pressed ? "#D95C1E" : maxButton.hovered || maxButton.activeFocus ? "#F26A21" : "#201712"
+ radius: 6
+ }
+ }
+ }
+
+ RowLayout {
+ spacing: 8
+
+ Layout.fillWidth: true
+
+ Text {
+ color: root.errorText.length > 0 ? "#F08A76" : root.helperText.length > 0 ? "#F26A21" : "#A9A098"
+ elide: Text.ElideRight
+ font.pixelSize: 11
+ text: root.errorText.length > 0 ? root.errorText : root.helperText
+ visible: text.length > 0
+
+ Layout.fillWidth: true
+ }
+
+ Text {
+ color: "#A9A098"
+ elide: Text.ElideRight
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignRight
+ text: qsTr("Balance %1").arg(root.balance)
+
+ Layout.alignment: Qt.AlignRight
+ Layout.maximumWidth: 150
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..7e4d573
--- /dev/null
+++ b/amm-ui/qml/state/DummyPoolState.qml
@@ -0,0 +1,205 @@
+import QtQuick 2.15
+
+QtObject {
+ id: root
+
+ property string tokenA: "USDC"
+ property string tokenB: "ETH"
+ property string feeTier: "0.30%"
+ property real userLpBalance: 1118033
+ property real reserveA: 1000000
+ property real reserveB: 500
+ 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
+ readonly property real userOwnedB: reserveB * poolShare
+ readonly property real tokenAPerTokenB: reserveB > 0 ? Math.floor(reserveA / reserveB) : 0
+
+ function applyAddLiquidity(actualA, actualB, mintedLp) {
+ const safeA = Math.max(0, Number(actualA) || 0);
+ const safeB = Math.max(0, Number(actualB) || 0);
+ const safeLp = Math.max(0, Number(mintedLp) || 0);
+
+ reserveA += safeA;
+ reserveB += safeB;
+ totalLpSupply += safeLp;
+ userLpBalance += safeLp;
+ }
+
+ function applyRemoveLiquidity(withdrawA, withdrawB, burnedLp) {
+ const safeA = Math.max(0, Number(withdrawA) || 0);
+ const safeB = Math.max(0, Number(withdrawB) || 0);
+ const safeLp = Math.max(0, Number(burnedLp) || 0);
+
+ reserveA = Math.max(0, reserveA - safeA);
+ reserveB = Math.max(0, reserveB - safeB);
+ totalLpSupply = Math.max(0, totalLpSupply - safeLp);
+ userLpBalance = Math.max(0, userLpBalance - safeLp);
+ }
+
+ function resetDummyState() {
+ tokenA = "USDC";
+ tokenB = "ETH";
+ feeTier = "0.30%";
+ userLpBalance = 1118033;
+ reserveA = 1000000;
+ reserveB = 500;
+ totalLpSupply = 22360679;
+ walletBalanceA = 60000;
+ walletBalanceB = 20;
+ }
+
+ function parseAmount(value) {
+ return Math.max(0, Number(value) || 0);
+ }
+
+ function floorAmount(value) {
+ return Math.floor(parseAmount(value));
+ }
+
+ function amountBForA(amountA) {
+ if (reserveA <= 0) {
+ return 0;
+ }
+
+ return reserveB * parseAmount(amountA) / reserveA;
+ }
+
+ function amountAForB(amountB) {
+ if (reserveB <= 0) {
+ return 0;
+ }
+
+ return reserveA * parseAmount(amountB) / reserveB;
+ }
+
+ function addLiquidityPreview(maxA, maxB) {
+ const safeMaxA = parseAmount(maxA);
+ const safeMaxB = parseAmount(maxB);
+ const idealA = reserveB > 0 ? reserveA * safeMaxB / reserveB : 0;
+ const idealB = reserveA > 0 ? reserveB * safeMaxA / reserveA : 0;
+ const actualA = Math.min(idealA, safeMaxA);
+ const actualB = Math.min(idealB, safeMaxB);
+ const lpFromA = reserveA > 0 ? Math.floor(totalLpSupply * actualA / reserveA) : 0;
+ const lpFromB = reserveB > 0 ? Math.floor(totalLpSupply * actualB / reserveB) : 0;
+
+ return {
+ "actualA": actualA,
+ "actualB": actualB,
+ "deltaLp": Math.min(lpFromA, lpFromB),
+ "idealA": idealA,
+ "idealB": idealB
+ };
+ }
+
+ function maxAddLiquidityForBalances() {
+ return addLiquidityPreview(walletBalanceA, walletBalanceB);
+ }
+
+ function clampBurnAmount(value) {
+ return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance)));
+ }
+
+ function clampSlippageTolerancePercent(value) {
+ return Math.max(0.01, Math.min(50, Number(value) || 0));
+ }
+
+ function minReceivedAmount(previewAmount, slippageTolerancePercent) {
+ const safeAmount = floorAmount(previewAmount);
+ const safeSlippage = clampSlippageTolerancePercent(slippageTolerancePercent);
+
+ return Math.floor(safeAmount * (1 - safeSlippage / 100));
+ }
+
+ 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, ",");
+ }
+
+ function formatDecimal(value) {
+ const amount = Number(value) || 0;
+
+ if (Math.abs(amount - Math.round(amount)) < 0.000001) {
+ return formatInteger(amount);
+ }
+
+ 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);
+ }
+
+ function formatTokenAmount(value, token) {
+ return formatDecimal(value) + " " + token;
+ }
+
+ function formatCompactTokenAmount(value, token) {
+ return formatCompactDecimal(value) + " " + token;
+ }
+
+ function formatLpAmount(value) {
+ return formatInteger(value) + " LP";
+ }
+
+ 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(/[.]$/, "") + "%";
+ }
+}