From e0ae3208a188bfa9a7893c1d97cc562d31218399 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Thu, 30 Jul 2026 01:13:20 -0300 Subject: [PATCH] feat(wallet): add reusable ProgramAccountSelector component A shared QML select box for choosing a program account (a token holding) that matches given criteria (accountType + a state field such as the token definitionId), listing matches with balances and emitting selectionChanged. Registered in the wallet module's public QML. Cherry-picked from feat/shared-wallet-token-holding-selector, reduced to the reusable component only; the AMM swap/liquidity wiring and FFI account source are reimplemented on the current (post swap + createPool) code. --- apps/shared/wallet/CMakeLists.txt | 1 + .../wallet/qml/ProgramAccountSelector.qml | 362 ++++++++++++++++++ .../tests/qml/tst_ProgramAccountSelector.qml | 152 ++++++++ 3 files changed, 515 insertions(+) create mode 100644 apps/shared/wallet/qml/ProgramAccountSelector.qml create mode 100644 apps/shared/wallet/tests/qml/tst_ProgramAccountSelector.qml diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 896716b..b06fdc4 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -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 ) diff --git a/apps/shared/wallet/qml/ProgramAccountSelector.qml b/apps/shared/wallet/qml/ProgramAccountSelector.qml new file mode 100644 index 0000000..4fb7dd0 --- /dev/null +++ b/apps/shared/wallet/qml/ProgramAccountSelector.qml @@ -0,0 +1,362 @@ +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 balanceValue = root.valueFor(row, "balanceRaw") + const balance = balanceValue === undefined || balanceValue === null ? "" : String(balanceValue) + 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) + } + } +} diff --git a/apps/shared/wallet/tests/qml/tst_ProgramAccountSelector.qml b/apps/shared/wallet/tests/qml/tst_ProgramAccountSelector.qml new file mode 100644 index 0000000..2d8a21b --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_ProgramAccountSelector.qml @@ -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) + } + } +}