mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(apps/amm): add a pool detail view reached from the Pools list
This commit is contained in:
@@ -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
|
||||
|
||||
+37
-1
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user