feat: add labels

This commit is contained in:
Sergio Chouhy 2026-07-24 00:24:03 -03:00
parent aae5e5f817
commit 275415f34c
13 changed files with 16275 additions and 3560 deletions

19516
flake.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx";
lez_core.url = "github:logos-blockchain/logos-execution-zone-module?rev=1d4db06ebcdd4879a4395efbe4993a15992a261b";
lez_core.url = "github:logos-blockchain/logos-execution-zone-module?rev=033f807a3b1690fb2f8e661cb8067646b8a98242";
};
outputs = inputs@{ logos-module-builder, ... }:

View File

@ -19,13 +19,27 @@ void LEZAccountFilterModel::setFilterByPublic(bool value)
emit countChanged();
}
void LEZAccountFilterModel::setOnlyInitialized(bool value)
{
if (m_onlyInitialized == value)
return;
m_onlyInitialized = value;
invalidateFilter();
emit onlyInitializedChanged();
emit countChanged();
}
bool LEZAccountFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const
{
if (!sourceModel())
return false;
const QModelIndex idx = sourceModel()->index(sourceRow, 0, sourceParent);
const bool isPublic = sourceModel()->data(idx, LEZWalletAccountModel::IsPublicRole).toBool();
return isPublic == m_filterByPublic;
if (isPublic != m_filterByPublic)
return false;
if (m_onlyInitialized && !sourceModel()->data(idx, LEZWalletAccountModel::IsInitializedRole).toBool())
return false;
return true;
}
int LEZAccountFilterModel::rowForAddress(const QString& address) const

View File

