diff --git a/apps/amm/README.md b/apps/amm/README.md index a0ac951..39a5974 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -243,6 +243,17 @@ 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 +`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 +pair preselected. Both the detail view and the preselection need +`tokenADefinitionId`/`tokenBDefinitionId` on the entry, and the ids must match +the ones in `TOKENS_CONFIG` (Swap) and in the token selector's resolved list +(Add liquidity); an entry without them still lists, but its detail view can only +report that the ids are missing. Volume and transaction history are not shown — +the AMM program stores no history to read them from. + Full command with the variables set (absolute paths, from the repo root): ```bash diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index 63465dd..56d9413 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -16,6 +16,24 @@ Item { property bool ready: false + // The pool row opened from PoolsPage, or null for the list. There is no + // StackView here — the Pools tab renders either the list or the detail view. + property var selectedPool: null + + // Hand a pool's pair to the Trade tab and leave the detail view. + function openSwapFor(pool) { + root.selectedPool = null + navbar.currentIndex = 0 + swapPage.selectPair(pool) + } + + // Hand a pool's pair to the Liquidity tab and leave the detail view. + function openLiquidityFor(pool) { + root.selectedPool = null + navbar.currentIndex = 1 + liquidityPage.selectPair(pool) + } + Connections { target: logos function onViewModuleReadyChanged(moduleName, isReady) { @@ -82,12 +100,16 @@ Item { anchors.bottom: parent.bottom SwapPage { + id: swapPage + anchors.fill: parent visible: navbar.currentIndex === 0 backend: root.ready ? root.backend : null } LiquidityPage { + id: liquidityPage + anchors.fill: parent backend: root.ready ? root.backend : null runtime: logos @@ -98,7 +120,21 @@ Item { anchors.fill: parent backend: root.ready ? root.backend : null runtime: logos - visible: navbar.currentIndex === 2 + visible: navbar.currentIndex === 2 && root.selectedPool === null + + onPoolActivated: function(pool) { root.selectedPool = pool } + } + + PoolDetailPage { + anchors.fill: parent + backend: root.ready ? root.backend : null + runtime: logos + visible: navbar.currentIndex === 2 && root.selectedPool !== null + pool: root.selectedPool + + onBackRequested: root.selectedPool = null + onSwapRequested: function(pool) { root.openSwapFor(pool) } + onAddLiquidityRequested: function(pool) { root.openLiquidityFor(pool) } } } } diff --git a/apps/amm/qml/components/liquidity/AmmDetailCard.qml b/apps/amm/qml/components/liquidity/AmmDetailCard.qml new file mode 100644 index 0000000..21c75de --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmDetailCard.qml @@ -0,0 +1,47 @@ +import QtQuick +import QtQuick.Layouts + +// A titled read-only card: a small section heading over a column of rows. +// Children go straight into the body column, so callers just declare their rows. +Rectangle { + id: root + + required property var theme + property string title: "" + property int contentSpacing: 10 + default property alias content: body.data + + implicitHeight: layout.implicitHeight + 40 + radius: 16 + color: root.theme.colors.cardBg + border.color: root.theme.colors.border + border.width: 1 + + ColumnLayout { + id: layout + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 20 + spacing: 14 + + Text { + Layout.fillWidth: true + text: root.title + color: root.theme.colors.textSecondary + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + ColumnLayout { + id: body + + Layout.fillWidth: true + spacing: root.contentSpacing + } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmSecondaryButton.qml b/apps/amm/qml/components/liquidity/AmmSecondaryButton.qml new file mode 100644 index 0000000..247d8f7 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmSecondaryButton.qml @@ -0,0 +1,47 @@ +import QtQuick +import QtQuick.Controls + +// Outline counterpart to AmmPrimaryButton, for the second action in a pair +// (e.g. "Add liquidity" next to "Swap") where only one should carry the CTA fill. +Button { + id: root + + required property var theme + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + implicitHeight: 44 + + Accessible.name: text + + contentItem: Text { + text: root.text + color: root.enabled ? root.theme.colors.textPrimary : root.theme.colors.textPlaceholder + font.pixelSize: 15 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: 20 + color: !root.enabled + ? "transparent" + : root.pressed + ? root.theme.colors.panelHoverBg + : root.hovered || root.activeFocus + ? root.theme.colors.panelBg + : "transparent" + border.color: root.enabled + ? (root.activeFocus ? root.theme.colors.textPrimary + : root.theme.colors.borderStrong) + : root.theme.colors.border + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 120 } + } + } +} diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 994f8f7..2e6e1f3 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -35,6 +35,51 @@ Item { property bool tokensLoading: false property int tokensGeneration: 0 + // A pair handed over from the pool detail view. Held until resolveTokens() + // has answered, since the handoff can arrive before the selector's rows do. + property var pendingPair: null + + // 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) { + if (!pool) + return + root.pendingPair = pool + root.applyPendingPair() + } + + // Only an id the selector actually offers can be preselected: the form drops + // any selection that isn't in resolveTokens(), and the pool config could + // carry a different id encoding than the resolved rows. + function selectableTokenId(definitionId) { + if (definitionId.length === 0) + return "" + for (var i = 0; i < root.resolvedTokens.length; ++i) { + if (String(root.resolvedTokens[i].definitionId) === definitionId) + return definitionId + } + return "" + } + + function applyPendingPair() { + // Guard on the list itself, not on tokensLoading: refreshTokens() assigns + // resolvedTokens before it clears the flag, so this runs from + // onResolvedTokensChanged while the load is still nominally in progress. + if (!root.pendingPair || root.resolvedTokens.length === 0) + return + var pair = root.pendingPair + // One attempt per handoff — see SwapPage.applyPendingPair(). + root.pendingPair = null + var idA = root.selectableTokenId(String(pair.tokenADefinitionId || "")) + var idB = root.selectableTokenId(String(pair.tokenBDefinitionId || "")) + if (idA.length > 0) + form.selectToken("A", idA) + if (idB.length > 0) + form.selectToken("B", idB) + } + + onResolvedTokensChanged: root.applyPendingPair() + function refreshHoldings() { if (!root.backend || root.runtime === null) return diff --git a/apps/amm/qml/pages/PoolDetailPage.qml b/apps/amm/qml/pages/PoolDetailPage.qml new file mode 100644 index 0000000..eb23ca1 --- /dev/null +++ b/apps/amm/qml/pages/PoolDetailPage.qml @@ -0,0 +1,862 @@ +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 + +// Detail view for a single pool, opened from PoolsPage. The pair, fee tier and +// token definition ids come from the clicked pool row (AMM_POOLS_CONFIG); the +// live numbers come from AmmUiBackend::resolvePoolAccount(), which is the only +// pool read the program exposes. Volume and transaction history are deliberately +// absent: the AMM stores no history, so there is nothing to chart. +Item { + id: root + + objectName: "poolDetailPage" + + // 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 + + // The pool row that was activated in PoolsPage: { tokenA, tokenB, feeBps, + // poolId, tokenADefinitionId, tokenBDefinitionId }. Null when nothing is open. + property var pool: null + + signal backRequested() + signal swapRequested(var pool) + signal addLiquidityRequested(var pool) + + readonly property int pageMargin: width < 640 ? 16 : 24 + readonly property int contentMaxWidth: 960 + // Two columns only when the sidebar still gets a usable width next to the + // balances card; below that everything stacks. + readonly property bool wideLayout: root.width >= 900 + + // ── Pool row (config) ──────────────────────────────────────────────────── + readonly property string symbolA: root.pool ? String(root.pool.tokenA || "") : "" + readonly property string symbolB: root.pool ? String(root.pool.tokenB || "") : "" + readonly property string definitionIdA: root.pool ? String(root.pool.tokenADefinitionId || "") : "" + readonly property string definitionIdB: root.pool ? String(root.pool.tokenBDefinitionId || "") : "" + readonly property string pairText: qsTr("%1 / %2").arg(root.symbolA).arg(root.symbolB) + + // ── Chain state (backend.resolvePoolAccount) ───────────────────────────── + property bool chainLoading: false + property bool chainResolved: false + property bool poolExists: false + property string poolError: "" + property string reserveA: "0" + property string reserveB: "0" + property string liquiditySupply: "0" + property string resolvedPoolId: "" + property string lpDefinitionId: "" + property string vaultAId: "" + property string vaultBId: "" + // -1 until the chain answers, so a legitimate zero-fee pool is distinguishable + // from "not resolved yet" and the config fee can stand in meanwhile. + property int chainFeeBps: -1 + + // Monotonic tag for resolvePoolAccount requests: opening pool X, going back + // and opening pool Y can leave X's reply in flight, and it must not paint + // over Y's numbers. + property int resolveGeneration: 0 + + readonly property int feeBps: root.chainFeeBps >= 0 + ? root.chainFeeBps + : (root.pool ? (Number(root.pool.feeBps) || 0) : 0) + readonly property string poolId: root.resolvedPoolId.length > 0 + ? root.resolvedPoolId + : (root.pool ? String(root.pool.poolId || "") : "") + + readonly property bool hasDefinitionIds: root.definitionIdA.length > 0 + && root.definitionIdB.length > 0 + // Both actions need the pair's definition ids to preselect anything; + // swapping additionally needs a pool that actually holds reserves. + readonly property bool canSwap: root.hasDefinitionIds && root.hasLiquidity + readonly property bool canAddLiquidity: root.hasDefinitionIds + + function loadPoolState() { + // Drop the previous pool's numbers first: this runs on every pool change, + // and stale reserves next to a new pair's header would be wrong, not just late. + root.chainLoading = false + root.chainResolved = false + root.poolExists = false + root.poolError = "" + root.reserveA = "0" + root.reserveB = "0" + root.liquiditySupply = "0" + root.resolvedPoolId = "" + root.lpDefinitionId = "" + root.vaultAId = "" + root.vaultBId = "" + root.chainFeeBps = -1 + + if (!root.backend || !root.runtime || !root.pool) + return + + // Read the ids off the pool row rather than through definitionIdA/B: this + // runs from onPoolChanged, and those bindings may not have re-evaluated + // against the new row yet. + const idA = String(root.pool.tokenADefinitionId || "") + const idB = String(root.pool.tokenBDefinitionId || "") + if (idA.length === 0 || idB.length === 0) { + // A config entry without token definition ids can't be resolved or + // acted on; say so instead of spinning forever. + console.warn("pool row has no token definition ids:", JSON.stringify(root.pool)) + root.chainResolved = true + root.poolError = qsTr("This pool is missing token definition ids in the pool config.") + return + } + + const generation = ++root.resolveGeneration + root.chainLoading = true + root.runtime.watch(root.backend.resolvePoolAccount(idA, idB), + function(result) { + if (generation !== root.resolveGeneration) + return + root.chainLoading = false + root.chainResolved = true + root.poolExists = !!(result && result.status === "ok") + root.reserveA = (result && result.reserveA) || "0" + root.reserveB = (result && result.reserveB) || "0" + root.liquiditySupply = (result && result.liquiditySupply) || "0" + root.resolvedPoolId = (result && result.poolId) || "" + root.lpDefinitionId = (result && result.lpDefinitionId) || "" + root.vaultAId = (result && result.vaultAId) || "" + root.vaultBId = (result && result.vaultBId) || "" + root.chainFeeBps = (result && result.feeBps !== undefined) ? result.feeBps : -1 + // no_pool is the ordinary "not created yet" state, not a failure. + root.poolError = (result && result.error && result.error !== "no_pool") + ? root.issueText(result.error) : "" + }, + function(error) { + if (generation !== root.resolveGeneration) + return + console.warn("resolvePoolAccount error:", error) + root.chainLoading = false + root.chainResolved = true + root.poolExists = false + root.poolError = qsTr("Failed to load pool state: %1").arg(error) + }) + } + + onPoolChanged: root.loadPoolState() + onBackendChanged: root.loadPoolState() + onRuntimeChanged: root.loadPoolState() + + function issueText(code) { + switch (String(code)) { + case "no_program_bin": + return qsTr("The AMM program binary is not available to the app.") + case "amm_not_initialized": + return qsTr("The AMM config account has not been initialized.") + case "bad_config": + return qsTr("The AMM app configuration is invalid.") + case "same_token_pair": + return qsTr("This pool is configured with the same token on both sides.") + default: + return qsTr("Failed to load pool state: %1").arg(String(code)) + } + } + + // ── Derived pool figures ───────────────────────────────────────────────── + // Amounts are raw token units throughout the app (the liquidity flow quotes + // and submits in raw units too), so they are shown unscaled here as well. + readonly property real reserveANumber: Number(root.reserveA) || 0 + readonly property real reserveBNumber: Number(root.reserveB) || 0 + readonly property real supplyNumber: Number(root.liquiditySupply) || 0 + readonly property bool hasLiquidity: root.poolExists + && root.reserveANumber > 0 + && root.reserveBNumber > 0 + + // Share of the token units held on each side. Valued at the pool's own spot + // price the two sides are equal by construction (reserveA * reserveB/reserveA + // == reserveB), so a value-weighted bar would be a constant 50/50; the unit + // split is what actually says something about the pair. Valuing them against + // an external price would need a feed this page doesn't have. + readonly property real balanceShareA: root.hasLiquidity + ? root.reserveANumber / (root.reserveANumber + root.reserveBNumber) : 0.5 + readonly property real balanceShareB: 1 - root.balanceShareA + + // Fee growth, derived — the program keeps no fee accumulator. Swap fees stay + // in the reserves and lift k without minting LP, so sqrt(k) / lpSupply starts + // at exactly 1 (new_definition mints floor(sqrt(a*b)), including the locked + // MINIMUM_LIQUIDITY) and only grows with collected fees. add mints + // min(floor(L*dA/rA), floor(L*dB/rB)) and remove pays floor(lp*r/L), both + // proportional, so neither can lower the ratio — it only ever rises. Called an + // estimate because it also absorbs that floor rounding and the over-deposit an + // imbalanced add leaves behind, not swap fees alone. + readonly property real feeGrowth: { + if (!root.hasLiquidity || root.supplyNumber <= 0) + return 0 + // sqrt(a) * sqrt(b) rather than sqrt(a * b): the product of two u128-scale + // reserves overflows a double long before either factor does. + var growth = (Math.sqrt(root.reserveANumber) * Math.sqrt(root.reserveBNumber)) + / root.supplyNumber + return growth > 1 ? growth - 1 : 0 + } + // The fraction of today's reserves that is collected fees rather than deposits. + readonly property real accruedShare: root.feeGrowth > 0 + ? root.feeGrowth / (1 + root.feeGrowth) : 0 + readonly property bool hasAccruedFees: root.accruedShare > 0 + + // Apply the (approximate) share to the exact reserve strings, so the printed + // amount keeps the magnitude of a u128 reserve instead of a double's 15 digits. + function accruedAmount(reserveRaw) { + if (!root.hasAccruedFees) + return "0" + var scaledShare = String(Math.round(root.accruedShare * 1e12)) + return AmountMath.mulDivFloor(reserveRaw, scaledShare, "1000000000000") + } + + readonly property string accruedFeesA: root.accruedAmount(root.reserveA) + readonly property string accruedFeesB: root.accruedAmount(root.reserveB) + + // Spot price from the reserves, computed on the exact decimal strings. + readonly property string priceAInB: root.hasLiquidity + ? AmountMath.formatRatio(root.reserveB, root.reserveA, 8) : "" + readonly property string priceBInA: root.hasLiquidity + ? AmountMath.formatRatio(root.reserveA, root.reserveB, 8) : "" + + // ── Formatting helpers ─────────────────────────────────────────────────── + 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 reserve 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 + } + + function percentText(share) { + return qsTr("%1%").arg((share * 100).toLocaleString(Qt.locale(), "f", 1)) + } + + // Fee growth needs far more resolution than a balance share: one swap of 1% of + // the reserve moves a 1bps pool by ~0.00005%, which rounds away at 1 decimal. + function growthText(share) { + return qsTr("%1%").arg((share * 100).toLocaleString(Qt.locale(), "f", 4)) + } + + function shortId(value) { + var text = String(value || "") + return text.length > 0 ? text : qsTr("—") + } + + AmmTheme { + id: theme + } + + 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 + + ColumnLayout { + id: contentColumn + + width: parent.width + spacing: 20 + + // ── Back link ──────────────────────────────────────────────── + Item { + id: backLink + + objectName: "poolDetailBackLink" + Layout.preferredWidth: backText.implicitWidth + 8 + Layout.preferredHeight: 24 + activeFocusOnTab: true + + Accessible.role: Accessible.Button + Accessible.name: backText.text + Accessible.onPressAction: backLink.activate() + + function activate() { + root.backRequested() + } + + Text { + id: backText + + anchors.verticalCenter: parent.verticalCenter + text: qsTr("← Pools") + color: backMouse.containsMouse || backLink.activeFocus + ? theme.colors.textPrimary : theme.colors.textSecondary + font.pixelSize: 13 + font.weight: Font.Medium + } + + MouseArea { + id: backMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: backLink.activate() + } + + Keys.onReturnPressed: backLink.activate() + Keys.onEnterPressed: backLink.activate() + Keys.onSpacePressed: backLink.activate() + } + + // ── Header: pair, fee tier, actions ────────────────────────── + GridLayout { + Layout.fillWidth: true + columns: root.wideLayout ? 2 : 1 + columnSpacing: 24 + rowSpacing: 16 + + ColumnLayout { + Layout.fillWidth: true + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Item { + Layout.preferredWidth: 58 + Layout.preferredHeight: 36 + Accessible.ignored: true + + TokenAvatar { + x: 0 + y: 0 + symbol: root.symbolA + z: 1 + } + + TokenAvatar { + x: 22 + y: 0 + symbol: root.symbolB + } + } + + Text { + objectName: "poolDetailPairText" + text: root.pairText + color: theme.colors.textPrimary + font.pixelSize: 30 + font.weight: Font.Bold + elide: Text.ElideRight + Layout.fillWidth: true + } + + Rectangle { + Layout.preferredWidth: headerFeeText.implicitWidth + 20 + Layout.preferredHeight: 30 + radius: 6 + color: theme.colors.inputBg + border.color: theme.colors.borderStrong + border.width: 1 + + Text { + id: headerFeeText + + objectName: "poolDetailFeeText" + anchors.centerIn: parent + text: root.feeLabel(root.feeBps) + color: theme.colors.textPrimary + font.pixelSize: 13 + font.weight: Font.Medium + } + } + } + + Text { + Layout.fillWidth: true + text: root.poolId.length > 0 + ? qsTr("Pool %1").arg(root.poolId) + : qsTr("Pool address unavailable") + color: theme.colors.textSecondary + font.pixelSize: 12 + elide: Text.ElideMiddle + } + } + + RowLayout { + Layout.fillWidth: !root.wideLayout + Layout.alignment: root.wideLayout ? Qt.AlignRight | Qt.AlignVCenter + : Qt.AlignLeft + spacing: 12 + + AmmPrimaryButton { + objectName: "poolDetailSwapButton" + theme: theme + text: qsTr("Swap") + enabled: root.canSwap + implicitHeight: 44 + Layout.fillWidth: !root.wideLayout + Layout.preferredWidth: root.wideLayout ? 120 : -1 + onClicked: root.swapRequested(root.pool) + } + + AmmSecondaryButton { + objectName: "poolDetailAddLiquidityButton" + theme: theme + text: qsTr("Add liquidity") + enabled: root.canAddLiquidity + Layout.fillWidth: !root.wideLayout + Layout.preferredWidth: root.wideLayout ? 150 : -1 + onClicked: root.addLiquidityRequested(root.pool) + } + } + } + + // ── Status banner ──────────────────────────────────────────── + Rectangle { + id: statusBanner + + objectName: "poolDetailStatusBanner" + readonly property string message: { + if (root.poolError.length > 0) + return root.poolError + if (root.chainLoading || !root.chainResolved) + return qsTr("Loading pool state…") + if (!root.poolExists) + return qsTr("This pool has not been created on-chain yet. Add liquidity to create it.") + if (!root.hasLiquidity) + return qsTr("This pool holds no liquidity.") + return "" + } + readonly property bool isError: root.poolError.length > 0 + + Layout.fillWidth: true + visible: message.length > 0 + implicitHeight: visible ? statusText.implicitHeight + 28 : 0 + radius: 12 + color: theme.colors.cardBg + border.color: isError ? theme.colors.error : theme.colors.border + border.width: 1 + + Text { + id: statusText + + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + text: statusBanner.message + color: statusBanner.isError ? theme.colors.error : theme.colors.textSecondary + font.pixelSize: 13 + wrapMode: Text.Wrap + } + } + + // ── Body: balances + price | stats + fees + addresses ──────── + GridLayout { + Layout.fillWidth: true + columns: root.wideLayout ? 2 : 1 + columnSpacing: 20 + rowSpacing: 20 + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: 20 + + // ── Pool balances ──────────────────────────────────── + AmmDetailCard { + objectName: "poolBalancesCard" + theme: theme + title: qsTr("Pool balances") + contentSpacing: 14 + Layout.fillWidth: true + + // Stacked bar: each side's share of the pool's raw units. + Rectangle { + id: balanceBar + + objectName: "poolBalanceBar" + Layout.fillWidth: true + Layout.preferredHeight: 10 + radius: 5 + color: theme.colors.inputBg + clip: true + Accessible.ignored: true + + readonly property real shareA: root.hasLiquidity ? root.balanceShareA : 0.5 + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * balanceBar.shareA + color: root.hasLiquidity + ? TokenVisuals.colorFor(root.symbolA) + : theme.colors.noTokenCircle + + Behavior on width { + NumberAnimation { duration: 180; easing.type: Easing.OutCubic } + } + } + + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * (1 - balanceBar.shareA) + color: root.hasLiquidity + ? TokenVisuals.colorFor(root.symbolB) + : theme.colors.noTokenCircle + + Behavior on width { + NumberAnimation { duration: 180; easing.type: Easing.OutCubic } + } + } + } + + BalanceRow { + objectName: "poolBalanceRowA" + symbol: root.symbolA + amount: root.hasLiquidity ? root.amountText(root.reserveA) : qsTr("—") + share: root.hasLiquidity ? root.percentText(root.balanceShareA) : "" + } + + BalanceRow { + objectName: "poolBalanceRowB" + symbol: root.symbolB + amount: root.hasLiquidity ? root.amountText(root.reserveB) : qsTr("—") + share: root.hasLiquidity ? root.percentText(root.balanceShareB) : "" + } + + Text { + Layout.fillWidth: true + text: qsTr("Split by token units held, which reflects the pool's own price for the pair.") + color: theme.colors.textPlaceholder + font.pixelSize: 11 + wrapMode: Text.Wrap + } + } + + // ── Price ──────────────────────────────────────────── + AmmDetailCard { + objectName: "poolPriceCard" + theme: theme + title: qsTr("Price") + Layout.fillWidth: true + + StatRow { + objectName: "poolPriceRowA" + label: qsTr("1 %1").arg(root.symbolA) + value: root.priceAInB.length > 0 + ? qsTr("%1 %2").arg(root.priceAInB).arg(root.symbolB) + : qsTr("—") + } + + StatRow { + objectName: "poolPriceRowB" + label: qsTr("1 %1").arg(root.symbolB) + value: root.priceBInA.length > 0 + ? qsTr("%1 %2").arg(root.priceBInA).arg(root.symbolA) + : qsTr("—") + } + + Text { + Layout.fillWidth: true + text: qsTr("Spot price from the current reserves, before fees and price impact.") + color: theme.colors.textPlaceholder + font.pixelSize: 11 + wrapMode: Text.Wrap + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.wideLayout ? 320 : -1 + Layout.maximumWidth: root.wideLayout ? 320 : Number.POSITIVE_INFINITY + Layout.alignment: Qt.AlignTop + spacing: 20 + + // ── Stats ──────────────────────────────────────────── + AmmDetailCard { + objectName: "poolStatsCard" + theme: theme + title: qsTr("Stats") + Layout.fillWidth: true + + StatRow { + objectName: "poolStatFeeTier" + label: qsTr("Fee tier") + value: root.feeLabel(root.feeBps) + } + + StatRow { + objectName: "poolStatReserveA" + label: qsTr("%1 reserve").arg(root.symbolA) + value: root.poolExists ? root.amountText(root.reserveA) : qsTr("—") + } + + StatRow { + objectName: "poolStatReserveB" + label: qsTr("%1 reserve").arg(root.symbolB) + value: root.poolExists ? root.amountText(root.reserveB) : qsTr("—") + } + + StatRow { + objectName: "poolStatLpSupply" + label: qsTr("LP token supply") + value: root.poolExists ? root.amountText(root.liquiditySupply) : qsTr("—") + } + } + + // ── Accumulated fees ───────────────────────────────── + AmmDetailCard { + objectName: "poolFeesCard" + theme: theme + title: qsTr("Accumulated fees") + Layout.fillWidth: true + + StatRow { + objectName: "poolFeesRowA" + label: root.symbolA + value: root.hasAccruedFees ? root.amountText(root.accruedFeesA) : qsTr("—") + } + + StatRow { + objectName: "poolFeesRowB" + label: root.symbolB + value: root.hasAccruedFees ? root.amountText(root.accruedFeesB) : qsTr("—") + } + + StatRow { + objectName: "poolFeesGrowthRow" + label: qsTr("Growth since creation") + value: root.hasAccruedFees + ? root.growthText(root.feeGrowth) : qsTr("—") + } + + Text { + Layout.fillWidth: true + text: root.hasAccruedFees + ? qsTr("Estimated. Swap fees stay in the reserves instead of a counter, so this is derived from how far the reserves have grown ahead of the LP supply.") + : qsTr("Swap fees stay in the reserves instead of a counter. Nothing has accrued beyond the deposits yet.") + color: theme.colors.textPlaceholder + font.pixelSize: 11 + wrapMode: Text.Wrap + } + } + + // ── Addresses ──────────────────────────────────────── + AmmDetailCard { + objectName: "poolAddressesCard" + theme: theme + title: qsTr("Addresses") + Layout.fillWidth: true + + AddressRow { + objectName: "poolAddressPool" + label: qsTr("Pool") + value: root.shortId(root.poolId) + } + + AddressRow { + objectName: "poolAddressVaultA" + label: qsTr("%1 vault").arg(root.symbolA) + value: root.shortId(root.vaultAId) + } + + AddressRow { + objectName: "poolAddressVaultB" + label: qsTr("%1 vault").arg(root.symbolB) + value: root.shortId(root.vaultBId) + } + + AddressRow { + objectName: "poolAddressLpToken" + label: qsTr("LP token") + value: root.shortId(root.lpDefinitionId) + } + } + } + } + } + } + } + + // ── Page-local building blocks ─────────────────────────────────────────── + + // Anchors rather than a RowLayout: the value is capped against the row's own + // width, and a layout child whose maximumWidth depends on the row width can + // feed back into the row's implicit size. + component StatRow: Item { + id: statRow + + property string label: "" + property string value: "" + + Layout.fillWidth: true + implicitHeight: Math.max(statLabel.implicitHeight, statValue.implicitHeight) + + Text { + id: statLabel + + anchors.left: parent.left + anchors.right: statValue.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: statRow.label + color: theme.colors.textSecondary + font.pixelSize: 13 + elide: Text.ElideRight + } + + Text { + id: statValue + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, statRow.width * 0.7) + text: statRow.value + color: theme.colors.textPrimary + font.pixelSize: 13 + font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + } + + component AddressRow: ColumnLayout { + id: addressRow + + property string label: "" + property string value: "" + + Layout.fillWidth: true + spacing: 2 + + Text { + text: addressRow.label + color: theme.colors.textSecondary + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: addressRow.value + color: theme.colors.textPrimary + font.pixelSize: 12 + font.family: "monospace" + elide: Text.ElideMiddle + Layout.fillWidth: true + } + } + + component BalanceRow: RowLayout { + id: balanceRow + + property string symbol: "" + property string amount: "" + property string share: "" + + Layout.fillWidth: true + spacing: 10 + + Rectangle { + Layout.preferredWidth: 10 + Layout.preferredHeight: 10 + radius: 5 + color: TokenVisuals.colorFor(balanceRow.symbol) + Accessible.ignored: true + } + + // Both sides absorb the shortfall (and both elide), so a long raw reserve + // can't push the percentage out of the card. + Text { + text: balanceRow.symbol + color: theme.colors.textPrimary + font.pixelSize: 14 + font.weight: Font.Medium + elide: Text.ElideRight + Layout.fillWidth: true + Layout.preferredWidth: implicitWidth + } + + Text { + text: balanceRow.amount + color: theme.colors.textPrimary + font.pixelSize: 14 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + Layout.fillWidth: true + Layout.preferredWidth: implicitWidth + } + + Text { + visible: balanceRow.share.length > 0 + text: balanceRow.share + color: theme.colors.textSecondary + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 46 + } + } + + component TokenAvatar: Rectangle { + id: avatar + + required property string symbol + + width: 36 + height: 36 + radius: 18 + 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: 13 + font.weight: Font.Bold + Accessible.ignored: true + } + } +} diff --git a/apps/amm/qml/pages/PoolsPage.qml b/apps/amm/qml/pages/PoolsPage.qml index ae0d6c0..6962a18 100644 --- a/apps/amm/qml/pages/PoolsPage.qml +++ b/apps/amm/qml/pages/PoolsPage.qml @@ -15,6 +15,10 @@ Item { property var backend: null property var runtime: null + // Emitted when a row is activated (click or keyboard); Main.qml opens the + // pool detail view with the row's pool object. + signal poolActivated(var pool) + readonly property int pageMargin: width < 640 ? 16 : 24 readonly property int contentMaxWidth: 760 readonly property int poolCount: root.pools ? root.pools.length : 0 @@ -157,6 +161,13 @@ Item { objectName: "poolRow%1".arg(index) } } + + // Keeps the last row's hover tint off the card's rounded + // bottom corners, which a square child would paint over. + Item { + width: parent.width + height: 8 + } } Text { @@ -190,6 +201,40 @@ Item { readonly property string feeText: root.feeLabel(pool.feeBps) height: 68 + activeFocusOnTab: true + + Accessible.role: Accessible.Button + Accessible.name: qsTr("%1, %2 fee").arg(row.pairText).arg(row.feeText) + Accessible.onPressAction: row.activate() + + function activate() { + root.poolActivated(row.pool) + } + + 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 diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index b76b338..019e53e 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -25,6 +25,59 @@ Item { // latest, so an out-of-order tokenHoldings reply can't clobber the current wallet's list. property int holdingsGeneration: 0 + // A pair handed over from the pool detail view. Held until tokenList() has + // resolved, since the handoff can arrive before the config tokens do. + property var pendingPair: null + + // Preselects a pool's pair on the swap card. Called by Main.qml when the pool + // detail view's "Swap" button is pressed. + function selectPair(pool) { + if (!pool) + return + root.pendingPair = pool + root.applyPendingPair() + } + + // The pool config and the token config carry the same definition ids, so that + // is the primary match; the symbol is a fallback for a config that only + // agrees on the pair's names. + function tokenFor(definitionId, symbol) { + var i + if (definitionId.length > 0) { + for (i = 0; i < root.tokens.length; ++i) { + if (String(root.tokens[i].definitionId) === definitionId) + return root.tokens[i] + } + } + if (symbol.length > 0) { + for (i = 0; i < root.tokens.length; ++i) { + if (String(root.tokens[i].symbol) === symbol) + return root.tokens[i] + } + } + return null + } + + function applyPendingPair() { + if (!root.pendingPair || root.tokens.length === 0) + return + var pair = root.pendingPair + // One attempt per handoff: a pair the token config doesn't carry stays + // unselected rather than lying in wait for a later tokenList() refresh. + root.pendingPair = null + var sell = root.tokenFor(String(pair.tokenADefinitionId || ""), String(pair.tokenA || "")) + var buy = root.tokenFor(String(pair.tokenBDefinitionId || ""), String(pair.tokenB || "")) + if (!sell && !buy) + return + swapCard.resetAmounts() + if (sell) + swapCard.setToken("sell", sell) + if (buy) + swapCard.setToken("buy", buy) + } + + onTokensChanged: root.applyPendingPair() + function refreshHoldings() { if (!root.backend) return diff --git a/apps/amm/tests/qml/tst_PoolsPage.qml b/apps/amm/tests/qml/tst_PoolsPage.qml index 58fc832..45f9541 100644 --- a/apps/amm/tests/qml/tst_PoolsPage.qml +++ b/apps/amm/tests/qml/tst_PoolsPage.qml @@ -15,7 +15,10 @@ TestCase { QtObject { property var poolListResult: [ - { "tokenA": "TKA", "tokenB": "TKB", "feeBps": 5 }, + { + "tokenA": "TKA", "tokenB": "TKB", "feeBps": 5, + "tokenADefinitionId": "DEF_A", "tokenBDefinitionId": "DEF_B" + }, { "tokenA": "TKC", "tokenB": "TKA", "feeBps": 30 } ] @@ -71,6 +74,40 @@ TestCase { compare(firstRow.feeText, page.feeLabel(5)) } + function test_activatingARowHandsItsPoolToTheDetailView() { + var backend = createTemporaryObject(backendComponent, testCase) + var runtime = createTemporaryObject(runtimeComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend, + "runtime": runtime + }) + verify(page) + + var spy = createTemporaryObject(spyComponent, testCase, { + "target": page, + "signalName": "poolActivated" + }) + verify(spy) + + findChild(page, "poolRow1").activate() + + // The whole row goes through, so the detail view gets the definition ids + // it needs to resolve the pool — not just the displayed pair. + compare(spy.count, 1) + compare(spy.signalArguments[0][0].tokenA, "TKC") + compare(spy.signalArguments[0][0].feeBps, 30) + + findChild(page, "poolRow0").activate() + compare(spy.count, 2) + compare(spy.signalArguments[1][0].tokenADefinitionId, "DEF_A") + } + + Component { + id: spyComponent + + SignalSpy {} + } + function test_emptyPoolModelUsesTheEmptyState() { var page = createTemporaryObject(pageComponent, testCase) verify(page)