mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-27 07:01:14 +00:00
feat(wallet): add reusable program account selector
This commit is contained in:
+8
-8
@@ -180,11 +180,11 @@ Swap view stays disabled (no pool can be resolved).
|
||||
|
||||
### Token list config (required for the Swap token picker)
|
||||
|
||||
The Swap view's token picker is config-driven: it doesn't derive tokens from
|
||||
chain state, it reads a flat JSON list from the `TOKENS_CONFIG` environment
|
||||
variable (absolute path). Each entry needs, at minimum, the token's
|
||||
`definitionId` and **your own** `holding` account address for that token (the
|
||||
account the wallet will sign transfers from/to for that token):
|
||||
The Swap view's token picker is config-driven: it doesn't derive token
|
||||
definitions from chain state, it reads a flat JSON list from the
|
||||
`TOKENS_CONFIG` environment variable (absolute path). Each entry needs, at
|
||||
minimum, a `definitionId`. Source and destination TokenHoldings come from the
|
||||
connected wallet:
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -192,7 +192,6 @@ account the wallet will sign transfers from/to for that token):
|
||||
"symbol": "TKA",
|
||||
"name": "Token A",
|
||||
"definitionId": "9qbX…",
|
||||
"holding": "4T69…",
|
||||
"decimals": 18
|
||||
}
|
||||
]
|
||||
@@ -208,8 +207,9 @@ cp apps/amm/amm-tokens.json.example apps/amm/amm-tokens.json # then replace th
|
||||
|
||||
If `TOKENS_CONFIG` is unset, unreadable, or not a valid JSON array, the token
|
||||
picker stays empty (a `qWarning` naming the exact cause is logged to stderr; no
|
||||
swap can be started). `definitionId`/`holding` may be given as base58 (as the
|
||||
wallet/runbook display them) or hex — the app normalizes both to hex.
|
||||
swap can be started). `definitionId` may be given as base58 (as the
|
||||
wallet/runbook displays it) or hex. A legacy `holding` value is accepted but is
|
||||
not used for transaction account selection.
|
||||
|
||||
Full command with both variables set (absolute paths, from the repo root):
|
||||
|
||||
|
||||
@@ -61,10 +61,17 @@ AmmActionCard {
|
||||
})
|
||||
readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens
|
||||
? root.newPositionContext.tokens : []
|
||||
readonly property var programAccounts: root.newPositionContext
|
||||
&& root.newPositionContext.programAccounts
|
||||
? root.newPositionContext.programAccounts : []
|
||||
readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers
|
||||
? root.newPositionContext.feeTiers : []
|
||||
readonly property var tokenA: root.tokenById(root.selectedTokenAId)
|
||||
readonly property var tokenB: root.tokenById(root.selectedTokenBId)
|
||||
readonly property string selectedHoldingAId: tokenAInput.selectedHoldingId
|
||||
readonly property string selectedHoldingBId: tokenBInput.selectedHoldingId
|
||||
readonly property var holdingA: tokenAInput.selectedHolding
|
||||
readonly property var holdingB: tokenBInput.selectedHolding
|
||||
readonly property int decimalsA: 0
|
||||
readonly property int decimalsB: 0
|
||||
readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0
|
||||
@@ -96,6 +103,9 @@ AmmActionCard {
|
||||
&& !root.quoteStale
|
||||
&& !root.submitting
|
||||
&& !root.poolCreationPending
|
||||
&& tokenAInput.holdingReady
|
||||
&& tokenBInput.holdingReady
|
||||
&& lpDestinationSelector.ready
|
||||
|
||||
signal quoteRequested(bool immediate, var quoteRequest)
|
||||
signal confirmationRequested(var snapshot)
|
||||
@@ -235,15 +245,18 @@ AmmActionCard {
|
||||
theme: root.theme
|
||||
text: root.amountA
|
||||
label: qsTr("Token A amount")
|
||||
balance: root.contextLoading ? "" : root.balanceText(root.tokenA, root.decimalsA)
|
||||
balance: root.contextLoading ? "" : root.holdingBalanceText(
|
||||
root.holdingA, root.decimalsA)
|
||||
helperText: root.missingPool && !root.compact
|
||||
? root.minimumAmountText("A") : ""
|
||||
errorText: root.formErrorText()
|
||||
invalid: root.fieldHasError("amountA")
|
||||
|| root.fieldHasError("holdingAId")
|
||||
readOnly: root.submitting || (!root.activePool && !root.missingPool)
|
||||
showMaxButton: root.activePool
|
||||
tokenData: root.tokenA.definitionId ? root.tokenA : null
|
||||
tokens: root.tokens
|
||||
programAccounts: root.programAccounts
|
||||
selectedTokenId: root.selectedTokenAId
|
||||
tokenInvalid: root.tokenHasError("A")
|
||||
tokenSelectionEnabled: !root.contextLoading && !root.submitting
|
||||
@@ -267,6 +280,10 @@ AmmActionCard {
|
||||
onMaxClicked: root.useMaximum()
|
||||
onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) }
|
||||
onTokenEntered: function(value) { root.resolveToken("A", value) }
|
||||
onHoldingSelectionChanged: function(holdingId) {
|
||||
root.noteDraftChanged()
|
||||
root.requestQuote(true)
|
||||
}
|
||||
}
|
||||
|
||||
AmmPairSeparator {
|
||||
@@ -284,14 +301,17 @@ AmmActionCard {
|
||||
theme: root.theme
|
||||
text: root.amountB
|
||||
label: qsTr("Token B amount")
|
||||
balance: root.contextLoading ? "" : root.balanceText(root.tokenB, root.decimalsB)
|
||||
balance: root.contextLoading ? "" : root.holdingBalanceText(
|
||||
root.holdingB, root.decimalsB)
|
||||
helperText: root.missingPool && !root.compact
|
||||
? root.minimumAmountText("B") : ""
|
||||
invalid: root.fieldHasError("amountB")
|
||||
|| root.fieldHasError("holdingBId")
|
||||
readOnly: root.submitting || (!root.activePool && !root.missingPool)
|
||||
showMaxButton: root.activePool
|
||||
tokenData: root.tokenB.definitionId ? root.tokenB : null
|
||||
tokens: root.tokens
|
||||
programAccounts: root.programAccounts
|
||||
selectedTokenId: root.selectedTokenBId
|
||||
tokenInvalid: root.tokenHasError("B")
|
||||
tokenSelectionEnabled: !root.contextLoading && !root.submitting
|
||||
@@ -315,6 +335,10 @@ AmmActionCard {
|
||||
onMaxClicked: root.useMaximum()
|
||||
onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) }
|
||||
onTokenEntered: function(value) { root.resolveToken("B", value) }
|
||||
onHoldingSelectionChanged: function(holdingId) {
|
||||
root.noteDraftChanged()
|
||||
root.requestQuote(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +500,44 @@ AmmActionCard {
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
visible: root.hasPair
|
||||
|
||||
Text {
|
||||
text: qsTr("LP TokenHolding output")
|
||||
color: root.theme.colors.textSecondary
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
ProgramAccountSelector {
|
||||
id: lpDestinationSelector
|
||||
|
||||
objectName: "lpTokenHoldingSelector"
|
||||
Layout.fillWidth: true
|
||||
sourceModel: root.programAccounts
|
||||
accountType: "TokenHolding"
|
||||
stateField: "definitionId"
|
||||
stateValue: root.lpDefinitionIdHex()
|
||||
selectionMode: ProgramAccountSelector.Output
|
||||
createNewText: qsTr("Create new TokenHolding")
|
||||
placeholderText: qsTr("Select LP destination")
|
||||
criteriaPendingText: qsTr("Resolving LP token")
|
||||
accessibleName: qsTr("LP TokenHolding destination")
|
||||
backgroundColor: root.theme.colors.panelBg
|
||||
hoverColor: root.theme.colors.panelHoverBg
|
||||
textColor: root.theme.colors.textPrimary
|
||||
secondaryTextColor: root.theme.colors.textSecondary
|
||||
borderColor: root.theme.colors.borderStrong
|
||||
focusColor: root.theme.colors.ctaBg
|
||||
onSelectionChanged: function(accountId, createNew) {
|
||||
root.noteDraftChanged()
|
||||
root.requestQuote(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 9
|
||||
@@ -826,6 +888,8 @@ AmmActionCard {
|
||||
}
|
||||
|
||||
function swapTokens() {
|
||||
var holdingAId = root.selectedHoldingAId
|
||||
var holdingBId = root.selectedHoldingBId
|
||||
var tokenId = root.selectedTokenAId
|
||||
root.selectedTokenAId = root.selectedTokenBId
|
||||
root.selectedTokenBId = tokenId
|
||||
@@ -838,8 +902,12 @@ AmmActionCard {
|
||||
var priceAmount = root.priceAmountA
|
||||
root.priceAmountA = root.priceAmountB
|
||||
root.priceAmountB = priceAmount
|
||||
root.noteDraftChanged()
|
||||
root.requestQuote(true)
|
||||
Qt.callLater(function() {
|
||||
tokenAInput.setHoldingSelection(holdingBId)
|
||||
tokenBInput.setHoldingSelection(holdingAId)
|
||||
root.noteDraftChanged()
|
||||
root.requestQuote(true)
|
||||
})
|
||||
}
|
||||
|
||||
function resetPairDraft() {
|
||||
@@ -1074,13 +1142,27 @@ AmmActionCard {
|
||||
}
|
||||
|
||||
function pairRequest() {
|
||||
return {
|
||||
var request = {
|
||||
"tokenAId": root.displayIsCanonical
|
||||
? root.selectedTokenAId : root.selectedTokenBId,
|
||||
"tokenBId": root.displayIsCanonical
|
||||
? root.selectedTokenBId : root.selectedTokenAId,
|
||||
"feeBps": root.selectedFeeBps
|
||||
}
|
||||
var holdingAId = root.displayIsCanonical
|
||||
? root.selectedHoldingAId : root.selectedHoldingBId
|
||||
var holdingBId = root.displayIsCanonical
|
||||
? root.selectedHoldingBId : root.selectedHoldingAId
|
||||
if (holdingAId.length > 0)
|
||||
request.holdingAId = holdingAId
|
||||
if (holdingBId.length > 0)
|
||||
request.holdingBId = holdingBId
|
||||
if (lpDestinationSelector.createNewSelected) {
|
||||
request.createFreshLp = true
|
||||
} else if (lpDestinationSelector.selectedAccountId.length > 0) {
|
||||
request.lpHoldingId = lpDestinationSelector.selectedAccountId
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
function requestQuote(immediate) {
|
||||
@@ -1092,7 +1174,10 @@ AmmActionCard {
|
||||
}
|
||||
|
||||
function probeRaw(token, decimals) {
|
||||
var balance = String(token.balanceRaw || "0")
|
||||
var holding = token.definitionId === root.tokenA.definitionId
|
||||
? root.holdingA : root.holdingB
|
||||
var balance = String(holding && holding.balanceRaw
|
||||
? holding.balanceRaw : "0")
|
||||
var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000")
|
||||
if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0)
|
||||
return balance
|
||||
@@ -1132,6 +1217,12 @@ AmmActionCard {
|
||||
return root.displayIsCanonical ? "amountA" : "amountB"
|
||||
if (field === "amountBRaw")
|
||||
return root.displayIsCanonical ? "amountB" : "amountA"
|
||||
if (field === "holdingAId")
|
||||
return root.displayIsCanonical ? "holdingAId" : "holdingBId"
|
||||
if (field === "holdingBId")
|
||||
return root.displayIsCanonical ? "holdingBId" : "holdingAId"
|
||||
if (field === "lpHoldingId" || field === "createFreshLp")
|
||||
return "lpHoldingId"
|
||||
if (field === "initialPriceRealRaw")
|
||||
return "initialPrice"
|
||||
return field
|
||||
@@ -1192,6 +1283,10 @@ AmmActionCard {
|
||||
"invalid_amount_precision": qsTr("Token amounts must use whole raw units."),
|
||||
"invalid_raw_amount": qsTr("Value is outside the supported range."),
|
||||
"amount_exceeds_balance": qsTr("Amount exceeds the selected holding balance."),
|
||||
"holding_selection_required": qsTr("Select a wallet TokenHolding for this token."),
|
||||
"invalid_holding_selection": qsTr("Selected TokenHolding is unavailable."),
|
||||
"lp_destination_required": qsTr("Select where LP tokens should be deposited."),
|
||||
"invalid_lp_destination": qsTr("Selected LP TokenHolding is unavailable."),
|
||||
"amount_too_low": qsTr("Value is too low for this pool."),
|
||||
"invalid_token_id": qsTr("Enter a valid base58 TokenDefinition ID."),
|
||||
"deposit_ratio_mismatch": qsTr("Deposit amounts must match the initial price."),
|
||||
@@ -1261,8 +1356,10 @@ AmmActionCard {
|
||||
var reserveB = root.poolReserve("B")
|
||||
if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0")
|
||||
return
|
||||
var balanceA = String(root.tokenA.balanceRaw || "0")
|
||||
var balanceB = String(root.tokenB.balanceRaw || "0")
|
||||
var balanceA = String(root.holdingA && root.holdingA.balanceRaw
|
||||
? root.holdingA.balanceRaw : "0")
|
||||
var balanceB = String(root.holdingB && root.holdingB.balanceRaw
|
||||
? root.holdingB.balanceRaw : "0")
|
||||
var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB)
|
||||
var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA
|
||||
var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA)
|
||||
@@ -1470,10 +1567,23 @@ AmmActionCard {
|
||||
return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals)
|
||||
}
|
||||
|
||||
function holdingBalanceText(holding, decimals) {
|
||||
return holding ? AmountMath.formatRaw(
|
||||
String(holding.balanceRaw || "0"), decimals) : ""
|
||||
}
|
||||
|
||||
function tokenBalanceDetail(token) {
|
||||
return qsTr("Available %1").arg(root.balanceText(token, 0))
|
||||
}
|
||||
|
||||
function lpDefinitionIdHex() {
|
||||
if (root.quoteMatchesPair())
|
||||
return String(root.quotePayload.lpDefinitionIdHex || "")
|
||||
if (root.quoteMatchesSelectedPair(root.activePoolQuote))
|
||||
return String(root.activePoolQuote.lpDefinitionIdHex || "")
|
||||
return ""
|
||||
}
|
||||
|
||||
function shortId(value) {
|
||||
var text = String(value || "")
|
||||
return text.length > 14 ? text.slice(0, 7) + "…" + text.slice(-5) : text
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
import Logos.Wallet
|
||||
|
||||
import "../shared"
|
||||
|
||||
@@ -13,6 +16,7 @@ AmmTokenAmountSurface {
|
||||
property bool showMaxButton: true
|
||||
property var tokenData: null
|
||||
property var tokens: []
|
||||
property var programAccounts: []
|
||||
property string selectedTokenId: ""
|
||||
property bool tokenInvalid: false
|
||||
property bool tokenSelectionEnabled: true
|
||||
@@ -25,19 +29,26 @@ AmmTokenAmountSurface {
|
||||
property alias popup: tokenModal
|
||||
property alias query: tokenModal.searchText
|
||||
readonly property var rows: tokenModal.rows
|
||||
property string selectedHoldingId: ""
|
||||
property bool holdingReady: false
|
||||
property bool hasHoldingFunds: false
|
||||
property var selectedHolding: null
|
||||
property real accessoryContentHeight: 40
|
||||
|
||||
signal editingChanged(string value)
|
||||
signal editingCommitted(string value)
|
||||
signal maxClicked
|
||||
signal tokenSelected(string tokenId)
|
||||
signal tokenEntered(string value)
|
||||
signal holdingSelectionChanged(string holdingId)
|
||||
signal holdingSelectionRequested(string holdingId)
|
||||
|
||||
amount: root.text
|
||||
supportingText: root.helperText
|
||||
supportingActionText: root.showMaxButton ? qsTr("MAX") : ""
|
||||
accessory: tokenActions
|
||||
accessoryWidth: width < 360 ? 132 : 180
|
||||
accessoryHeight: root.balance.length > 0 ? 58 : 40
|
||||
accessoryHeight: root.accessoryContentHeight
|
||||
|
||||
onAmountEdited: function(value) {
|
||||
root.pendingValue = value
|
||||
@@ -68,17 +79,87 @@ AmmTokenAmountSurface {
|
||||
Component {
|
||||
id: tokenActions
|
||||
|
||||
AmmTokenAccessory {
|
||||
theme: root.theme
|
||||
enabled: root.tokenSelectionEnabled
|
||||
invalid: root.tokenInvalid
|
||||
hasToken: root.tokenData !== null
|
||||
tokenColor: root.tokenColor(root.tokenData)
|
||||
tokenLetter: root.tokenLetter(root.tokenData)
|
||||
tokenText: root.tokenText(root.tokenData)
|
||||
balance: root.balance
|
||||
accessibleName: qsTr("Select %1").arg(root.label)
|
||||
onClicked: tokenModal.open()
|
||||
ColumnLayout {
|
||||
id: tokenActionLayout
|
||||
|
||||
spacing: 4
|
||||
|
||||
Binding {
|
||||
target: root
|
||||
property: "accessoryContentHeight"
|
||||
value: tokenActionLayout.implicitHeight
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: root
|
||||
property: "selectedHoldingId"
|
||||
value: holdingPicker.selectedAccountId
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: root
|
||||
property: "holdingReady"
|
||||
value: holdingPicker.ready
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: root
|
||||
property: "hasHoldingFunds"
|
||||
value: holdingPicker.hasFunds
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: root
|
||||
property: "selectedHolding"
|
||||
value: holdingPicker.selectedAccount
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
|
||||
function onHoldingSelectionRequested(accountId) {
|
||||
holdingPicker.setSelection(accountId, false)
|
||||
holdingPicker.reconcileSelection()
|
||||
}
|
||||
}
|
||||
|
||||
AmmTokenAccessory {
|
||||
Layout.fillWidth: true
|
||||
theme: root.theme
|
||||
enabled: root.tokenSelectionEnabled
|
||||
invalid: root.tokenInvalid
|
||||
hasToken: root.tokenData !== null
|
||||
tokenColor: root.tokenColor(root.tokenData)
|
||||
tokenLetter: root.tokenLetter(root.tokenData)
|
||||
tokenText: root.tokenText(root.tokenData)
|
||||
balance: root.balance
|
||||
accessibleName: qsTr("Select %1").arg(root.label)
|
||||
onClicked: tokenModal.open()
|
||||
}
|
||||
|
||||
ProgramAccountSelector {
|
||||
id: holdingPicker
|
||||
|
||||
Layout.fillWidth: true
|
||||
sourceModel: root.programAccounts
|
||||
accountType: "TokenHolding"
|
||||
stateField: "definitionId"
|
||||
stateValue: root.tokenData
|
||||
? String(root.tokenData.definitionIdHex
|
||||
|| root.tokenData.definitionId || "") : ""
|
||||
selectionMode: ProgramAccountSelector.Input
|
||||
placeholderText: qsTr("Select source")
|
||||
accessibleName: qsTr("Source TokenHolding for %1").arg(root.label)
|
||||
backgroundColor: root.theme.colors.panelBg
|
||||
hoverColor: root.theme.colors.panelHoverBg
|
||||
textColor: root.theme.colors.textPrimary
|
||||
secondaryTextColor: root.theme.colors.textSecondary
|
||||
borderColor: root.theme.colors.borderStrong
|
||||
focusColor: root.theme.colors.ctaBg
|
||||
onSelectionChanged: function(accountId, createNew) {
|
||||
root.holdingSelectionChanged(accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +186,10 @@ AmmTokenAmountSurface {
|
||||
tokenModal.acceptInput(value)
|
||||
}
|
||||
|
||||
function setHoldingSelection(accountId) {
|
||||
root.holdingSelectionRequested(accountId)
|
||||
}
|
||||
|
||||
function commitPendingEdit() {
|
||||
if (!root.editPending)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
import Logos.Wallet
|
||||
import "../shared"
|
||||
import "../../state"
|
||||
|
||||
@@ -14,6 +15,7 @@ Rectangle {
|
||||
|
||||
property var theme
|
||||
property var tokens: []
|
||||
property var programAccounts: []
|
||||
// Real backend replica (logos.module("amm_ui")), wired from SwapPage.
|
||||
property var backend: null
|
||||
|
||||
@@ -389,10 +391,16 @@ Rectangle {
|
||||
&& root.poolResolved && root.poolExists
|
||||
&& !outputExceedsLiquidity && !root.swapInProgress
|
||||
&& !root.quoteLoading && root.walletOpen
|
||||
&& sellAmountInput.holdingReady
|
||||
&& buyAmountInput.holdingReady
|
||||
|
||||
readonly property string submitButtonText: {
|
||||
if (!tokensSelected) return qsTr("Select tokens")
|
||||
if (root.swapInProgress) return qsTr("Submitting…")
|
||||
if (root.sellToken && !sellAmountInput.hasHoldingFunds) return qsTr("No funds")
|
||||
if (root.sellToken && !sellAmountInput.holdingReady) return qsTr("Select source holding")
|
||||
if (root.buyToken && !buyAmountInput.holdingReady) return qsTr("Select destination")
|
||||
if (root.backend && !root.backend.isWalletOpen) return qsTr("Connect wallet")
|
||||
if (!hasAmount) return qsTr("Enter an amount")
|
||||
if (root.poolLoading || !root.poolResolved) return qsTr("Resolving pool…")
|
||||
if (!root.poolExists) return qsTr("No pool / no liquidity")
|
||||
@@ -442,7 +450,10 @@ Rectangle {
|
||||
"priceImpactPercentValue": priceImpactPercent,
|
||||
"slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent),
|
||||
"swapMode": isExactIn ? "swap-exact-input" : "swap-exact-output",
|
||||
"swapModeText": swapModeText
|
||||
"swapModeText": swapModeText,
|
||||
"inputHoldingId": sellAmountInput.selectedHoldingId,
|
||||
"outputHoldingId": buyAmountInput.selectedHoldingId,
|
||||
"createOutputHolding": buyAmountInput.createNewHolding
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,15 +464,36 @@ Rectangle {
|
||||
if (!root.backend || !root.canSubmit)
|
||||
return
|
||||
|
||||
root.swapInProgress = true
|
||||
root.swapError = ""
|
||||
|
||||
// Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep.
|
||||
var deadline = "18446744073709551615"
|
||||
var inDef = root.sellToken.definitionId
|
||||
var outDef = root.buyToken.definitionId
|
||||
var inHolding = root.sellToken.holding
|
||||
var outHolding = root.buyToken.holding
|
||||
var inHolding = sellAmountInput.selectedHoldingId
|
||||
var outHolding = buyAmountInput.selectedHoldingId
|
||||
|
||||
if (buyAmountInput.createNewHolding) {
|
||||
root.swapInProgress = true
|
||||
root.swapError = ""
|
||||
logos.watch(root.backend.createAccountPublic(),
|
||||
function(accountId) {
|
||||
if (!accountId) {
|
||||
root.failSwap(qsTr("Could not create a destination TokenHolding."))
|
||||
return
|
||||
}
|
||||
root.submitSwap(inDef, outDef, inHolding, String(accountId), deadline)
|
||||
},
|
||||
function(error) {
|
||||
root.failSwap(qsTr("Could not create a destination TokenHolding: %1").arg(error))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
root.submitSwap(inDef, outDef, inHolding, outHolding, deadline)
|
||||
}
|
||||
|
||||
function submitSwap(inDef, outDef, inHolding, outHolding, deadline) {
|
||||
root.swapInProgress = true
|
||||
root.swapError = ""
|
||||
|
||||
// The on-chain guard is the quote's exact-integer bound: the exact-input
|
||||
// floor (minReceivedRaw) or the exact-output ceiling (maxInRaw). The typed
|
||||
@@ -483,19 +515,27 @@ Rectangle {
|
||||
})
|
||||
root.resetAmounts()
|
||||
resolveDebounce.restart()
|
||||
logos.watch(root.backend.refreshNewPositionContext({
|
||||
"refreshWalletAccounts": true
|
||||
}), function() {}, function(error) {
|
||||
console.warn("wallet holding refresh error:", error)
|
||||
})
|
||||
} else {
|
||||
root.swapError = qsTr("Swap failed (empty response from sequencer).")
|
||||
root.swapFailed(root.swapError)
|
||||
root.failSwap(qsTr("Swap failed (empty response from sequencer)."))
|
||||
}
|
||||
},
|
||||
function (error) {
|
||||
console.warn("swap error:", error)
|
||||
root.swapInProgress = false
|
||||
root.swapError = qsTr("Swap error: %1").arg(error)
|
||||
root.swapFailed(root.swapError)
|
||||
root.failSwap(qsTr("Swap error: %1").arg(error))
|
||||
})
|
||||
}
|
||||
|
||||
function failSwap(message) {
|
||||
root.swapInProgress = false
|
||||
root.swapError = message
|
||||
root.swapFailed(message)
|
||||
}
|
||||
|
||||
radius: 24
|
||||
color: theme.colors.cardBg
|
||||
border.color: theme.colors.border
|
||||
@@ -514,6 +554,8 @@ Rectangle {
|
||||
spacing: 0
|
||||
|
||||
TokenInput {
|
||||
id: sellAmountInput
|
||||
|
||||
Layout.fillWidth: true
|
||||
theme: root.theme
|
||||
label: "Sell"
|
||||
@@ -521,6 +563,8 @@ Rectangle {
|
||||
buttonObjectName: "swapSellTokenButton"
|
||||
amount: root.sellDisplay
|
||||
token: root.sellToken
|
||||
programAccounts: root.programAccounts
|
||||
holdingSelectionMode: ProgramAccountSelector.Input
|
||||
active: root.editingSide === "sell"
|
||||
// Sell amount is sent to the backend as a raw base-units integer
|
||||
// string; reject fractional entry rather than fail opaquely.
|
||||
@@ -574,6 +618,8 @@ Rectangle {
|
||||
}
|
||||
|
||||
TokenInput {
|
||||
id: buyAmountInput
|
||||
|
||||
Layout.fillWidth: true
|
||||
theme: root.theme
|
||||
label: "Buy"
|
||||
@@ -581,6 +627,8 @@ Rectangle {
|
||||
buttonObjectName: "swapBuyTokenButton"
|
||||
amount: root.buyDisplay
|
||||
token: root.buyToken
|
||||
programAccounts: root.programAccounts
|
||||
holdingSelectionMode: ProgramAccountSelector.Output
|
||||
active: root.editingSide === "buy"
|
||||
// Exact-output amount is sent to the backend as a raw base-units
|
||||
// integer string; reject fractional entry rather than fail opaquely.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Layouts 1.15
|
||||
import Logos.Wallet
|
||||
import "TokenVisuals.js" as TokenVisuals
|
||||
|
||||
Rectangle {
|
||||
@@ -10,6 +11,8 @@ Rectangle {
|
||||
property string amount: ""
|
||||
property string usdValue: ""
|
||||
property var token: null
|
||||
property var programAccounts: []
|
||||
property int holdingSelectionMode: ProgramAccountSelector.Input
|
||||
property bool active: true
|
||||
// When true, restrict input to digits only — used for the sell-amount
|
||||
// field, whose value is sent to the backend as a raw base-units integer
|
||||
@@ -25,6 +28,13 @@ Rectangle {
|
||||
|
||||
signal tokenClicked()
|
||||
signal inputEdited(string newValue)
|
||||
signal holdingSelectionChanged(string accountId, bool createNew)
|
||||
|
||||
property alias selectedHoldingId: holdingSelector.selectedAccountId
|
||||
property alias createNewHolding: holdingSelector.createNewSelected
|
||||
readonly property bool holdingReady: holdingSelector.ready
|
||||
readonly property bool hasHoldingFunds: holdingSelector.hasFunds
|
||||
readonly property var selectedHolding: holdingSelector.selectedAccount
|
||||
|
||||
Binding {
|
||||
target: tiInput
|
||||
@@ -104,13 +114,17 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: tokenButton
|
||||
height: 40
|
||||
radius: 20
|
||||
color: tokenBtnHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg
|
||||
implicitWidth: tokenBtnRow.implicitWidth + 24
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
ColumnLayout {
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
id: tokenButton
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.preferredHeight: 40
|
||||
radius: 20
|
||||
color: tokenBtnHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg
|
||||
implicitWidth: tokenBtnRow.implicitWidth + 24
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
|
||||
RowLayout {
|
||||
id: tokenBtnRow
|
||||
@@ -144,12 +158,42 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: tokenBtnHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.tokenClicked()
|
||||
MouseArea {
|
||||
id: tokenBtnHover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.tokenClicked()
|
||||
}
|
||||
}
|
||||
|
||||
ProgramAccountSelector {
|
||||
id: holdingSelector
|
||||
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.preferredWidth: 190
|
||||
sourceModel: root.programAccounts
|
||||
accountType: "TokenHolding"
|
||||
stateField: "definitionId"
|
||||
stateValue: root.token
|
||||
? String(root.token.definitionIdHex
|
||||
|| root.token.definitionId || "") : ""
|
||||
selectionMode: root.holdingSelectionMode
|
||||
createNewText: qsTr("Create new TokenHolding")
|
||||
placeholderText: root.holdingSelectionMode === ProgramAccountSelector.Input
|
||||
? qsTr("Select source") : qsTr("Select destination")
|
||||
accessibleName: root.holdingSelectionMode === ProgramAccountSelector.Input
|
||||
? qsTr("Source TokenHolding for %1").arg(root.label)
|
||||
: qsTr("Destination TokenHolding for %1").arg(root.label)
|
||||
backgroundColor: theme.colors.panelBg
|
||||
hoverColor: theme.colors.panelHoverBg
|
||||
textColor: theme.colors.textPrimary
|
||||
secondaryTextColor: theme.colors.textSecondary
|
||||
borderColor: theme.colors.borderStrong
|
||||
focusColor: theme.colors.ctaBg
|
||||
onSelectionChanged: function(accountId, createNew) {
|
||||
root.holdingSelectionChanged(accountId, createNew)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// Shared derivation helpers for a token's display avatar (color + letter).
|
||||
// The real token config (see AmmUiBackend::tokenList / TOKENS_CONFIG) only
|
||||
// carries symbol/name/definitionId/holding/decimals — no color/letter — so
|
||||
// carries symbol/name/definitionId/decimals — no color/letter — so
|
||||
// every place that used to read token.color/token.letter derives them here
|
||||
// instead, deterministically from the token's symbol.
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ Item {
|
||||
// reads the TOKENS_CONFIG JSON file — see apps/amm/README.md). Empty
|
||||
// until the backend is ready and the call resolves.
|
||||
property var tokens: []
|
||||
readonly property var programAccounts: root.backend
|
||||
&& root.backend.newPositionContext
|
||||
&& root.backend.newPositionContext.programAccounts
|
||||
? root.backend.newPositionContext.programAccounts : []
|
||||
|
||||
onBackendChanged: {
|
||||
if (root.backend) {
|
||||
@@ -104,6 +108,7 @@ Item {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
theme: pageTheme
|
||||
tokens: root.tokens
|
||||
programAccounts: root.programAccounts
|
||||
backend: root.backend
|
||||
width: Math.min(480, root.width - 32)
|
||||
|
||||
|
||||
@@ -88,8 +88,8 @@ class AmmUiBackend
|
||||
// Same return contract as swapExactInput (tx hash, empty string on failure).
|
||||
SLOT(QString swapExactOutput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountOutDecimal, QString maxInDecimal, QString deadlineDecimal))
|
||||
// Reads the token list config at TOKENS_CONFIG (absolute path, JSON array
|
||||
// of { symbol, name, definitionId, holding, decimals }) and returns it as
|
||||
// a QVariantList of QVariantMap entries. Returns an empty list if
|
||||
// TOKENS_CONFIG is unset/unreadable/invalid.
|
||||
// of { symbol, name, definitionId, decimals }). A legacy holding field is
|
||||
// accepted but transaction inputs come from wallet-owned TokenHoldings.
|
||||
// Returns an empty list if TOKENS_CONFIG is unset/unreadable/invalid.
|
||||
SLOT(QVariantList tokenList())
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ TestCase {
|
||||
readonly property string tokenLow: "22222222222222222222222222222222"
|
||||
readonly property string tokenHigh: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
|
||||
readonly property string tokenThird: "33333333333333333333333333333333"
|
||||
readonly property string holdingLow: "holding-low"
|
||||
readonly property string holdingHigh: "holding-high"
|
||||
readonly property string submittedTransactionId:
|
||||
"1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"
|
||||
|
||||
@@ -43,6 +45,7 @@ TestCase {
|
||||
"tokens": [
|
||||
{
|
||||
"definitionId": tokenLow,
|
||||
"definitionIdHex": tokenLow,
|
||||
"name": "Low",
|
||||
"totalSupplyRaw": "1000000",
|
||||
"balanceRaw": "1000",
|
||||
@@ -50,11 +53,16 @@ TestCase {
|
||||
},
|
||||
{
|
||||
"definitionId": tokenHigh,
|
||||
"definitionIdHex": tokenHigh,
|
||||
"name": "High",
|
||||
"totalSupplyRaw": "1000000000000",
|
||||
"balanceRaw": "5000000000",
|
||||
"selectable": true
|
||||
}
|
||||
],
|
||||
"programAccounts": [
|
||||
programAccount(holdingLow, tokenLow, "1000"),
|
||||
programAccount(holdingHigh, tokenHigh, "5000000000")
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -65,6 +73,7 @@ TestCase {
|
||||
"tokens": [
|
||||
{
|
||||
"definitionId": tokenLow,
|
||||
"definitionIdHex": tokenLow,
|
||||
"name": "Sir Mints-a-Lot",
|
||||
"totalSupplyRaw": "1000000000000",
|
||||
"balanceRaw": "1000000000",
|
||||
@@ -72,15 +81,29 @@ TestCase {
|
||||
},
|
||||
{
|
||||
"definitionId": tokenHigh,
|
||||
"definitionIdHex": tokenHigh,
|
||||
"name": "Aurora",
|
||||
"totalSupplyRaw": "1000000000000",
|
||||
"balanceRaw": "1000000000",
|
||||
"selectable": true
|
||||
}
|
||||
],
|
||||
"programAccounts": [
|
||||
programAccount(holdingLow, tokenLow, "1000000000"),
|
||||
programAccount(holdingHigh, tokenHigh, "1000000000")
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function programAccount(accountId, definitionId, balanceRaw) {
|
||||
return {
|
||||
"accountId": accountId,
|
||||
"accountType": "TokenHolding",
|
||||
"definitionId": definitionId,
|
||||
"balanceRaw": balanceRaw
|
||||
}
|
||||
}
|
||||
|
||||
function flowState(quote) {
|
||||
return {
|
||||
"quote": quote || ({}),
|
||||
@@ -107,6 +130,8 @@ TestCase {
|
||||
form.selectToken("B", tokenHigh)
|
||||
compare(form.selectedTokenAId, tokenLow)
|
||||
compare(form.selectedTokenBId, tokenHigh)
|
||||
tryCompare(form, "selectedHoldingAId", holdingLow)
|
||||
tryCompare(form, "selectedHoldingBId", holdingHigh)
|
||||
return form
|
||||
}
|
||||
|
||||
@@ -124,14 +149,20 @@ TestCase {
|
||||
verify(built.ok)
|
||||
compare(built.request.tokenAId, tokenHigh)
|
||||
compare(built.request.tokenBId, tokenLow)
|
||||
compare(built.request.holdingAId, holdingHigh)
|
||||
compare(built.request.holdingBId, holdingLow)
|
||||
|
||||
form.swapTokens()
|
||||
tryCompare(form, "selectedHoldingAId", holdingHigh)
|
||||
tryCompare(form, "selectedHoldingBId", holdingLow)
|
||||
compare(form.selectedTokenAId, tokenHigh)
|
||||
compare(form.selectedTokenBId, tokenLow)
|
||||
built = form.buildQuoteRequest()
|
||||
verify(built.ok)
|
||||
compare(built.request.tokenAId, tokenHigh)
|
||||
compare(built.request.tokenBId, tokenLow)
|
||||
compare(built.request.holdingAId, holdingHigh)
|
||||
compare(built.request.holdingBId, holdingLow)
|
||||
}
|
||||
|
||||
function test_tokenAmountsUseRawUnits() {
|
||||
|
||||
@@ -95,6 +95,61 @@ TestCase {
|
||||
compare(input.accessoryWidth, 180)
|
||||
}
|
||||
|
||||
function test_singleMatchingHoldingAutoSelects() {
|
||||
var input = createTemporaryObject(inputComponent, testCase, {
|
||||
"tokenData": {
|
||||
"definitionId": enabledId,
|
||||
"definitionIdHex": enabledId,
|
||||
"name": "Enabled"
|
||||
},
|
||||
"programAccounts": [{
|
||||
"accountId": "holding-enabled",
|
||||
"accountType": "TokenHolding",
|
||||
"definitionId": enabledId,
|
||||
"balanceRaw": "42"
|
||||
}]
|
||||
})
|
||||
verify(input)
|
||||
|
||||
tryCompare(input, "selectedHoldingId", "holding-enabled")
|
||||
compare(input.holdingReady, true)
|
||||
compare(input.hasHoldingFunds, true)
|
||||
compare(input.selectedHolding.balanceRaw, "42")
|
||||
}
|
||||
|
||||
function test_multipleMatchingHoldingsRequireSelection() {
|
||||
var input = createTemporaryObject(inputComponent, testCase, {
|
||||
"tokenData": {
|
||||
"definitionId": enabledId,
|
||||
"definitionIdHex": enabledId,
|
||||
"name": "Enabled"
|
||||
},
|
||||
"programAccounts": [
|
||||
{
|
||||
"accountId": "holding-a",
|
||||
"accountType": "TokenHolding",
|
||||
"definitionId": enabledId,
|
||||
"balanceRaw": "42"
|
||||
},
|
||||
{
|
||||
"accountId": "holding-b",
|
||||
"accountType": "TokenHolding",
|
||||
"definitionId": enabledId,
|
||||
"balanceRaw": "21"
|
||||
}
|
||||
]
|
||||
})
|
||||
verify(input)
|
||||
|
||||
tryCompare(input, "hasHoldingFunds", true)
|
||||
compare(input.holdingReady, false)
|
||||
compare(input.selectedHoldingId, "")
|
||||
|
||||
input.setHoldingSelection("holding-b")
|
||||
compare(input.selectedHoldingId, "holding-b")
|
||||
compare(input.holdingReady, true)
|
||||
}
|
||||
|
||||
function test_disabledTokenIsRejectedByTypedInput() {
|
||||
var input = createTemporaryObject(inputComponent, testCase)
|
||||
verify(input)
|
||||
|
||||
@@ -67,6 +67,7 @@ if(LOGOS_WALLET_BUILD_QML)
|
||||
)
|
||||
set(wallet_public_qml
|
||||
qml/WalletControl.qml
|
||||
qml/ProgramAccountSelector.qml
|
||||
qml/TransactionConfirmationDialog.qml
|
||||
qml/SubmittedTransaction.qml
|
||||
)
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
enum SelectionMode {
|
||||
Input,
|
||||
Output
|
||||
}
|
||||
|
||||
property var sourceModel: []
|
||||
property string accountType: ""
|
||||
property string stateField: ""
|
||||
property var stateValue: ""
|
||||
property int selectionMode: ProgramAccountSelector.Input
|
||||
property string selectedAccountId: ""
|
||||
property bool createNewSelected: false
|
||||
property string createNewText: qsTr("Create new account")
|
||||
property string emptyInputText: qsTr("No funds")
|
||||
property string placeholderText: qsTr("Select account")
|
||||
property string criteriaPendingText: qsTr("Select token first")
|
||||
property string accessibleName: qsTr("Program account")
|
||||
property color backgroundColor: "#27272a"
|
||||
property color hoverColor: "#3f3f46"
|
||||
property color textColor: "#f4f4f5"
|
||||
property color secondaryTextColor: "#a1a1aa"
|
||||
property color borderColor: "#52525b"
|
||||
property color focusColor: "#f26a21"
|
||||
property int modelRevision: 0
|
||||
|
||||
readonly property bool criteriaReady: root.accountType.length > 0
|
||||
&& (root.stateField.length === 0
|
||||
|| root.scalarText(root.stateValue).length > 0)
|
||||
readonly property var matchingAccounts: root.filteredAccounts()
|
||||
readonly property var choices: root.choiceRows()
|
||||
readonly property bool hasFunds: root.matchingAccounts.length > 0
|
||||
readonly property bool selectionValid: root.accountById(root.selectedAccountId) !== null
|
||||
readonly property bool ready: root.criteriaReady
|
||||
&& (root.selectionValid
|
||||
|| (root.selectionMode === ProgramAccountSelector.Output
|
||||
&& root.createNewSelected))
|
||||
readonly property var selectedAccount: root.accountById(root.selectedAccountId)
|
||||
readonly property string selectedBalanceRaw: root.selectedAccount
|
||||
? String(root.valueFor(
|
||||
root.selectedAccount,
|
||||
"balanceRaw") || "0")
|
||||
: "0"
|
||||
readonly property bool showCombo: root.selectionMode === ProgramAccountSelector.Output
|
||||
|| (root.criteriaReady
|
||||
&& root.matchingAccounts.length > 1)
|
||||
readonly property bool showEmptyInput: root.selectionMode === ProgramAccountSelector.Input
|
||||
&& root.criteriaReady
|
||||
&& root.matchingAccounts.length === 0
|
||||
|
||||
signal selectionChanged(string accountId, bool createNew)
|
||||
|
||||
implicitWidth: 220
|
||||
implicitHeight: root.showCombo ? 34 : root.showEmptyInput ? 20 : 0
|
||||
visible: implicitHeight > 0
|
||||
|
||||
Instantiator {
|
||||
id: rows
|
||||
|
||||
model: root.sourceModel
|
||||
delegate: QtObject {
|
||||
required property var model
|
||||
required property var modelData
|
||||
|
||||
readonly property var accountRow: {
|
||||
if (modelData !== null && typeof modelData === "object") {
|
||||
return modelData
|
||||
}
|
||||
if (model === null || typeof model !== "object")
|
||||
return ({})
|
||||
return {
|
||||
"accountId": model.accountId,
|
||||
"address": model.address,
|
||||
"displayAddress": model.displayAddress,
|
||||
"accountType": model.accountType,
|
||||
"definitionId": model.definitionId,
|
||||
"balanceRaw": model.balanceRaw,
|
||||
"state": model.state
|
||||
}
|
||||
}
|
||||
}
|
||||
onObjectAdded: function(index, object) {
|
||||
++root.modelRevision
|
||||
Qt.callLater(root.reconcileSelection)
|
||||
}
|
||||
onObjectRemoved: function(index, object) {
|
||||
++root.modelRevision
|
||||
Qt.callLater(root.reconcileSelection)
|
||||
}
|
||||
}
|
||||
|
||||
onSourceModelChanged: Qt.callLater(root.reconcileSelection)
|
||||
onAccountTypeChanged: Qt.callLater(root.reconcileSelection)
|
||||
onStateFieldChanged: Qt.callLater(root.reconcileSelection)
|
||||
onStateValueChanged: Qt.callLater(root.reconcileSelection)
|
||||
onSelectionModeChanged: Qt.callLater(root.reconcileSelection)
|
||||
Component.onCompleted: Qt.callLater(root.reconcileSelection)
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: root.showEmptyInput
|
||||
text: root.emptyInputText
|
||||
color: root.secondaryTextColor
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Accessible.role: Accessible.StaticText
|
||||
Accessible.name: text
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
id: accountCombo
|
||||
|
||||
objectName: "programAccountComboBox"
|
||||
anchors.fill: parent
|
||||
visible: root.showCombo
|
||||
enabled: root.enabled && root.criteriaReady && root.choices.length > 0
|
||||
model: root.choices
|
||||
currentIndex: root.choiceIndex()
|
||||
displayText: root.displayLabel()
|
||||
leftPadding: 10
|
||||
rightPadding: 28
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
hoverEnabled: true
|
||||
activeFocusOnTab: true
|
||||
focusPolicy: Qt.StrongFocus
|
||||
Accessible.name: root.accessibleName
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: accountCombo.leftPadding
|
||||
rightPadding: accountCombo.rightPadding
|
||||
text: accountCombo.displayText
|
||||
color: accountCombo.enabled ? root.textColor : root.secondaryTextColor
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
indicator: Text {
|
||||
x: accountCombo.width - width - 10
|
||||
y: Math.round((accountCombo.height - height) / 2)
|
||||
text: "\u25BE"
|
||||
color: accountCombo.enabled ? root.secondaryTextColor : root.borderColor
|
||||
font.pixelSize: 10
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 7
|
||||
color: !accountCombo.enabled
|
||||
? root.backgroundColor
|
||||
: accountCombo.down || accountCombo.hovered
|
||||
? root.hoverColor : root.backgroundColor
|
||||
border.color: accountCombo.activeFocus ? root.focusColor : root.borderColor
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: optionDelegate
|
||||
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
width: ListView.view ? ListView.view.width : accountCombo.width
|
||||
height: 34
|
||||
hoverEnabled: true
|
||||
highlighted: accountCombo.highlightedIndex === optionDelegate.index
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: 8
|
||||
rightPadding: 8
|
||||
text: root.labelFor(optionDelegate.modelData)
|
||||
color: root.textColor
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 5
|
||||
color: optionDelegate.highlighted || optionDelegate.hovered
|
||||
? root.hoverColor : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: accountCombo.height + 4
|
||||
width: accountCombo.width
|
||||
implicitHeight: Math.min(contentItem.implicitHeight + topPadding + bottomPadding,
|
||||
204)
|
||||
padding: 4
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: accountCombo.delegateModel
|
||||
currentIndex: accountCombo.highlightedIndex
|
||||
highlightMoveDuration: 0
|
||||
ScrollIndicator.vertical: ScrollIndicator { }
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 7
|
||||
color: root.backgroundColor
|
||||
border.color: root.borderColor
|
||||
border.width: 1
|
||||
}
|
||||
}
|
||||
|
||||
onActivated: function(index) {
|
||||
const choice = root.choices[index]
|
||||
if (!choice)
|
||||
return
|
||||
root.setSelection(choice.createNew === true
|
||||
? "" : root.accountIdFor(choice),
|
||||
choice.createNew === true)
|
||||
}
|
||||
}
|
||||
|
||||
function filteredAccounts() {
|
||||
root.modelRevision
|
||||
if (!root.criteriaReady)
|
||||
return []
|
||||
const result = []
|
||||
for (let index = 0; index < rows.count; ++index) {
|
||||
const object = rows.objectAt(index)
|
||||
const row = object ? root.valueFor(object, "accountRow") : null
|
||||
if (!row)
|
||||
continue
|
||||
const type = String(root.valueFor(row, "accountType")
|
||||
|| root.valueFor(row, "typeName")
|
||||
|| root.valueFor(row, "programType") || "")
|
||||
if (type !== root.accountType)
|
||||
continue
|
||||
if (root.stateField.length > 0
|
||||
&& root.scalarText(root.valueFor(row, root.stateField))
|
||||
!== root.scalarText(root.stateValue)) {
|
||||
continue
|
||||
}
|
||||
if (root.accountIdFor(row).length > 0)
|
||||
result.push(row)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function valueFor(row, field) {
|
||||
if (!row)
|
||||
return undefined
|
||||
if (row[field] !== undefined)
|
||||
return row[field]
|
||||
if (row.state && row.state[field] !== undefined)
|
||||
return row.state[field]
|
||||
if (row.fields && row.fields[field] !== undefined)
|
||||
return row.fields[field]
|
||||
return undefined
|
||||
}
|
||||
|
||||
function scalarText(value) {
|
||||
return value === undefined || value === null ? "" : String(value)
|
||||
}
|
||||
|
||||
function accountIdFor(row) {
|
||||
return String(root.valueFor(row, "accountId")
|
||||
|| root.valueFor(row, "displayAddress")
|
||||
|| root.valueFor(row, "address")
|
||||
|| root.valueFor(row, "holdingId") || "")
|
||||
}
|
||||
|
||||
function accountById(accountId) {
|
||||
const value = String(accountId || "")
|
||||
if (value.length === 0)
|
||||
return null
|
||||
for (let index = 0; index < root.matchingAccounts.length; ++index) {
|
||||
if (root.accountIdFor(root.matchingAccounts[index]) === value)
|
||||
return root.matchingAccounts[index]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function choiceRows() {
|
||||
const result = root.matchingAccounts.slice(0)
|
||||
if (root.selectionMode === ProgramAccountSelector.Output)
|
||||
result.push({ "createNew": true })
|
||||
return result
|
||||
}
|
||||
|
||||
function choiceIndex() {
|
||||
if (root.createNewSelected)
|
||||
return root.choices.length - 1
|
||||
for (let index = 0; index < root.choices.length; ++index) {
|
||||
if (root.accountIdFor(root.choices[index]) === root.selectedAccountId)
|
||||
return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function displayLabel() {
|
||||
const index = root.choiceIndex()
|
||||
if (index >= 0)
|
||||
return root.labelFor(root.choices[index])
|
||||
if (!root.criteriaReady)
|
||||
return root.criteriaPendingText
|
||||
return root.placeholderText
|
||||
}
|
||||
|
||||
function labelFor(row) {
|
||||
if (row && row.createNew === true)
|
||||
return root.createNewText
|
||||
const id = root.accountIdFor(row)
|
||||
const balance = String(root.valueFor(row, "balanceRaw") || "")
|
||||
return balance.length > 0
|
||||
? qsTr("%1 · %2").arg(root.shortId(id)).arg(balance)
|
||||
: root.shortId(id)
|
||||
}
|
||||
|
||||
function shortId(value) {
|
||||
const text = String(value || "")
|
||||
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
|
||||
}
|
||||
|
||||
function setSelection(accountId, createNew) {
|
||||
const nextId = String(accountId || "")
|
||||
const nextCreate = createNew === true
|
||||
if (root.selectedAccountId === nextId && root.createNewSelected === nextCreate)
|
||||
return
|
||||
root.selectedAccountId = nextId
|
||||
root.createNewSelected = nextCreate
|
||||
root.selectionChanged(nextId, nextCreate)
|
||||
}
|
||||
|
||||
function reconcileSelection() {
|
||||
if (!root.criteriaReady) {
|
||||
root.setSelection("", false)
|
||||
return
|
||||
}
|
||||
if (root.selectionValid)
|
||||
return
|
||||
if (root.selectionMode === ProgramAccountSelector.Input) {
|
||||
root.setSelection(root.matchingAccounts.length === 1
|
||||
? root.accountIdFor(root.matchingAccounts[0]) : "",
|
||||
false)
|
||||
return
|
||||
}
|
||||
if (root.createNewSelected)
|
||||
return
|
||||
if (root.matchingAccounts.length === 0) {
|
||||
root.setSelection("", true)
|
||||
} else if (root.matchingAccounts.length === 1) {
|
||||
root.setSelection(root.accountIdFor(root.matchingAccounts[0]), false)
|
||||
} else {
|
||||
root.setSelection("", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import Logos.Wallet as Wallet
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
width: 480
|
||||
height: 320
|
||||
|
||||
readonly property var holdingA: ({
|
||||
"accountId": "holding-a",
|
||||
"accountType": "TokenHolding",
|
||||
"definitionId": "token-a",
|
||||
"balanceRaw": "120"
|
||||
})
|
||||
readonly property var holdingB: ({
|
||||
"accountId": "holding-b",
|
||||
"accountType": "TokenHolding",
|
||||
"state": {
|
||||
"definitionId": "token-a",
|
||||
"balanceRaw": "80"
|
||||
}
|
||||
})
|
||||
readonly property var otherToken: ({
|
||||
"accountId": "holding-c",
|
||||
"accountType": "TokenHolding",
|
||||
"definitionId": "token-b",
|
||||
"balanceRaw": "50"
|
||||
})
|
||||
readonly property var otherType: ({
|
||||
"accountId": "pool-a",
|
||||
"accountType": "Pool",
|
||||
"definitionId": "token-a"
|
||||
})
|
||||
|
||||
Component {
|
||||
id: selectorComponent
|
||||
|
||||
Wallet.ProgramAccountSelector {
|
||||
width: 260
|
||||
accountType: "TokenHolding"
|
||||
stateField: "definitionId"
|
||||
stateValue: "token-a"
|
||||
}
|
||||
}
|
||||
|
||||
TestCase {
|
||||
name: "ProgramAccountSelector"
|
||||
when: windowShown
|
||||
|
||||
function test_inputNoFunds() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.otherToken, root.otherType],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Input
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "showEmptyInput", true)
|
||||
compare(selector.showCombo, false)
|
||||
compare(selector.hasFunds, false)
|
||||
compare(selector.ready, false)
|
||||
compare(selector.selectedAccountId, "")
|
||||
}
|
||||
|
||||
function test_inputSingleHoldingAutoSelectsWithoutCombo() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.holdingA, root.otherToken],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Input
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "selectedAccountId", "holding-a")
|
||||
compare(selector.showCombo, false)
|
||||
compare(selector.hasFunds, true)
|
||||
compare(selector.ready, true)
|
||||
compare(selector.selectedBalanceRaw, "120")
|
||||
}
|
||||
|
||||
function test_inputMultipleHoldingsRequiresSelection() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.holdingA, root.holdingB],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Input
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "showCombo", true)
|
||||
compare(selector.matchingAccounts.length, 2)
|
||||
compare(selector.ready, false)
|
||||
|
||||
selector.setSelection("holding-b", false)
|
||||
compare(selector.selectedAccountId, "holding-b")
|
||||
compare(selector.selectedBalanceRaw, "80")
|
||||
compare(selector.ready, true)
|
||||
}
|
||||
|
||||
function test_outputNoHoldingSelectsCreateNew() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Output
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "createNewSelected", true)
|
||||
compare(selector.showCombo, true)
|
||||
compare(selector.choices.length, 1)
|
||||
compare(selector.ready, true)
|
||||
}
|
||||
|
||||
function test_outputSingleHoldingOffersExistingAndCreateNew() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.holdingA],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Output
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "selectedAccountId", "holding-a")
|
||||
compare(selector.choices.length, 2)
|
||||
compare(selector.createNewSelected, false)
|
||||
compare(selector.ready, true)
|
||||
|
||||
selector.setSelection("", true)
|
||||
compare(selector.selectedAccountId, "")
|
||||
compare(selector.createNewSelected, true)
|
||||
compare(selector.ready, true)
|
||||
}
|
||||
|
||||
function test_outputMultipleHoldingsRequiresDestination() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.holdingA, root.holdingB],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Output
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "showCombo", true)
|
||||
compare(selector.choices.length, 3)
|
||||
compare(selector.selectedAccountId, "")
|
||||
compare(selector.createNewSelected, false)
|
||||
compare(selector.ready, false)
|
||||
}
|
||||
|
||||
function test_matchesNumericZeroState() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [{
|
||||
"accountId": "zero-state",
|
||||
"accountType": "TokenHolding",
|
||||
"state": { "version": 0 }
|
||||
}],
|
||||
"stateField": "version",
|
||||
"stateValue": 0,
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Input
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "selectedAccountId", "zero-state")
|
||||
compare(selector.ready, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user