mirror of
https://github.com/logos-blockchain/logos-execution-zone-wallet-ui.git
synced 2026-07-29 22:23:30 +00:00
Merge 97bae8ad1f6089d28a83ad21c19b3e97a869749d into da1b021db5315eccd1e39835268bbc08633adc12
This commit is contained in:
commit
a73bfb628f
19516
flake.lock
generated
19516
flake.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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, ... }:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -31,6 +31,8 @@ QVariant LEZWalletAccountModel::data(const QModelIndex& index, int role) const
|
||||
case SectionKeyRole: return e.sectionKey;
|
||||
case KeysJsonRole: return e.keysJson;
|
||||
case IsFirstInGroupRole: return e.isFirstInGroup;
|
||||
case IsFirstPrivateRole: return e.isFirstPrivate;
|
||||
case IsInitializedRole: return e.isInitialized;
|
||||
default: return QVariant();
|
||||
}
|
||||
}
|
||||
@ -45,7 +47,9 @@ QHash<int, QByteArray> LEZWalletAccountModel::roleNames() const
|
||||
{ IsPublicRole, "isPublic" },
|
||||
{ SectionKeyRole, "sectionKey" },
|
||||
{ KeysJsonRole, "keysJson" },
|
||||
{ IsFirstInGroupRole, "isFirstInGroup" }
|
||||
{ IsFirstInGroupRole, "isFirstInGroup" },
|
||||
{ IsFirstPrivateRole, "isFirstPrivate" },
|
||||
{ IsInitializedRole, "isInitialized" }
|
||||
};
|
||||
}
|
||||
|
||||
@ -71,6 +75,8 @@ void LEZWalletAccountModel::replaceFromVariantList(const QVariantList& list)
|
||||
const QVariantMap map = v.toMap();
|
||||
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 {
|
||||
@ -96,8 +102,13 @@ void LEZWalletAccountModel::replaceFromVariantList(const QVariantList& list)
|
||||
if (a.isPublic != b.isPublic) return a.isPublic;
|
||||
return a.sectionKey < b.sectionKey;
|
||||
});
|
||||
for (int i = 0; i < m_entries.size(); ++i)
|
||||
for (int i = 0; i < m_entries.size(); ++i) {
|
||||
m_entries[i].isFirstInGroup = (i == 0) || (m_entries[i].sectionKey != m_entries[i - 1].sectionKey);
|
||||
// All private key-groups sit under a single "Private" title (unlike the public
|
||||
// section, they don't each get their own top-level header) — so only the very
|
||||
// first private row across all groups needs to flag it.
|
||||
m_entries[i].isFirstPrivate = !m_entries[i].isPublic && (i == 0 || m_entries[i - 1].isPublic);
|
||||
}
|
||||
endResetModel();
|
||||
if (oldCount != m_entries.size())
|
||||
emit countChanged();
|
||||
|
||||
@ -18,7 +18,12 @@ struct LEZWalletAccountEntry {
|
||||
bool isPublic = true;
|
||||
QString sectionKey;
|
||||
QString keysJson; // {nullifier_public_key, viewing_public_key} shared by the whole section; private only
|
||||
bool isFirstInGroup = false; // QML renders the section header above rows where this is true
|
||||
bool isFirstInGroup = false; // QML renders the per-key-set header (copy button) above rows where this is true
|
||||
bool isFirstPrivate = false; // QML renders the single "Private" section title above rows where this is true
|
||||
// Whether some program (in practice, the authenticated-transfer program) has claimed
|
||||
// this account yet. Defaults to false (shown as needing init) so an account whose
|
||||
// state we failed to enrich isn't silently mistaken for a usable one.
|
||||
bool isInitialized = false;
|
||||
};
|
||||
|
||||
// Note: this model is exposed to QML via Qt Remote Objects model replication (see
|
||||
@ -37,7 +42,9 @@ public:
|
||||
IsPublicRole,
|
||||
SectionKeyRole,
|
||||
KeysJsonRole,
|
||||
IsFirstInGroupRole
|
||||
IsFirstInGroupRole,
|
||||
IsFirstPrivateRole,
|
||||
IsInitializedRole
|
||||
};
|
||||
Q_ENUM(Role)
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#include "LEZWalletBackend.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <QAbstractItemModel>
|
||||
#include <QClipboard>
|
||||
#include <QCoreApplication>
|
||||
@ -57,6 +58,21 @@ namespace {
|
||||
return QUrl::fromUserInput(p).toLocalFile();
|
||||
return p;
|
||||
}
|
||||
|
||||
// An account is uninitialized until some program claims it (program_owner goes
|
||||
// from all-zero to that program's ID) — see DEFAULT_PROGRAM_ID in the execution
|
||||
// zone's state machine. Accounts this wallet creates are only ever claimed by the
|
||||
// authenticated-transfer program (via an explicit init or as a side effect of
|
||||
// receiving a transfer to a still-unclaimed account), so "non-zero owner" is
|
||||
// enough to show as initialized without needing that program's ID here.
|
||||
bool accountJsonIsInitialized(const QString& accountJson) {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(accountJson.toUtf8());
|
||||
if (!doc.isObject())
|
||||
return false;
|
||||
const QString programOwner = doc.object().value(QStringLiteral("program_owner")).toString();
|
||||
return std::any_of(programOwner.cbegin(), programOwner.cend(),
|
||||
[](QChar c) { return c != QLatin1Char('0'); });
|
||||
}
|
||||
}
|
||||
|
||||
LEZWalletBackend::LEZWalletBackend(LogosAPI* logosAPI, QObject* parent)
|
||||
@ -68,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);
|
||||
|
||||
@ -165,8 +186,9 @@ QVariantList LEZWalletBackend::buildEnrichedAccountList()
|
||||
enriched.reserve(raw.size());
|
||||
for (const QVariant& v : raw) {
|
||||
QVariantMap map = v.toMap();
|
||||
if (!map.value(QStringLiteral("is_public"), true).toBool()) {
|
||||
const QString accountId = map.value(QStringLiteral("account_id")).toString();
|
||||
const QString accountId = map.value(QStringLiteral("account_id")).toString();
|
||||
const bool isPublic = map.value(QStringLiteral("is_public"), true).toBool();
|
||||
if (!isPublic) {
|
||||
const QString keysJson = getPrivateAccountKeys(accountId);
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(keysJson.toUtf8());
|
||||
if (doc.isObject()) {
|
||||
@ -174,6 +196,13 @@ QVariantList LEZWalletBackend::buildEnrichedAccountList()
|
||||
map[QStringLiteral("keys_json")] = keysJson;
|
||||
}
|
||||
}
|
||||
const QString accountJson = isPublic
|
||||
? 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;
|
||||
@ -305,6 +334,25 @@ QString LEZWalletBackend::getPrivateAccountKeys(QString accountIdHex)
|
||||
return m_logos->lez_core.get_private_account_keys(accountIdHex);
|
||||
}
|
||||
|
||||
QString LEZWalletBackend::initializeAccount(QString accountIdHex, bool isPublic)
|
||||
{
|
||||
// Public registration is a plain public tx (like transferPublic, no proof needed),
|
||||
// so the generated accessor's default timeout is fine. Private registration
|
||||
// generates a proof, like transferPrivate/vaultClaim above, so it goes through
|
||||
// invokeRemoteMethod with NO_TIMEOUT instead.
|
||||
const QString result = isPublic
|
||||
? m_logos->lez_core.register_public_account(accountIdHex)
|
||||
: m_logosAPI->getClient(LEZ_MODULE)->invokeRemoteMethod(
|
||||
LEZ_MODULE, "register_private_account",
|
||||
QVariantList{accountIdHex.trimmed()},
|
||||
NO_TIMEOUT).toString();
|
||||
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(result.toUtf8());
|
||||
if (doc.isObject() && doc.object().value(QStringLiteral("success")).toBool())
|
||||
refreshAccounts();
|
||||
return result;
|
||||
}
|
||||
|
||||
bool LEZWalletBackend::syncToBlock(quint64 blockId)
|
||||
{
|
||||
int err = m_logos->lez_core.sync_to_block(blockId);
|
||||
@ -492,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();
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ public slots:
|
||||
void refreshBalances() override;
|
||||
QString getPublicAccountKey(QString accountIdHex) override;
|
||||
QString getPrivateAccountKeys(QString accountIdHex) override;
|
||||
QString initializeAccount(QString accountIdHex, bool isPublic) override;
|
||||
bool syncToBlock(quint64 blockId) override;
|
||||
QString transferPublic(QString fromHex, QString toHex, QString amountStr) override;
|
||||
QString transferPrivate(QString fromHex, QString toHex, QString amountStr) override;
|
||||
@ -53,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();
|
||||
|
||||
@ -14,6 +14,7 @@ class LEZWalletBackend
|
||||
SLOT(void refreshBalances())
|
||||
SLOT(QString getPublicAccountKey(QString accountIdHex))
|
||||
SLOT(QString getPrivateAccountKeys(QString accountIdHex))
|
||||
SLOT(QString initializeAccount(QString accountIdHex, bool isPublic))
|
||||
SLOT(bool syncToBlock(quint64 blockId))
|
||||
|
||||
SLOT(QString transferPublic(QString fromHex, QString toHex, QString amountStr))
|
||||
@ -31,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))
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import QtQuick.Layouts
|
||||
import Logos.Theme
|
||||
import Logos.Controls
|
||||
import "views"
|
||||
import "popups"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
@ -15,6 +16,9 @@ Rectangle {
|
||||
readonly property var privateAccountModel: logos.model("lez_wallet_ui", "privateAccountModel")
|
||||
readonly property var claimableAccountModel: logos.model("lez_wallet_ui", "claimableAccountModel")
|
||||
property bool ready: false
|
||||
// Maps accountId -> true while that account's initializeAccount() call is in flight,
|
||||
// so AccountDelegate can show the click was registered instead of appearing inert.
|
||||
property var pendingInitializations: ({})
|
||||
|
||||
Connections {
|
||||
target: logos
|
||||
@ -108,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
|
||||
@ -145,13 +199,18 @@ Rectangle {
|
||||
claimableAccountModel: root.claimableAccountModel
|
||||
lastSyncedBlock: backend ? backend.lastSyncedBlock : 0
|
||||
currentBlockHeight: backend ? backend.currentBlockHeight : 0
|
||||
pendingInitializations: root.pendingInitializations
|
||||
|
||||
onCreatePublicAccountRequested: {
|
||||
onCreatePublicAccountRequested: (initializeOnCreate) => {
|
||||
if (!backend) { console.warn("backend is null"); return }
|
||||
// Result not consumed here — accountModel updates via NOTIFY when
|
||||
// the backend's refreshAccounts() runs after creation.
|
||||
// accountModel updates via NOTIFY when the backend's refreshAccounts()
|
||||
// runs after creation; the id is only needed to chase it with an
|
||||
// initializeAccount() call when the user asked for that.
|
||||
logos.watch(backend.createAccountPublic(),
|
||||
function(_id) { /* ignored */ },
|
||||
function(id) {
|
||||
if (initializeOnCreate && id)
|
||||
dashboardView.initializeAccount(id, true)
|
||||
},
|
||||
function(error) { console.warn("createAccountPublic failed:", error) })
|
||||
}
|
||||
onCreatePrivateAccountRequested: {
|
||||
@ -277,6 +336,43 @@ Rectangle {
|
||||
clipHelper.selectAll()
|
||||
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).
|
||||
function initializeAccount(accountId, isPublic) {
|
||||
if (!backend) return
|
||||
// Reassign (not mutate) so the pendingInitializations binding
|
||||
// propagated down to each AccountDelegate re-evaluates.
|
||||
var pending = Object.assign({}, root.pendingInitializations)
|
||||
pending[accountId] = true
|
||||
root.pendingInitializations = pending
|
||||
function clearPending() {
|
||||
var updated = Object.assign({}, root.pendingInitializations)
|
||||
delete updated[accountId]
|
||||
root.pendingInitializations = updated
|
||||
}
|
||||
// Same {success, tx_hash, error} shape as the transfer/vaultClaim
|
||||
// slots below, so it gets the same result-panel feedback. The
|
||||
// accountModel's tag updates via NOTIFY once the backend
|
||||
// refreshes accounts after a successful initialization.
|
||||
logos.watch(backend.initializeAccount(accountId, isPublic),
|
||||
function(raw) {
|
||||
clearPending()
|
||||
ffiErrors.applyTransferResult(dashboardView, raw)
|
||||
},
|
||||
function(error) {
|
||||
clearPending()
|
||||
dashboardView.transferResult = qsTr("Error: %1").arg(error)
|
||||
dashboardView.transferResultIsError = true
|
||||
dashboardView.transferTxHash = ""
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,20 @@ ItemDelegate {
|
||||
// logos.module() bridge in the parent view.
|
||||
signal copyRequested(string text)
|
||||
|
||||
// Emitted when the user clicks "Initialize" on an uninitialized account. The
|
||||
// parent wires this to backend.initializeAccount(...) for the same reason as
|
||||
// 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
|
||||
|
||||
leftPadding: Theme.spacing.medium
|
||||
rightPadding: Theme.spacing.medium
|
||||
topPadding: Theme.spacing.medium
|
||||
@ -39,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
|
||||
@ -54,12 +100,23 @@ ItemDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
Rectangle {
|
||||
Layout.preferredWidth: initLabel.implicitWidth + Theme.spacing.small * 2
|
||||
Layout.preferredHeight: initLabel.implicitHeight + 4
|
||||
radius: 4
|
||||
color: Theme.colors.getColor(
|
||||
model.isInitialized ? Theme.palette.success : Theme.palette.warning, 0.18)
|
||||
|
||||
LogosText {
|
||||
text: model.balance && model.balance.length > 0 ? model.balance : "—"
|
||||
font.bold: true
|
||||
LogosText {
|
||||
id: initLabel
|
||||
anchors.centerIn: parent
|
||||
text: model.isInitialized ? qsTr("Initialized") : qsTr("Uninitialized")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: model.isInitialized ? Theme.palette.success : Theme.palette.warning
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
@ -82,5 +139,14 @@ ItemDelegate {
|
||||
icon.color: Theme.palette.textMuted
|
||||
}
|
||||
}
|
||||
|
||||
FeedbackButton {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 32
|
||||
visible: (model.isPublic ?? true) && !model.isInitialized
|
||||
enabled: !root.initializing
|
||||
text: root.initializing ? qsTr("Initializing…") : qsTr("Initialize")
|
||||
onClicked: root.initializeRequested(model.accountId ?? "", model.isPublic ?? true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
src/qml/controls/FeedbackButton.qml
Normal file
18
src/qml/controls/FeedbackButton.qml
Normal file
@ -0,0 +1,18 @@
|
||||
import QtQuick
|
||||
|
||||
import Logos.Theme
|
||||
import Logos.Controls
|
||||
|
||||
// Drop-in replacement for LogosButton that darkens while pressed. LogosButton's
|
||||
// own background only reacts to isActive (hover OR press, same color for both),
|
||||
// so a click on an already-hovered button showed no visible change at all.
|
||||
LogosButton {
|
||||
id: root
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: root.radius
|
||||
color: Theme.palette.text
|
||||
opacity: root.mouseAreaItem.pressed ? 0.15 : 0
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,12 @@ import QtQuick.Layouts
|
||||
import Logos.Theme
|
||||
import Logos.Controls
|
||||
|
||||
import "../controls"
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
|
||||
signal createPublicRequested()
|
||||
signal createPublicRequested(bool initializeOnCreate)
|
||||
signal createPrivateRequested()
|
||||
|
||||
modal: true
|
||||
@ -72,20 +74,27 @@ Popup {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
LogosCheckbox {
|
||||
id: initializeOnCreateCheck
|
||||
checked: true
|
||||
text: qsTr("Initialize on creation")
|
||||
visible: tabBar.currentIndex === 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.topMargin: Theme.spacing.medium
|
||||
spacing: Theme.spacing.medium
|
||||
Layout.fillWidth: true
|
||||
Item { Layout.fillWidth: true }
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
text: qsTr("Cancel")
|
||||
onClicked: root.close()
|
||||
}
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
text: qsTr("Create")
|
||||
onClicked: {
|
||||
if (tabBar.currentIndex === 0)
|
||||
root.createPublicRequested()
|
||||
root.createPublicRequested(initializeOnCreateCheck.checked)
|
||||
else
|
||||
root.createPrivateRequested()
|
||||
root.close()
|
||||
|
||||
150
src/qml/popups/SetLabelDialog.qml
Normal file
150
src/qml/popups/SetLabelDialog.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,19 +15,23 @@ Rectangle {
|
||||
property var accountModel: null
|
||||
property int lastSyncedBlock: 0
|
||||
property int currentBlockHeight: 0
|
||||
// Maps accountId -> true while that account's initializeAccount() call is in flight.
|
||||
property var pendingInitializations: ({})
|
||||
|
||||
// --- Public API: signals out ---
|
||||
signal createPublicAccountRequested()
|
||||
signal createPublicAccountRequested(bool initializeOnCreate)
|
||||
signal createPrivateAccountRequested()
|
||||
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
|
||||
|
||||
CreateAccountDialog {
|
||||
id: createAccountDialog
|
||||
onCreatePublicRequested: root.createPublicAccountRequested()
|
||||
onCreatePublicRequested: (initializeOnCreate) => root.createPublicAccountRequested(initializeOnCreate)
|
||||
onCreatePrivateRequested: root.createPrivateAccountRequested()
|
||||
}
|
||||
|
||||
@ -51,7 +55,7 @@ Rectangle {
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
Layout.preferredHeight: 40
|
||||
Layout.preferredWidth: 80
|
||||
text: qsTr("+ Create")
|
||||
@ -145,26 +149,124 @@ Rectangle {
|
||||
// row is created.
|
||||
property string keysJsonWarm: model.keysJson ?? ""
|
||||
|
||||
// "Public Accounts" title: the public section is a single group, so this
|
||||
// is equivalent to showing it once above the first public row.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: model.isFirstInGroup ?? false
|
||||
visible: model.isPublic && (model.isFirstInGroup ?? false)
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
LogosText {
|
||||
text: model.isPublic
|
||||
? qsTr("Public Accounts")
|
||||
: qsTr("Private")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
text: qsTr("Public Accounts")
|
||||
font.pixelSize: Theme.typography.primaryText
|
||||
font.bold: true
|
||||
color: Theme.palette.textSecondary
|
||||
color: Theme.palette.text
|
||||
}
|
||||
}
|
||||
|
||||
// "Private Accounts" title: shown once above the whole private section,
|
||||
// unlike the per-key-set row below which repeats for every private key
|
||||
// group. Wrapped the same way as the "Public Accounts" title above so
|
||||
// both line up identically.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: model.isFirstPrivate ?? false
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
LogosText {
|
||||
text: qsTr("Private Accounts")
|
||||
font.pixelSize: Theme.typography.primaryText
|
||||
font.bold: true
|
||||
color: Theme.palette.text
|
||||
}
|
||||
}
|
||||
|
||||
// Per-key-set row: separates each private key group within the Private
|
||||
// section, naming the group by its Npk/Vpk pair, and holds the copy
|
||||
// button for that pair.
|
||||
RowLayout {
|
||||
id: keyGroupHeader
|
||||
Layout.fillWidth: true
|
||||
visible: !model.isPublic && (model.isFirstInGroup ?? false)
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
property var groupKeys: {
|
||||
try { return JSON.parse(model.keysJson ?? "{}") } catch (e) { return {} }
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
function shortKey(key) {
|
||||
return key && key.length > 12 ? key.slice(0, 6) + "…" + key.slice(-4) : (key || "")
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
LogosText {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Accounts under keys")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
}
|
||||
|
||||
// Each of Npk/Vpk gets its own bullet, aligned with "Private
|
||||
// Accounts"/"Accounts under keys" above. Labels share a fixed
|
||||
// width (the wider of the two) so the value column still lines
|
||||
// up between the two rows.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
LogosText {
|
||||
text: "•"
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
}
|
||||
LogosText {
|
||||
id: npkLabel
|
||||
Layout.preferredWidth: Math.max(npkLabel.implicitWidth, vpkLabel.implicitWidth)
|
||||
text: qsTr("Npk:")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
}
|
||||
LogosText {
|
||||
Layout.fillWidth: true
|
||||
text: keyGroupHeader.shortKey(keyGroupHeader.groupKeys.nullifier_public_key)
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
LogosText {
|
||||
text: "•"
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
}
|
||||
LogosText {
|
||||
id: vpkLabel
|
||||
Layout.preferredWidth: Math.max(npkLabel.implicitWidth, vpkLabel.implicitWidth)
|
||||
text: qsTr("Vpk:")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
}
|
||||
LogosText {
|
||||
Layout.fillWidth: true
|
||||
text: keyGroupHeader.shortKey(keyGroupHeader.groupKeys.viewing_public_key)
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.textSecondary
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogosCopyButton {
|
||||
Layout.preferredHeight: 32
|
||||
Layout.preferredWidth: 32
|
||||
visible: !model.isPublic
|
||||
icon.color: Theme.palette.textMuted
|
||||
onCopyText: root.copyRequested(model.keysJson ?? "")
|
||||
}
|
||||
@ -172,13 +274,16 @@ Rectangle {
|
||||
|
||||
AccountDelegate {
|
||||
Layout.fillWidth: true
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: Fetch / Refresh Balances
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Refresh Balances")
|
||||
onClicked: root.fetchBalancesRequested()
|
||||
|
||||
@ -5,6 +5,7 @@ import QtQuick.Layouts
|
||||
import Logos.Theme
|
||||
import Logos.Controls
|
||||
|
||||
import "../controls"
|
||||
import "../Base58.js" as Base58
|
||||
|
||||
Item {
|
||||
@ -79,7 +80,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
text: qsTr("Claim")
|
||||
enabled: !root.claimPending
|
||||
onClicked: root.vaultClaimRequested(model.accountId, model.isPublic, model.vaultBalance)
|
||||
@ -88,7 +89,7 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Refresh")
|
||||
enabled: !root.claimPending
|
||||
|
||||
@ -19,9 +19,10 @@ Rectangle {
|
||||
property bool transferPending: false
|
||||
property int lastSyncedBlock: 0
|
||||
property int currentBlockHeight: 0
|
||||
property var pendingInitializations: ({})
|
||||
|
||||
// --- Public API: output signals (parent connects and calls backend) ---
|
||||
signal createPublicAccountRequested()
|
||||
signal createPublicAccountRequested(bool initializeOnCreate)
|
||||
signal createPrivateAccountRequested()
|
||||
signal fetchBalancesRequested()
|
||||
signal transferPublicRequested(string fromAccountId, string toAddress, string amount)
|
||||
@ -34,6 +35,8 @@ Rectangle {
|
||||
signal vaultClaimRequested(string fromAccountId, bool isPublic, string amount)
|
||||
signal refreshClaimableDepositsRequested()
|
||||
signal copyRequested(string copyText)
|
||||
signal initializeAccountRequested(string accountId, bool isPublic)
|
||||
signal labelRequested(string accountId, bool isPublic)
|
||||
|
||||
color: Theme.palette.background
|
||||
|
||||
@ -50,11 +53,14 @@ Rectangle {
|
||||
accountModel: root.accountModel
|
||||
lastSyncedBlock: root.lastSyncedBlock
|
||||
currentBlockHeight: root.currentBlockHeight
|
||||
pendingInitializations: root.pendingInitializations
|
||||
|
||||
onCreatePublicAccountRequested: root.createPublicAccountRequested()
|
||||
onCreatePublicAccountRequested: (initializeOnCreate) => root.createPublicAccountRequested(initializeOnCreate)
|
||||
onCreatePrivateAccountRequested: root.createPrivateAccountRequested()
|
||||
onFetchBalancesRequested: root.fetchBalancesRequested()
|
||||
onCopyRequested: (text) => root.copyRequested(text)
|
||||
onInitializeAccountRequested: (accountId, isPublic) => root.initializeAccountRequested(accountId, isPublic)
|
||||
onLabelRequested: (accountId, isPublic) => root.labelRequested(accountId, isPublic)
|
||||
}
|
||||
|
||||
TransferPanel {
|
||||
|
||||
@ -6,6 +6,8 @@ import QtQuick.Dialogs
|
||||
import Logos.Theme
|
||||
import Logos.Controls
|
||||
|
||||
import "../controls"
|
||||
|
||||
Control {
|
||||
id: root
|
||||
|
||||
@ -66,7 +68,7 @@ Control {
|
||||
placeholderText: qsTr("Add store path")
|
||||
text: root.storePath
|
||||
}
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
text: qsTr("Browse")
|
||||
onClicked: storageFolderDialog.open()
|
||||
}
|
||||
@ -86,7 +88,7 @@ Control {
|
||||
placeholderText: qsTr("Add path to config")
|
||||
text: root.configPath
|
||||
}
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
Layout.preferredHeight: configPathField.height
|
||||
text: qsTr("Browse")
|
||||
onClicked: configFileDialog.open()
|
||||
@ -113,12 +115,12 @@ Control {
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacing.small
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
text: qsTr("Testnet")
|
||||
opacity: sequencerUrlField.text === root.testnetUrl ? 1.0 : 0.4
|
||||
onClicked: sequencerUrlField.text = root.testnetUrl
|
||||
}
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
text: qsTr("Localhost")
|
||||
opacity: sequencerUrlField.text === root.localhostUrl ? 1.0 : 0.4
|
||||
onClicked: sequencerUrlField.text = root.localhostUrl
|
||||
@ -155,7 +157,7 @@ Control {
|
||||
text: root.createError
|
||||
}
|
||||
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
Layout.alignment: Qt.AlignRight
|
||||
text: qsTr("Create Wallet")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
|
||||
@ -188,7 +188,7 @@ Item {
|
||||
}
|
||||
|
||||
// Send button
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Send")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
|
||||
@ -99,7 +99,7 @@ Item {
|
||||
}
|
||||
|
||||
// Withdraw button
|
||||
LogosButton {
|
||||
FeedbackButton {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Withdraw")
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user