From 99ea6805dfb6868fffd51733c47966225ba6d22b Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:46:48 +0200 Subject: [PATCH] feat(apps/amm): introduce positions view This view allows the user to manage their positions. --- apps/amm/README.md | 13 +- apps/amm/qml/Main.qml | 58 +- apps/amm/qml/chrome/NavBar.qml | 160 ++++- .../qml/components/liquidity/AmountMath.js | 17 + .../components/liquidity/NewPositionForm.qml | 17 + .../components/shared/TokenSelectorModal.qml | 4 + apps/amm/qml/pages/LiquidityPage.qml | 8 + apps/amm/qml/pages/PositionsPage.qml | 582 ++++++++++++++++++ apps/amm/qml/pages/SwapPage.qml | 1 + apps/amm/tests/add-liquidity.mjs | 7 +- apps/amm/tests/create-pool.mjs | 6 +- apps/amm/tests/custom-token.mjs | 6 +- 12 files changed, 853 insertions(+), 26 deletions(-) create mode 100644 apps/amm/qml/pages/PositionsPage.qml diff --git a/apps/amm/README.md b/apps/amm/README.md index 39a5974..b77eace 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -243,7 +243,18 @@ list shows its empty state. Entries missing `tokenA`, `tokenB`, or a numeric `feeBps` are skipped individually. The AMM testnet setup script writes this file for the pool(s) it seeds (see below). -Clicking a row opens the pool detail view, which reads the live pool through +The **Pool** tab in the nav bar is a dropdown with two entries. *Create pool* +opens the new-position / add-liquidity form. *View positions* lists the wallet's +liquidity positions: the program has no "list my positions" read and an LP +definition id cannot be reversed back to its pool, so the app resolves every +pool in this config and matches each pool's `lpDefinitionId` against the +wallet's token holdings. A pool that is not in the config therefore cannot +appear, however many LP tokens the wallet holds for it. Each row shows the pair, +fee tier, the wallet's claim on both reserves (`reserve × lpBalance / lpSupply`, +floored like the program's own payout), and its share of the pool. The list +needs an open wallet. + +Clicking a row in the Pools list opens the pool detail view, which reads the live pool through `resolvePoolAccount` and shows the reserve split, spot price, fee tier, LP supply, an estimate of the fees accrued into the reserves, and the pool's account ids. Its **Swap** and **Add liquidity** buttons switch tabs with the diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index 56d9413..f7d9413 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -20,17 +20,24 @@ Item { // StackView here — the Pools tab renders either the list or the detail view. property var selectedPool: null + // Open a pool's detail view on the Explore tab. select() clears selectedPool + // via onTabChanged, so the pool is assigned after it, not before. + function openPoolDetail(pool) { + navbar.select(1, 0) + root.selectedPool = pool + } + // Hand a pool's pair to the Trade tab and leave the detail view. function openSwapFor(pool) { root.selectedPool = null - navbar.currentIndex = 0 + navbar.select(0, 0) swapPage.selectPair(pool) } - // Hand a pool's pair to the Liquidity tab and leave the detail view. + // Hand a pool's pair to the Pool tab's create/add form and leave the detail view. function openLiquidityFor(pool) { root.selectedPool = null - navbar.currentIndex = 1 + navbar.select(2, 1) liquidityPage.selectPair(pool) } @@ -84,6 +91,7 @@ Item { // control. Trade/Liquidity render immediately on launch. NavBar { id: navbar + objectName: "navBar" anchors.top: connectionBanner.bottom anchors.left: parent.left anchors.right: parent.right @@ -91,6 +99,16 @@ Item { backend: root.ready ? root.backend : null accountModel: root.accountModel + + // Any nav selection leaves the pool detail view behind, so returning to + // Explore lands on the list rather than on the pool opened last time. + // Entering the create-pool form clears it for the same reason; a pair + // handed over by openLiquidityFor() is applied after this runs. + onTabChanged: { + root.selectedPool = null + if (navbar.currentIndex === 2 && navbar.currentSubIndex === 1) + liquidityPage.resetForm() + } } Item { @@ -107,20 +125,12 @@ Item { backend: root.ready ? root.backend : null } - LiquidityPage { - id: liquidityPage - - anchors.fill: parent - backend: root.ready ? root.backend : null - runtime: logos - visible: navbar.currentIndex === 1 - } - + // Explore tab: the pool list, or the detail view for the pool opened from it. PoolsPage { anchors.fill: parent backend: root.ready ? root.backend : null runtime: logos - visible: navbar.currentIndex === 2 && root.selectedPool === null + visible: navbar.currentIndex === 1 && root.selectedPool === null onPoolActivated: function(pool) { root.selectedPool = pool } } @@ -129,12 +139,32 @@ Item { anchors.fill: parent backend: root.ready ? root.backend : null runtime: logos - visible: navbar.currentIndex === 2 && root.selectedPool !== null + visible: navbar.currentIndex === 1 && root.selectedPool !== null pool: root.selectedPool onBackRequested: root.selectedPool = null onSwapRequested: function(pool) { root.openSwapFor(pool) } onAddLiquidityRequested: function(pool) { root.openLiquidityFor(pool) } } + + // Pool tab, "View positions": the wallet's LP holdings matched to pools. + PositionsPage { + anchors.fill: parent + backend: root.ready ? root.backend : null + runtime: logos + visible: navbar.currentIndex === 2 && navbar.currentSubIndex === 0 + + onPositionActivated: function(position) { root.openPoolDetail(position) } + } + + // Pool tab, "Create pool": the new-position / add-liquidity form. + LiquidityPage { + id: liquidityPage + + anchors.fill: parent + backend: root.ready ? root.backend : null + runtime: logos + visible: navbar.currentIndex === 2 && navbar.currentSubIndex === 1 + } } } diff --git a/apps/amm/qml/chrome/NavBar.qml b/apps/amm/qml/chrome/NavBar.qml index d48d9a1..0a3925d 100644 --- a/apps/amm/qml/chrome/NavBar.qml +++ b/apps/amm/qml/chrome/NavBar.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick 2.15 +import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import Logos.Theme @@ -8,11 +9,19 @@ import Logos.Wallet // Self-contained navigation bar — styling is independent of any view's theme. // Use currentIndex to read the active tab; tabChanged(index) fires on selection. +// A tab carrying `items` opens a dropdown instead of switching directly, and the +// chosen entry lands in currentSubIndex (always 0 for tabs without a menu). Item { id: root property int currentIndex: 0 - readonly property var tabs: [qsTr("Trade"), qsTr("Liquidity"), qsTr("Pools")] + property int currentSubIndex: 0 + + readonly property var tabs: [ + { "label": qsTr("Trade"), "items": [] }, + { "label": qsTr("Explore"), "items": [] }, + { "label": qsTr("Pool"), "items": [qsTr("View positions"), qsTr("Create pool")] } + ] // Wallet wiring, passed down from Main.qml. property var backend: null @@ -23,6 +32,12 @@ Item { signal tabChanged(int index) + function select(index, subIndex) { + root.currentIndex = index + root.currentSubIndex = subIndex + root.tabChanged(index) + } + implicitHeight: 56 Rectangle { @@ -68,9 +83,16 @@ Item { id: tabButton required property int index - required property string modelData + required property var modelData readonly property bool active: root.currentIndex === index + readonly property var items: modelData.items || [] + readonly property bool hasMenu: tabButton.items.length > 0 + + // Tracks where the pointer is so the menu can stay open + // while it travels from the tab down into the menu. + property bool pointerOnTab: false + property bool pointerInMenu: false height: 36 width: tabLabel.implicitWidth + 28 @@ -82,9 +104,125 @@ Item { Accessible.role: Accessible.PageTab Accessible.name: tabLabel.text + // A tab with a menu defers the switch to the chosen entry; + // one without goes straight to its page. function activate() { - root.currentIndex = index - root.tabChanged(index) + if (tabButton.hasMenu) { + tabButton.openMenu() + return + } + root.select(tabButton.index, 0) + } + + function openMenu() { + if (!tabButton.hasMenu) + return + menuCloseTimer.stop() + tabMenu.open() + } + + // Closing is deferred: crossing the gap between the tab and + // the menu leaves the pointer over neither for a moment, and + // closing on that would make the menu unreachable by mouse. + function scheduleMenuClose() { + if (tabButton.hasMenu) + menuCloseTimer.restart() + } + + Timer { + id: menuCloseTimer + + interval: 180 + onTriggered: { + if (!tabButton.pointerOnTab && !tabButton.pointerInMenu) + tabMenu.close() + } + } + + Popup { + id: tabMenu + + objectName: "navTabMenu%1".arg(tabButton.index) + y: tabButton.height + 6 + width: 190 + padding: 6 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + radius: 12 + color: Theme.palette.background + border.color: Theme.palette.borderSecondary + border.width: 1 + } + + contentItem: Column { + spacing: 2 + + // A handler rather than a MouseArea: the entries + // below have their own hoverEnabled MouseAreas, + // which would steal a parent MouseArea's hover. + HoverHandler { + onHoveredChanged: { + tabButton.pointerInMenu = hovered + if (hovered) + menuCloseTimer.stop() + else + tabButton.scheduleMenuClose() + } + } + + Repeater { + model: tabButton.items + + delegate: Rectangle { + id: menuEntry + + required property int index + required property string modelData + + readonly property bool current: root.currentIndex === tabButton.index + && root.currentSubIndex === menuEntry.index + + objectName: "navMenuItem%1_%2".arg(tabButton.index).arg(menuEntry.index) + width: tabMenu.availableWidth + height: 36 + radius: 8 + color: entryMouse.containsMouse + ? Theme.palette.backgroundSecondary : "transparent" + + Accessible.role: Accessible.MenuItem + Accessible.name: menuEntry.modelData + + function activate() { + tabMenu.close() + root.select(tabButton.index, menuEntry.index) + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: menuEntry.modelData + color: menuEntry.current || entryMouse.containsMouse + ? Theme.palette.text : Theme.palette.textSecondary + font.pixelSize: 14 + font.weight: menuEntry.current ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + + MouseArea { + id: entryMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: menuEntry.activate() + } + } + } + } } Behavior on color { ColorAnimation { duration: 150 } } @@ -107,7 +245,7 @@ Item { Text { id: tabLabel anchors.centerIn: parent - text: tabButton.modelData + text: tabButton.modelData.label color: tabButton.active ? Theme.palette.text : Theme.palette.textSecondary font.pixelSize: 14 font.weight: tabButton.active ? Font.Medium : Font.Normal @@ -117,7 +255,19 @@ Item { MouseArea { anchors.fill: parent + hoverEnabled: true cursorShape: Qt.PointingHandCursor + + onEntered: { + tabButton.pointerOnTab = true + tabButton.openMenu() + } + + onExited: { + tabButton.pointerOnTab = false + tabButton.scheduleMenuClose() + } + onClicked: { tabButton.forceActiveFocus() tabButton.activate() diff --git a/apps/amm/qml/components/liquidity/AmountMath.js b/apps/amm/qml/components/liquidity/AmountMath.js index 1130983..aac4484 100644 --- a/apps/amm/qml/components/liquidity/AmountMath.js +++ b/apps/amm/qml/components/liquidity/AmountMath.js @@ -71,6 +71,23 @@ function subtract(left, right) { return normalize(result.join("")) } +function add(left, right) { + var a = normalize(left) + var b = normalize(right) + var result = [] + var carry = 0 + var j = b.length - 1 + for (var i = a.length - 1; i >= 0 || j >= 0 || carry > 0; --i) { + var digit = carry + + (i >= 0 ? Number(a.charAt(i)) : 0) + + (j >= 0 ? Number(b.charAt(j)) : 0) + carry = digit >= 10 ? 1 : 0 + result.unshift(String(digit % 10)) + --j + } + return normalize(result.join("")) +} + function multiply(left, right) { var a = normalize(left) var b = normalize(right) diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index efcd97b..bebd5e4 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -801,6 +801,23 @@ AmmActionCard { root.requestQuote(true) } + // Back to a first-visit form. resetPairDraft() clears only the draft and keeps + // the selected pair, which is right when the pair itself just changed; this + // also drops the pair, the fee tier, slippage and any token-resolution error, + // for when the form is re-entered rather than edited. + function resetAll() { + root.selectedTokenAId = "" + root.selectedTokenBId = "" + root.selectedFeeBps = 30 + root.slippageBps = 50 + root.resolvingTokenId = "" + root.resolvingTokenSide = "" + root.tokenResolutionError = "" + root.tokenResolutionErrorSide = "" + root.tokenResolutionMessage = "" + root.resetPairDraft() + } + function resetPairDraft() { root.activePoolQuote = ({}) root.amountA = "" diff --git a/apps/amm/qml/components/shared/TokenSelectorModal.qml b/apps/amm/qml/components/shared/TokenSelectorModal.qml index 68b0363..edee032 100644 --- a/apps/amm/qml/components/shared/TokenSelectorModal.qml +++ b/apps/amm/qml/components/shared/TokenSelectorModal.qml @@ -210,8 +210,12 @@ Popup { delegate: Item { id: tokenRow + objectName: "tokenListItem" required property var modelData + // Exposed for UI tests to read the row's token (see swap.mjs). + readonly property string tokenSymbol: root.tokenSymbol(modelData) + || root.tokenName(modelData) readonly property bool selectable: root.isSelectable(modelData) readonly property string disabledReason: root.disabledReasonForCode( modelData.code diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 2e6e1f3..7e606aa 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -39,6 +39,14 @@ Item { // has answered, since the handoff can arrive before the selector's rows do. property var pendingPair: null + // Clears the form back to a first-visit state. Called by Main.qml whenever the + // page is navigated to, so a previous visit's pair and amounts don't linger. + // A pair handed over by selectPair() is applied after this, not before. + function resetForm() { + root.pendingPair = null + form.resetAll() + } + // Preselects a pool's pair on the new-position form. Called by Main.qml when // the pool detail view's "Add liquidity" button is pressed. function selectPair(pool) { diff --git a/apps/amm/qml/pages/PositionsPage.qml b/apps/amm/qml/pages/PositionsPage.qml new file mode 100644 index 0000000..496aa2c --- /dev/null +++ b/apps/amm/qml/pages/PositionsPage.qml @@ -0,0 +1,582 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "../components/liquidity" +import "../components/liquidity/AmountMath.js" as AmountMath +import "../components/shared/TokenVisuals.js" as TokenVisuals + +// The wallet's liquidity positions. The program has no "list my positions" read +// and an LP definition id can't be reversed back to its pool, so positions are +// discovered the other way round: resolve every pool in AMM_POOLS_CONFIG, then +// match each pool's lpDefinitionId against the wallet's token holdings. A pool +// the config doesn't list therefore can't surface here, however many LP tokens +// the wallet holds for it. +Item { + id: root + + objectName: "positionsPage" + + // Real backend replica (logos.module("amm_ui")) and the watch runtime, + // wired from Main.qml. Null until the app is ready. + property var backend: null + property var runtime: null + + readonly property int pageMargin: width < 640 ? 16 : 24 + readonly property int contentMaxWidth: 760 + + // One entry per pool the wallet holds LP tokens for; see buildPosition(). + property var positions: [] + readonly property int positionCount: root.positions ? root.positions.length : 0 + + property bool loading: false + property string loadError: "" + + // Emitted when a row is activated (click or keyboard); Main.qml opens the + // pool detail view, from where the pair can be topped up. + signal positionActivated(var position) + + readonly property bool walletOpen: !!(root.backend && root.backend.isWalletOpen) + readonly property bool showEmptyState: !root.loading && root.positionCount === 0 + + // Monotonic tag for a whole reload: a wallet switch mid-flight would + // otherwise let the previous wallet's pool resolutions land in the list. + property int generation: 0 + // Outstanding resolvePoolAccount calls for the current generation. + property int pendingResolves: 0 + + function reload() { + if (!root.backend || !root.runtime) + return + + // Reserves move with every swap, so what was resolved last time is stale + // by the time the page is opened again. Resolving every pool for a page + // nobody is looking at would be wasted work, so an offscreen reload just + // marks the list dirty and onVisibleChanged refetches on the way in. + if (!root.visible) + return + + const gen = ++root.generation + root.loading = true + root.loadError = "" + root.positions = [] + root.pendingResolves = 0 + + // Read isWalletOpen off the backend rather than through the walletOpen + // binding: this runs from onBackendChanged, where that binding may not + // have re-evaluated against the new backend yet. + if (!root.backend.isWalletOpen) { + // tokenHoldings() needs an open wallet; without one there is nothing + // to match pools against. + root.loading = false + return + } + + root.runtime.watch(root.backend.poolList(), + function(pools) { + if (gen !== root.generation) + return + root.loadHoldings(gen, pools || []) + }, + function(err) { root.failLoad(gen, "poolList", err) }) + } + + function loadHoldings(gen, pools) { + root.runtime.watch(root.backend.tokenHoldings(), + function(holdings) { + if (gen !== root.generation) + return + root.resolvePools(gen, pools, holdings || []) + }, + function(err) { root.failLoad(gen, "tokenHoldings", err) }) + } + + // Fans out one resolvePoolAccount per configured pool and collects whichever + // come back with an LP balance. Order follows completion, not config order. + function resolvePools(gen, pools, holdings) { + if (pools.length === 0) { + root.loading = false + return + } + + var collected = [] + root.pendingResolves = pools.length + for (var i = 0; i < pools.length; ++i) + root.resolveOne(gen, pools[i], holdings, collected) + } + + function resolveOne(gen, pool, holdings, collected) { + const idA = String(pool.tokenADefinitionId || "") + const idB = String(pool.tokenBDefinitionId || "") + if (idA.length === 0 || idB.length === 0) { + // A config entry without definition ids can't be resolved; it simply + // contributes no position rather than failing the whole list. + root.finishOne(gen, collected) + return + } + + root.runtime.watch(root.backend.resolvePoolAccount(idA, idB), + function(result) { + if (gen !== root.generation) + return + var position = root.buildPosition(pool, result, holdings) + if (position) + collected.push(position) + root.finishOne(gen, collected) + }, + function(err) { + if (gen !== root.generation) + return + console.warn("resolvePoolAccount error:", err) + root.finishOne(gen, collected) + }) + } + + function finishOne(gen, collected) { + if (gen !== root.generation) + return + root.pendingResolves -= 1 + if (root.pendingResolves > 0) + return + root.positions = collected + root.loading = false + } + + function failLoad(gen, op, err) { + if (gen !== root.generation) + return + console.warn(op + " error:", err) + root.loading = false + root.loadError = qsTr("Failed to load positions: %1").arg(err) + } + + // Total LP balance the wallet holds for one definition. Summed rather than + // first-match: nothing stops a wallet holding the same LP token in more than + // one account, and under-reporting a position would be worse than slow. + function lpBalanceFor(lpDefinitionId, holdings) { + var total = "0" + for (var i = 0; i < holdings.length; ++i) { + var holding = holdings[i] + if (String(holding.definitionId || "") === lpDefinitionId) + total = AmountMath.add(total, String(holding.balanceRaw || "0")) + } + return total + } + + function buildPosition(pool, result, holdings) { + if (!result || result.status !== "ok") + return null + + const lpDefinitionId = String(result.lpDefinitionId || "") + if (lpDefinitionId.length === 0) + return null + + const supply = String(result.liquiditySupply || "0") + if (!AmountMath.isUnsigned(supply) || AmountMath.normalize(supply) === "0") + return null + + const balance = root.lpBalanceFor(lpDefinitionId, holdings) + if (AmountMath.normalize(balance) === "0") + return null + + const reserveA = String(result.reserveA || "0") + const reserveB = String(result.reserveB || "0") + + return { + // Passed through so a row carries everything the pool detail view + // would need, without re-reading the config. + "tokenA": String(pool.tokenA || ""), + "tokenB": String(pool.tokenB || ""), + "tokenADefinitionId": String(pool.tokenADefinitionId || ""), + "tokenBDefinitionId": String(pool.tokenBDefinitionId || ""), + "poolId": String(result.poolId || pool.poolId || ""), + "feeBps": result.feeBps !== undefined ? result.feeBps : (Number(pool.feeBps) || 0), + "lpDefinitionId": lpDefinitionId, + "lpBalance": balance, + "liquiditySupply": supply, + // The wallet's claim on each reserve, floored the same way the + // program's remove_liquidity floors its payout. + "amountA": AmountMath.mulDivFloor(reserveA, balance, supply), + "amountB": AmountMath.mulDivFloor(reserveB, balance, supply), + "share": (Number(balance) || 0) / (Number(supply) || 1) + } + } + + onBackendChanged: root.reload() + onRuntimeChanged: root.reload() + // Every entry refetches: a swap on another tab moves the reserves this page's + // amounts are derived from, and nothing else tells it that happened. + onVisibleChanged: { + if (root.visible) + root.reload() + } + + Connections { + target: root.backend + function onIsWalletOpenChanged() { root.reload() } + } + + AmmTheme { + id: theme + } + + function feeLabel(feeBps) { + var percentage = Number(feeBps) / 100 + return qsTr("%1%").arg(percentage.toLocaleString(Qt.locale(), "f", 2)) + } + + // Group an exact decimal string without routing it through Number(), which + // would round any balance past ~15 digits. + function amountText(rawValue) { + var digits = String(rawValue).replace(/[^0-9]/g, "").replace(/^0+(?=[0-9])/, "") + if (digits.length === 0) + return "0" + var separator = Qt.locale().groupSeparator + var grouped = "" + for (var i = 0; i < digits.length; ++i) { + if (i > 0 && (digits.length - i) % 3 === 0) + grouped += separator + grouped += digits[i] + } + return grouped + } + + // A dust position is still a position: report it as "<0.01%" rather than + // rounding it to a flat 0% that reads as "you hold nothing". + function shareText(share) { + var percent = share * 100 + if (percent > 0 && percent < 0.01) + return qsTr("<0.01%") + return qsTr("%1%").arg(percent.toLocaleString(Qt.locale(), "f", 2)) + } + + function emptyStateText() { + if (root.loadError.length > 0) + return root.loadError + if (!root.walletOpen) + return qsTr("Connect a wallet to see your liquidity positions.") + return qsTr("No liquidity positions yet. Create a pool or add liquidity to an existing one.") + } + + Rectangle { + anchors.fill: parent + color: theme.colors.background + } + + Flickable { + id: scroll + + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: Math.max(height, pageContent.y + pageContent.implicitHeight + + root.pageMargin) + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + Item { + id: pageContent + + x: Math.max(root.pageMargin, (scroll.width - width) / 2) + y: root.width < 640 ? 24 : 40 + width: Math.max(0, Math.min(root.contentMaxWidth, + scroll.width - root.pageMargin * 2)) + implicitHeight: contentColumn.implicitHeight + + Column { + id: contentColumn + + width: parent.width + spacing: 24 + + Column { + width: parent.width + spacing: 6 + + Text { + text: qsTr("Positions") + color: theme.colors.textPrimary + font.pixelSize: 30 + font.weight: Font.Bold + } + + Text { + width: parent.width + text: qsTr("Your share of each pool, from the LP tokens in your wallet.") + color: theme.colors.textSecondary + font.pixelSize: 13 + wrapMode: Text.Wrap + } + } + + Rectangle { + id: positionsList + + objectName: "positionsList" + width: parent.width + implicitHeight: !root.showEmptyState && !root.loading + ? listContent.implicitHeight : 144 + color: theme.colors.cardBg + radius: 16 + border.color: theme.colors.border + border.width: 1 + + Column { + id: listContent + + width: parent.width + visible: !root.showEmptyState && !root.loading + + Item { + width: parent.width + height: 48 + + Text { + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Position") + color: theme.colors.textSecondary + font.pixelSize: 12 + font.weight: Font.DemiBold + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 20 + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Pool share") + color: theme.colors.textSecondary + font.pixelSize: 12 + font.weight: Font.DemiBold + } + } + + Rectangle { + width: parent.width + height: 1 + color: theme.colors.divider + } + + Repeater { + model: root.positions || [] + + delegate: PositionRow { + width: listContent.width + showDivider: index < root.positionCount - 1 + objectName: "positionRow%1".arg(index) + } + } + + Item { + width: parent.width + height: 8 + } + } + + Text { + id: emptyState + + objectName: "positionsListEmptyState" + anchors.centerIn: parent + width: parent.width - 40 + visible: root.loading || root.showEmptyState + text: root.loading ? qsTr("Loading positions…") : root.emptyStateText() + color: root.loadError.length > 0 && !root.loading + ? theme.colors.error : theme.colors.textSecondary + font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + } + + // Only pools the app knows about can be matched, so a missing + // position is a config gap rather than a missing balance. + Text { + width: parent.width + visible: root.walletOpen && !root.loading + text: qsTr("Positions are matched against the pools in the app's pool config.") + color: theme.colors.textPlaceholder + font.pixelSize: 11 + wrapMode: Text.Wrap + } + } + } + } + + component PositionRow: Item { + id: row + + required property var modelData + required property int index + property bool showDivider: false + readonly property var position: modelData + readonly property string pairText: qsTr("%1 / %2") + .arg(String(position.tokenA || "")) + .arg(String(position.tokenB || "")) + readonly property string feeText: root.feeLabel(position.feeBps) + readonly property string shareText: root.shareText(position.share) + readonly property string underlyingText: qsTr("%1 %2 · %3 %4") + .arg(root.amountText(position.amountA)) + .arg(String(position.tokenA || "")) + .arg(root.amountText(position.amountB)) + .arg(String(position.tokenB || "")) + + height: 76 + activeFocusOnTab: true + + Accessible.role: Accessible.Button + Accessible.name: qsTr("%1, %2 fee, %3 of the pool") + .arg(row.pairText).arg(row.feeText).arg(row.shareText) + Accessible.onPressAction: row.activate() + + function activate() { + root.positionActivated(row.position) + } + + Keys.onReturnPressed: row.activate() + Keys.onEnterPressed: row.activate() + Keys.onSpacePressed: row.activate() + + // Hover/focus tint sits behind the content so the row reads as clickable. + Rectangle { + anchors.fill: parent + color: theme.colors.panelHoverBg + opacity: rowMouse.containsMouse || row.activeFocus ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { duration: 120 } + } + } + + MouseArea { + id: rowMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: row.activate() + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + spacing: 12 + + Item { + Layout.preferredWidth: 46 + Layout.preferredHeight: 30 + Accessible.ignored: true + + TokenAvatar { + x: 0 + y: 1 + symbol: String(row.position.tokenA || "") + z: 1 + } + + TokenAvatar { + x: 18 + y: 1 + symbol: String(row.position.tokenB || "") + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: row.pairText + color: theme.colors.textPrimary + font.pixelSize: 16 + font.weight: Font.Medium + elide: Text.ElideRight + Layout.fillWidth: true + Layout.preferredWidth: implicitWidth + } + + Rectangle { + Layout.preferredWidth: feePillText.implicitWidth + 16 + Layout.preferredHeight: 22 + radius: 6 + color: theme.colors.inputBg + border.color: theme.colors.borderStrong + border.width: 1 + + Text { + id: feePillText + + anchors.centerIn: parent + text: row.feeText + color: theme.colors.textSecondary + font.pixelSize: 11 + font.weight: Font.Medium + } + } + } + + Text { + Layout.fillWidth: true + text: row.underlyingText + color: theme.colors.textSecondary + font.pixelSize: 12 + elide: Text.ElideRight + } + } + + Text { + text: row.shareText + color: theme.colors.textPrimary + font.pixelSize: 14 + font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: row.showDivider + height: 1 + color: theme.colors.divider + } + } + + component TokenAvatar: Rectangle { + id: avatar + + required property string symbol + + width: 28 + height: 28 + radius: 14 + color: TokenVisuals.colorFor(symbol) + border.color: theme.colors.cardBg + border.width: 2 + Accessible.ignored: true + + Text { + anchors.centerIn: parent + text: TokenVisuals.letterFor(avatar.symbol) + color: "#FFFFFF" + font.pixelSize: 10 + font.weight: Font.Bold + Accessible.ignored: true + } + } +} diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 019e53e..c6e626e 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -226,6 +226,7 @@ Item { TokenSelectorModal { id: tokenModal + objectName: "tokenSelectorModal" z: 10 theme: pageTheme tokens: root.tokens diff --git a/apps/amm/tests/add-liquidity.mjs b/apps/amm/tests/add-liquidity.mjs index e0b5d57..afd777b 100644 --- a/apps/amm/tests/add-liquidity.mjs +++ b/apps/amm/tests/add-liquidity.mjs @@ -173,8 +173,11 @@ test("amm liquidity: add to the A/B pool", async (app) => { const before = await readReserveA(app); console.log(` A/B reserveA before: ${before.reserveA}`); - // 1. Switch to the Liquidity tab and wait for the form. - await ignore(() => app.click("Liquidity")); + // 1. Open the create-pool view (Pool > Create pool = tab 2, sub 1). Driving the + // navbar's select() fires tabChanged, which also resets the form. (The old + // "Liquidity" tab is now an entry under the "Pool" dropdown.) + const navBarId = await idByObjectName(app, "navBar"); + await evaluate(app, navBarId, "select(2, 1)"); await app.waitFor( async () => { await idByObjectName(app, "newPositionForm"); }, { timeout: 10000, interval: 300, description: "liquidity form to render" }, diff --git a/apps/amm/tests/create-pool.mjs b/apps/amm/tests/create-pool.mjs index 93783dd..8cb2897 100644 --- a/apps/amm/tests/create-pool.mjs +++ b/apps/amm/tests/create-pool.mjs @@ -145,10 +145,12 @@ test("amm liquidity: create the A/C pool", async (app) => { // 1. Switch to the Liquidity tab and wait for the form to render. await app.waitFor( - async () => { await app.expectTexts(["Trade", "Liquidity"]); }, + async () => { await app.expectTexts(["Trade", "Pool"]); }, { timeout: 20000, interval: 500, description: "nav bar to load" }, ); - await ignore(() => app.click("Liquidity")); + // "Liquidity" is now "Pool > Create pool" (tab 2, sub 1); drive the navbar directly. + const navBarId = await idByObjectName(app, "navBar"); + await evaluate(app, navBarId, "select(2, 1)"); // waitFor resolves when the condition stops throwing — it does NOT return the // callback's value, so fetch the id with a direct call afterwards. await app.waitFor( diff --git a/apps/amm/tests/custom-token.mjs b/apps/amm/tests/custom-token.mjs index 0b75369..4521a4f 100644 --- a/apps/amm/tests/custom-token.mjs +++ b/apps/amm/tests/custom-token.mjs @@ -113,10 +113,12 @@ test("amm liquidity: add a custom (unlisted) token by id", async (app) => { // 1. Switch to the Liquidity tab and wait for the form + page to render. await app.waitFor( - async () => { await app.expectTexts(["Trade", "Liquidity"]); }, + async () => { await app.expectTexts(["Trade", "Pool"]); }, { timeout: 20000, interval: 500, description: "nav bar to load" }, ); - await ignore(() => app.click("Liquidity")); + // "Liquidity" is now "Pool > Create pool" (tab 2, sub 1); drive the navbar directly. + const navBarId = await idByObjectName(app, "navBar"); + await evaluate(app, navBarId, "select(2, 1)"); await app.waitFor( async () => { await idByObjectName(app, "newPositionForm"); }, { timeout: 10000, interval: 300, description: "liquidity form to render" },