Merge 97bae8ad1f6089d28a83ad21c19b3e97a869749d into da1b021db5315eccd1e39835268bbc08633adc12

This commit is contained in:
Sergio Chouhy 2026-07-24 23:54:32 +00:00 committed by GitHub
commit a73bfb628f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 16578 additions and 3593 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

@ -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();

View File

@ -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)

View File

@ -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();
}

View File

@ -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();

View File

@ -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))
}

View File

@ -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 = ""
})
}
}
}
}

View File

@ -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)
}
}
}

View 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
}
}

View File

@ -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()

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

@ -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()

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -188,7 +188,7 @@ Item {
}
// Send button
LogosButton {
FeedbackButton {
Layout.fillWidth: true
text: qsTr("Send")
font.pixelSize: Theme.typography.secondaryText

View File

@ -99,7 +99,7 @@ Item {
}
// Withdraw button
LogosButton {
FeedbackButton {
Layout.fillWidth: true
text: qsTr("Withdraw")
font.pixelSize: Theme.typography.secondaryText