@ -6,6 +6,7 @@
class LEZAccountFilterModel : public QSortFilterProxyModel {
Q_OBJECT
Q_PROPERTY(bool filterByPublic READ filterByPublic WRITE setFilterByPublic NOTIFY filterByPublicChanged)
Q_PROPERTY(bool onlyInitialized READ onlyInitialized WRITE setOnlyInitialized NOTIFY onlyInitializedChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
public:
@ -14,6 +15,13 @@ public:
bool filterByPublic() const { return m_filterByPublic; }
void setFilterByPublic(bool value);
// When true, uninitialized accounts are excluded. Used for the account-picker
// combo boxes (transfer/withdraw "from"/"to" fields) where an uninitialized
// account can't yet be a valid sender or recipient — as opposed to AccountsPanel's
// unfiltered accountModel, which must keep showing them so the user can initialize.
bool onlyInitialized() const { return m_onlyInitialized; }
void setOnlyInitialized(bool value);
int count() const { return rowCount(); }
Q_INVOKABLE int rowForAddress(const QString& address) const;
@ -23,8 +31,10 @@ protected:
signals:
void filterByPublicChanged();
void onlyInitializedChanged();
void countChanged();
private:
bool m_filterByPublic = true;
bool m_onlyInitialized = false;
};

View File

@ -74,6 +74,7 @@ void LEZWalletAccountModel::replaceFromVariantList(const QVariantList& list)
e.accountId = map.value(QStringLiteral("account_id")).toString();
e.isPublic = map.value(QStringLiteral("is_public"), true).toBool();
e.isInitialized = map.value(QStringLiteral("is_initialized"), false).toBool();
e.name = map.value(QStringLiteral("name")).toString();
if (e.isPublic) {
e.sectionKey = PublicSectionKey;
} else {

View File

@ -84,8 +84,13 @@ LEZWalletBackend::LEZWalletBackend(LogosAPI* logosAPI, QObject* parent)
m_logosAPI(logosAPI ? logosAPI : new LogosAPI("lez_wallet_ui", this)),
m_logos(new LogosModules(m_logosAPI))
{
// Both feed the transfer/withdraw "from"/"to" account-picker combo boxes, where an
// uninitialized account isn't a usable sender or recipient — unlike m_accountModel
// (unfiltered), which AccountsPanel needs to keep showing them on for initialization.
m_filteredAccountModel->setOnlyInitialized(true);
m_filteredAccountModel->setSourceModel(m_accountModel);
m_privateAccountModel->setFilterByPublic(false);
m_privateAccountModel->setOnlyInitialized(true);
m_privateAccountModel->setSourceModel(m_accountModel);
m_claimableAccountModel->setSourceModel(m_accountModel);
@ -192,9 +197,12 @@ QVariantList LEZWalletBackend::buildEnrichedAccountList()
}
}
const QString accountJson = isPublic
? m_logos->logos_execution_zone.get_account_public(accountId)
: m_logos->logos_execution_zone.get_account_private(accountId);
? m_logos->lez_core.get_account_public(accountId)
: m_logos->lez_core.get_account_private(accountId);
map[QStringLiteral("is_initialized")] = accountJsonIsInitialized(accountJson);
const QStringList labels = m_logos->lez_core.get_all_labels_for_account(accountId, !isPublic);
if (!labels.isEmpty())
map[QStringLiteral("name")] = labels.join(QStringLiteral(", "));
enriched.append(map);
}
return enriched;
@ -333,7 +341,7 @@ QString LEZWalletBackend::initializeAccount(QString accountIdHex, bool isPublic)
// generates a proof, like transferPrivate/vaultClaim above, so it goes through
// invokeRemoteMethod with NO_TIMEOUT instead.
const QString result = isPublic
? m_logos->logos_execution_zone.register_public_account(accountIdHex)
? m_logos->lez_core.register_public_account(accountIdHex)
: m_logosAPI->getClient(LEZ_MODULE)->invokeRemoteMethod(
LEZ_MODULE, "register_private_account",
QVariantList{accountIdHex.trimmed()},
@ -532,3 +540,22 @@ void LEZWalletBackend::copyToClipboard(QString text)
if (QGuiApplication::clipboard())
QGuiApplication::clipboard()->setText(text);
}
bool LEZWalletBackend::checkLabelAvailable(QString label)
{
return m_logos->lez_core.check_label_available(label.trimmed());
}
QString LEZWalletBackend::addLabel(QString label, QString accountIdHex, bool isPublic)
{
const QString trimmedLabel = label.trimmed();
if (trimmedLabel.isEmpty())
return QStringLiteral("Error: Label cannot be empty.");
const int err = m_logos->lez_core.add_label(trimmedLabel, accountIdHex.trimmed(), !isPublic);
if (err != WALLET_FFI_SUCCESS)
return QStringLiteral("Error: wallet FFI error %1").arg(err);
refreshAccounts();
return QString();
}

View File

@ -54,6 +54,8 @@ public slots:
QString vaultClaim(QString fromHex, bool isPublic, QString amountStr) override;
QString createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr) override;
void copyToClipboard(QString text) override;
bool checkLabelAvailable(QString label) override;
QString addLabel(QString label, QString accountIdHex, bool isPublic) override;
private slots:
void syncNextChunk();

View File

@ -32,4 +32,7 @@ class LEZWalletBackend
SLOT(QString createNew(QString configPath, QString storagePath, QString password, QString sequencerAddr))
SLOT(void copyToClipboard(QString text))
SLOT(bool checkLabelAvailable(QString label))
SLOT(QString addLabel(QString label, QString accountIdHex, bool isPublic))
}

View File

