diff --git a/apps/amm/qml/components/swap/SwapCard.qml b/apps/amm/qml/components/swap/SwapCard.qml index daf8c25..597a209 100644 --- a/apps/amm/qml/components/swap/SwapCard.qml +++ b/apps/amm/qml/components/swap/SwapCard.qml @@ -17,6 +17,12 @@ Rectangle { // Real backend replica (logos.module("amm_ui")), wired from SwapPage. property var backend: null + // The wallet's token holdings (backend.tokenHoldings()), fed to each slot's + // account selector; the chosen input/output holding ids drive the submit. + property var holdings: [] + readonly property string sellHolding: sellTokenInput.selectedHoldingId + readonly property string buyHolding: buyTokenInput.selectedHoldingId + property var sellToken: null property var buyToken: null property string sellInput: "" @@ -389,6 +395,10 @@ Rectangle { && root.poolResolved && root.poolExists && !outputExceedsLiquidity && !root.swapInProgress && !root.quoteLoading && root.walletOpen + // Both holdings must be chosen (auto-selected + // when the wallet has exactly one per token). + && root.sellHolding.length > 0 + && root.buyHolding.length > 0 readonly property string submitButtonText: { if (!tokensSelected) return qsTr("Select tokens") @@ -400,6 +410,8 @@ Rectangle { if (outputExceedsLiquidity) return qsTr("Insufficient liquidity") if (parsedSellAmount <= 0 || parsedBuyAmount <= 0) return qsTr("Amount too small") if (!root.walletOpen) return qsTr("Connect wallet to swap") + if (root.sellHolding.length === 0 || root.buyHolding.length === 0) + return qsTr("Select token accounts") return qsTr("Swap") } @@ -460,8 +472,9 @@ Rectangle { var deadline = "18446744073709551615" var inDef = root.sellToken.definitionId var outDef = root.buyToken.definitionId - var inHolding = root.sellToken.holding - var outHolding = root.buyToken.holding + // Holdings come from the per-slot account selector, not the token config. + var inHolding = root.sellHolding + var outHolding = root.buyHolding // The on-chain guard is the quote's exact-integer bound: the exact-input // floor (minReceivedRaw) or the exact-output ceiling (maxInRaw). The typed @@ -514,6 +527,7 @@ Rectangle { spacing: 0 TokenInput { + id: sellTokenInput Layout.fillWidth: true theme: root.theme label: "Sell" @@ -521,6 +535,7 @@ Rectangle { buttonObjectName: "swapSellTokenButton" amount: root.sellDisplay token: root.sellToken + holdings: root.holdings 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 +589,7 @@ Rectangle { } TokenInput { + id: buyTokenInput Layout.fillWidth: true theme: root.theme label: "Buy" @@ -581,6 +597,7 @@ Rectangle { buttonObjectName: "swapBuyTokenButton" amount: root.buyDisplay token: root.buyToken + holdings: root.holdings 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. diff --git a/apps/amm/qml/components/swap/TokenInput.qml b/apps/amm/qml/components/swap/TokenInput.qml index 8707da0..92cd55e 100644 --- a/apps/amm/qml/components/swap/TokenInput.qml +++ b/apps/amm/qml/components/swap/TokenInput.qml @@ -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,11 @@ Rectangle { property string amount: "" property string usdValue: "" property var token: null + // The wallet's token holdings (backend.tokenHoldings()); the selector narrows + // them to this slot's token. The chosen holding id is exposed as selectedHoldingId. + property var holdings: [] + readonly property string tokenDefinitionIdHex: root.token ? String(root.token.definitionId || "") : "" + readonly property string selectedHoldingId: accountSelector.selectedAccountId 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 @@ -34,123 +40,160 @@ Rectangle { radius: 16 color: root.active ? theme.colors.inputBg : theme.colors.panelBg - implicitHeight: 110 + // Height grows with the content: the amount/token row plus the full-width + // account selector below it (34/20/0px depending on how many holdings match). + implicitHeight: tiContent.implicitHeight + 28 Behavior on color { ColorAnimation { duration: 300 } } - RowLayout { - anchors.fill: parent + ColumnLayout { + id: tiContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top anchors.leftMargin: 16 anchors.rightMargin: 16 anchors.topMargin: 14 - anchors.bottomMargin: 14 - spacing: 8 + spacing: 10 - ColumnLayout { + RowLayout { Layout.fillWidth: true - spacing: 4 + spacing: 8 - Text { - text: root.label - color: theme.colors.textSecondary - font.pixelSize: 14 - } - - Item { + ColumnLayout { Layout.fillWidth: true - height: 44 + spacing: 4 - TextInput { - id: tiInput - anchors.fill: parent - color: root.active ? theme.colors.textPrimary : theme.colors.textSecondary - font.pixelSize: 36 - font.weight: Font.Bold - selectionColor: theme.colors.selection - clip: true - onTextEdited: { - if (root.digitsOnly) { - // Amounts are base units (integers) — strip any - // character that slips past the validator (e.g. - // via paste) before it reaches the backend. - var filtered = text.replace(/[^0-9]/g, "") - if (filtered !== text) - text = filtered // does not re-trigger onTextEdited - root.inputEdited(filtered) - } else { - root.inputEdited(text) + Text { + text: root.label + color: theme.colors.textSecondary + font.pixelSize: 14 + } + + Item { + Layout.fillWidth: true + height: 44 + + TextInput { + id: tiInput + anchors.fill: parent + color: root.active ? theme.colors.textPrimary : theme.colors.textSecondary + font.pixelSize: 36 + font.weight: Font.Bold + selectionColor: theme.colors.selection + clip: true + onTextEdited: { + if (root.digitsOnly) { + // Amounts are base units (integers) — strip any + // character that slips past the validator (e.g. + // via paste) before it reaches the backend. + var filtered = text.replace(/[^0-9]/g, "") + if (filtered !== text) + text = filtered // does not re-trigger onTextEdited + root.inputEdited(filtered) + } else { + root.inputEdited(text) + } + } + validator: RegularExpressionValidator { + regularExpression: root.digitsOnly ? /^[0-9]*$/ : /^[0-9]*\.?[0-9]*$/ } } - validator: RegularExpressionValidator { - regularExpression: root.digitsOnly ? /^[0-9]*$/ : /^[0-9]*\.?[0-9]*$/ + + Text { + anchors.fill: parent + text: "0" + color: theme.colors.textPlaceholder + font: tiInput.font + visible: tiInput.text === "" && !tiInput.activeFocus + verticalAlignment: Text.AlignVCenter } } Text { - anchors.fill: parent - text: "0" - color: theme.colors.textPlaceholder - font: tiInput.font - visible: tiInput.text === "" && !tiInput.activeFocus - verticalAlignment: Text.AlignVCenter + text: root.usdValue + color: theme.colors.textSecondary + font.pixelSize: 13 + visible: root.usdValue !== "" } } - Text { - text: root.usdValue - color: theme.colors.textSecondary - font.pixelSize: 13 - visible: root.usdValue !== "" + Rectangle { + id: tokenButton + Layout.alignment: Qt.AlignVCenter + height: 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 + anchors.centerIn: parent + spacing: 6 + + Rectangle { + width: 24; height: 24; radius: 12 + color: root.token ? TokenVisuals.colorFor(root.token.symbol) : theme.colors.noTokenCircle + visible: root.token !== null + Text { + anchors.centerIn: parent + text: root.token ? TokenVisuals.letterFor(root.token.symbol) : "" + color: "#ffffff" + font.pixelSize: 10 + font.weight: Font.Bold + } + } + + Text { + text: root.token ? root.token.symbol : "Select token" + color: theme.colors.textPrimary + font.pixelSize: 15 + font.weight: root.token ? Font.Medium : Font.Normal + } + + Text { + text: "▼" + color: theme.colors.textSecondary + font.pixelSize: 10 + } + } + + MouseArea { + id: tokenBtnHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.tokenClicked() + } } } - 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 } } - - RowLayout { - id: tokenBtnRow - anchors.centerIn: parent - spacing: 6 - - Rectangle { - width: 24; height: 24; radius: 12 - color: root.token ? TokenVisuals.colorFor(root.token.symbol) : theme.colors.noTokenCircle - visible: root.token !== null - Text { - anchors.centerIn: parent - text: root.token ? TokenVisuals.letterFor(root.token.symbol) : "" - color: "#ffffff" - font.pixelSize: 10 - font.weight: Font.Bold - } - } - - Text { - text: root.token ? root.token.symbol : "Select token" - color: theme.colors.textPrimary - font.pixelSize: 15 - font.weight: root.token ? Font.Medium : Font.Normal - } - - Text { - text: "▼" - color: theme.colors.textSecondary - font.pixelSize: 10 - } - } - - MouseArea { - id: tokenBtnHover - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.tokenClicked() - } + // Which of the user's holdings for this token to use, full width below the + // amount/token row. Always shown when there is at least one holding + // (auto-selecting the single one); "No funds" when there are none. + ProgramAccountSelector { + id: accountSelector + // Half width, pinned to the right edge under the token button. + Layout.alignment: Qt.AlignRight + Layout.preferredWidth: Math.round(tiContent.width / 2) + sourceModel: root.holdings + accountType: "TokenHolding" + // The swap view is hex end-to-end: the token's definitionId is hex, so match the + // holding's hex definitionIdHex (tokenHoldings emits both — `definitionId` is base58 + // for the liquidity view). Filtering on `definitionId` here never matches a hex + // stateValue, so every token would show "No funds". + stateField: "definitionIdHex" + stateValue: root.tokenDefinitionIdHex + selectionMode: ProgramAccountSelector.Input + showWhenSingle: true + textAlignment: Text.AlignRight + 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 } } } diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 8433d23..959d647 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -17,14 +17,48 @@ Item { // until the backend is ready and the call resolves. property var tokens: [] + // The wallet's token holdings (backend.tokenHoldings()), fed to the swap card's + // account selectors. Refetched when the wallet opens (it needs an open wallet). + property var holdings: [] + + // Monotonic tag for refreshHoldings() requests: a callback applies only if it is still the + // latest, so an out-of-order tokenHoldings reply can't clobber the current wallet's list. + property int holdingsGeneration: 0 + + function refreshHoldings() { + if (!root.backend) + return + // onBackendChanged and onIsWalletOpenChanged can start overlapping tokenHoldings + // requests (a wallet-open refresh racing a just-closed one, or a fast wallet switch) + // whose replies may arrive out of order. Tag each request and drop any callback a newer + // request has superseded, so a stale (empty, or previous-wallet) list can't overwrite + // the current holdings. + const generation = ++root.holdingsGeneration + logos.watch(root.backend.tokenHoldings(), + function(list) { + if (generation === root.holdingsGeneration) + root.holdings = list + }, + function(err) { + if (generation === root.holdingsGeneration) + console.warn("tokenHoldings error:", err) + }) + } + onBackendChanged: { if (root.backend) { logos.watch(root.backend.tokenList(), function(list) { root.tokens = list }, function(err) { console.warn("tokenList error:", err) }) + root.refreshHoldings() } } + Connections { + target: root.backend + function onIsWalletOpenChanged() { root.refreshHoldings() } + } + QtObject { id: pageTheme property bool isDark: true @@ -104,6 +138,7 @@ Item { Layout.alignment: Qt.AlignHCenter theme: pageTheme tokens: root.tokens + holdings: root.holdings backend: root.backend width: Math.min(480, root.width - 32) diff --git a/apps/shared/wallet/qml/ProgramAccountSelector.qml b/apps/shared/wallet/qml/ProgramAccountSelector.qml index 4fb7dd0..7c4e922 100644 --- a/apps/shared/wallet/qml/ProgramAccountSelector.qml +++ b/apps/shared/wallet/qml/ProgramAccountSelector.qml @@ -29,6 +29,13 @@ Item { property color secondaryTextColor: "#a1a1aa" property color borderColor: "#52525b" property color focusColor: "#f26a21" + // Show the combo even when only one account matches. By default a single match + // auto-selects and hides (no choice to make); consumers that want the chosen + // holding always visible set this true. + property bool showWhenSingle: false + // Horizontal alignment of the selector's text (closed combo, dropdown rows and + // the empty "No funds" label). Defaults to left; consumers can right-align it. + property int textAlignment: Text.AlignLeft property int modelRevision: 0 readonly property bool criteriaReady: root.accountType.length > 0 @@ -50,7 +57,8 @@ Item { : "0" readonly property bool showCombo: root.selectionMode === ProgramAccountSelector.Output || (root.criteriaReady - && root.matchingAccounts.length > 1) + && root.matchingAccounts.length + > (root.showWhenSingle ? 0 : 1)) readonly property bool showEmptyInput: root.selectionMode === ProgramAccountSelector.Input && root.criteriaReady && root.matchingAccounts.length === 0 @@ -110,6 +118,7 @@ Item { color: root.secondaryTextColor font.pixelSize: 11 verticalAlignment: Text.AlignVCenter + horizontalAlignment: root.textAlignment Accessible.role: Accessible.StaticText Accessible.name: text } @@ -140,6 +149,7 @@ Item { color: accountCombo.enabled ? root.textColor : root.secondaryTextColor font.pixelSize: 11 verticalAlignment: Text.AlignVCenter + horizontalAlignment: root.textAlignment elide: Text.ElideMiddle } @@ -179,6 +189,7 @@ Item { color: root.textColor font.pixelSize: 11 verticalAlignment: Text.AlignVCenter + horizontalAlignment: root.textAlignment elide: Text.ElideMiddle }