@ -5,6 +5,7 @@ import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
import "views"
import "popups"
Rectangle {
id: root
@ -111,6 +112,56 @@ Rectangle {
visible: false
}
SetLabelDialog {
id: setLabelDialog
onCheckAvailabilityRequested: (label) => {
if (!backend) return
logos.watch(backend.checkLabelAvailable(label),
function(available) {
// The user may have kept typing while this round-trip was in
// flight only apply the result if it still matches the
// current text, otherwise it's stale.
if (label === setLabelDialog.trimmedText) {
setLabelDialog.checkingAvailability = false
setLabelDialog.labelAvailable = available
}
},
function(error) {
console.warn("checkLabelAvailable failed:", error)
if (label === setLabelDialog.trimmedText) {
setLabelDialog.checkingAvailability = false
// Fail open addLabel() itself still validates on submit.
setLabelDialog.labelAvailable = true
}
})
}
onSaveRequested: (accountId, isPublic, label) => {
if (!backend) {
setLabelDialog.reportSaveError(qsTr("Wallet backend unavailable."))
return
}
logos.watch(backend.addLabel(label, accountId, isPublic),
function(errorMessage) {
// The dialog may have been reopened for a different account while
// this round-trip was in flight only apply the result if it's
// still about the same account, otherwise it's stale.
if (setLabelDialog.accountId !== accountId || setLabelDialog.isPublic !== isPublic)
return
if (errorMessage)
setLabelDialog.reportSaveError(ffiErrors.format(errorMessage))
else
setLabelDialog.closeOnSaveSuccess()
},
function(error) {
if (setLabelDialog.accountId !== accountId || setLabelDialog.isPublic !== isPublic)
return
setLabelDialog.reportSaveError(error)
})
}
}
StackView {
id: stackView
anchors.fill: parent
@ -286,6 +337,11 @@ Rectangle {
clipHelper.copy()
}
onInitializeAccountRequested: (accountId, isPublic) => dashboardView.initializeAccount(accountId, isPublic)
onLabelRequested: (accountId, isPublic) => {
setLabelDialog.accountId = accountId
setLabelDialog.isPublic = isPublic
setLabelDialog.open()
}
// Shared by the manual Initialize button (onInitializeAccountRequested)
// and initialize-on-create (onCreatePublicAccountRequested above).

View File

@ -20,6 +20,11 @@ ItemDelegate {
// copyRequested above.
signal initializeRequested(string accountId, bool isPublic)
// Emitted when the user clicks "Add label", so the parent can open
// SetLabelDialog for this account. Only reachable for unlabeled accounts
// the wallet core has no way to rename or remove a label once added.
signal labelRequested(string accountId, bool isPublic)
// Set by the parent while this account's initializeAccount() call is in flight,
// so the button can show it took the click instead of appearing to do nothing.
property bool initializing: false
@ -48,6 +53,38 @@ ItemDelegate {
font.bold: true
}
LogosText {
// The wallet core only supports adding a label, never renaming or
// removing one so once an account has one, there's nothing left
// to offer here.
text: qsTr("Add label")
visible: !model.name
font.pixelSize: Theme.typography.secondaryText
font.underline: labelLinkArea.containsMouse
color: Theme.palette.textMuted
MouseArea {
id: labelLinkArea
anchors.fill: parent
anchors.margins: -Theme.spacing.small
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.labelRequested(model.accountId ?? "", model.isPublic ?? true)
}
}
Item { Layout.fillWidth: true }
LogosText {
text: model.balance && model.balance.length > 0 ? model.balance : "—"
font.bold: true
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
Rectangle {
Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2
Layout.preferredHeight: tagLabel.implicitHeight + 4
@ -80,11 +117,6 @@ ItemDelegate {
}
Item { Layout.fillWidth: true }
LogosText {
text: model.balance && model.balance.length > 0 ? model.balance : "—"
font.bold: true
}
}
RowLayout {

View File

@ -0,0 +1,150 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
import "../controls"
Popup {
id: root
// Context set by the parent right before open(). Only ever opened for an
// unlabeled account the wallet core has no way to rename or remove a label
// once added, so there's no "current label" to prefill or compare against.
property string accountId: ""
property bool isPublic: true
// Debounced availability-check result. The parent owns the backend connection,
// so it drives these two properties in response to checkAvailabilityRequested.
property bool checkingAvailability: false
property bool labelAvailable: true
// Save round-trip state, likewise driven by the parent: saving stays true and the
// popup stays open until the parent reports success (closeOnSaveSuccess) or failure
// (reportSaveError) so a failed save isn't silently swallowed by an immediate close.
property bool saving: false
property string saveError: ""
signal checkAvailabilityRequested(string label)
signal saveRequested(string accountId, bool isPublic, string label)
function closeOnSaveSuccess() {
root.saving = false
root.close()
}
function reportSaveError(message) {
root.saving = false
root.saveError = message
}
readonly property string trimmedText: labelField.text.trim()
readonly property bool saveEnabled: trimmedText.length > 0
&& root.labelAvailable && !root.checkingAvailability
modal: true
dim: true
padding: Theme.spacing.large
// No dismissal while a save is in flight mirrors the disabled Cancel/Save
// buttons below, so a stale response can't land on a dialog the user thinks
// they already closed.
closePolicy: root.saving ? Popup.NoAutoClose : (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
anchors.centerIn: parent
background: Rectangle {
color: Theme.palette.backgroundSecondary
radius: Theme.spacing.radiusXlarge
border.color: Theme.palette.backgroundElevated
}
onOpened: {
labelField.text = ""
root.labelAvailable = true
root.checkingAvailability = false
root.saving = false
root.saveError = ""
labelField.forceActiveFocus()
labelField.selectAll()
}
Timer {
id: debounce
interval: 400
onTriggered: {
if (root.trimmedText.length > 0) {
root.checkingAvailability = true
root.checkAvailabilityRequested(root.trimmedText)
} else {
root.checkingAvailability = false
}
}
}
contentItem: ColumnLayout {
width: 320
spacing: Theme.spacing.large
LogosText {
text: qsTr("Add label")
font.pixelSize: Theme.typography.titleText
font.weight: Theme.typography.weightBold
color: Theme.palette.text
}
LogosTextField {
id: labelField
Layout.fillWidth: true
enabled: !root.saving
onTextChanged: {
root.saveError = ""
// Mark as checking immediately so saveEnabled can't go true on stale
// (or no) availability data during the debounce window the Timer
// below only confirms/updates the result once typing settles.
root.checkingAvailability = root.trimmedText.length > 0
debounce.restart()
}
}
LogosText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.error
visible: root.trimmedText.length > 0 && !root.checkingAvailability && !root.labelAvailable
text: qsTr("That label is already in use.")
}
LogosText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.error
visible: root.saveError.length > 0
text: root.saveError
}
RowLayout {
Layout.topMargin: Theme.spacing.medium
spacing: Theme.spacing.medium
Layout.fillWidth: true
Item { Layout.fillWidth: true }
FeedbackButton {
text: qsTr("Cancel")
enabled: !root.saving
onClicked: root.close()
}
FeedbackButton {
text: root.saving ? qsTr("Saving…") : qsTr("Save")
enabled: root.saveEnabled && !root.saving
onClicked: {
root.saving = true
root.saveError = ""
root.saveRequested(root.accountId, root.isPublic, root.trimmedText)
}
}
}
}
}

View File

@ -24,6 +24,7 @@ Rectangle {
signal fetchBalancesRequested()
signal copyRequested(string text)
signal initializeAccountRequested(string accountId, bool isPublic)
signal labelRequested(string accountId, bool isPublic)
radius: Theme.spacing.radiusXlarge
color: Theme.palette.backgroundSecondary
@ -178,6 +179,7 @@ Rectangle {
initializing: root.pendingInitializations[model.accountId] === true
onCopyRequested: (text) => root.copyRequested(text)
onInitializeRequested: (accountId, isPublic) => root.initializeAccountRequested(accountId, isPublic)
onLabelRequested: (accountId, isPublic) => root.labelRequested(accountId, isPublic)
}
}
}

View File

@ -36,6 +36,7 @@ Rectangle {
signal refreshClaimableDepositsRequested()
signal copyRequested(string copyText)
signal initializeAccountRequested(string accountId, bool isPublic)
signal labelRequested(string accountId, bool isPublic)
color: Theme.palette.background
@ -59,6 +60,7 @@ Rectangle {
onFetchBalancesRequested: root.fetchBalancesRequested()
onCopyRequested: (text) => root.copyRequested(text)
onInitializeAccountRequested: (accountId, isPublic) => root.initializeAccountRequested(accountId, isPublic)
onLabelRequested: (accountId, isPublic) => root.labelRequested(accountId, isPublic)
}
TransferPanel {