add account list sections separated by public or (npk,vpk) groups

This commit is contained in:
Sergio Chouhy 2026-06-17 00:58:51 -03:00
parent fc28a83191
commit 2de3f043d9
9 changed files with 112 additions and 32 deletions

View File

@ -1,5 +1,6 @@
#include "LEZWalletAccountModel.h"
#include <QVariantMap>
#include <algorithm>
LEZWalletAccountModel::LEZWalletAccountModel(QObject* parent)
: QAbstractListModel(parent)
@ -24,6 +25,9 @@ QVariant LEZWalletAccountModel::data(const QModelIndex& index, int role) const
case AccountIdRole: return e.accountId;
case BalanceRole: return e.balance;
case IsPublicRole: return e.isPublic;
case SectionKeyRole: return e.sectionKey;
case KeysJsonRole: return e.keysJson;
case IsFirstInGroupRole: return e.isFirstInGroup;
default: return QVariant();
}
}
@ -34,7 +38,10 @@ QHash<int, QByteArray> LEZWalletAccountModel::roleNames() const
{ NameRole, "name" },
{ AccountIdRole, "accountId" },
{ BalanceRole, "balance" },
{ IsPublicRole, "isPublic" }
{ IsPublicRole, "isPublic" },
{ SectionKeyRole, "sectionKey" },
{ KeysJsonRole, "keysJson" },
{ IsFirstInGroupRole, "isFirstInGroup" }
};
}
@ -51,12 +58,28 @@ 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();
if (e.isPublic) {
e.sectionKey = PublicSectionKey;
} else {
e.sectionKey = map.value(QStringLiteral("npk")).toString();
e.keysJson = map.value(QStringLiteral("keys_json")).toString();
}
} else {
e.accountId = v.toString();
e.isPublic = true;
e.sectionKey = PublicSectionKey;
}
m_entries.append(e);
}
// Keep entries grouped by section (public first) so consecutive rows of the same
// group are contiguous, then mark each group's first row for the QML header.
std::stable_sort(m_entries.begin(), m_entries.end(),
[](const LEZWalletAccountEntry& a, const LEZWalletAccountEntry& b) {
if (a.isPublic != b.isPublic) return a.isPublic;
return a.sectionKey < b.sectionKey;
});
for (int i = 0; i < m_entries.size(); ++i)
m_entries[i].isFirstInGroup = (i == 0) || (m_entries[i].sectionKey != m_entries[i - 1].sectionKey);
endResetModel();
if (oldCount != m_entries.size())
emit countChanged();

View File

@ -4,13 +4,25 @@
#include <QVariant>
#include <QString>
// Public accounts have no key group, so they all share the PublicSectionKey section.
// Private accounts section by NPK (the key group they belong to) — see
// LEZWalletBackend::buildEnrichedAccountList, which attaches "npk"/"keys_json" per entry.
inline const QString PublicSectionKey = QStringLiteral("public");
struct LEZWalletAccountEntry {
QString name;
QString accountId;
QString balance;
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
};
// Note: this model is exposed to QML via Qt Remote Objects model replication (see
// logos.model() in ExecutionZoneWalletView.qml), which only replicates roles — not
// arbitrary Q_PROPERTYs. Anything QML needs must be a role on the row, not a property
// on the model itself.
class LEZWalletAccountModel : public QAbstractListModel {
Q_OBJECT
Q_PROPERTY(int count READ count NOTIFY countChanged)
@ -19,7 +31,10 @@ public:
NameRole = Qt::UserRole + 1,
AccountIdRole,
BalanceRole,
IsPublicRole
IsPublicRole,
SectionKeyRole,
KeysJsonRole,
IsFirstInGroupRole
};
Q_ENUM(Role)

View File

@ -133,11 +133,34 @@ void LEZWalletBackend::openIfPathsConfigured()
}
}
// Tags each private account with the NPK of the key group it belongs to (plus that
// group's {nullifier_public_key, viewing_public_key} JSON), so the model can section
// accounts by key group instead of listing them flat. Public accounts are untouched.
QVariantList LEZWalletBackend::buildEnrichedAccountList()
{
QVariantList raw = m_logos->logos_execution_zone.list_accounts();
QVariantList enriched;
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 keysJson = getPrivateAccountKeys(accountId);
const QJsonDocument doc = QJsonDocument::fromJson(keysJson.toUtf8());
if (doc.isObject()) {
map[QStringLiteral("npk")] = doc.object().value(QStringLiteral("nullifier_public_key")).toString();
map[QStringLiteral("keys_json")] = keysJson;
}
}
enriched.append(map);
}
return enriched;
}
void LEZWalletBackend::finishOpeningWallet()
{
setIsWalletOpen(true);
QVariantList arr = m_logos->logos_execution_zone.list_accounts();
m_accountModel->replaceFromVariantList(arr);
m_accountModel->replaceFromVariantList(buildEnrichedAccountList());
fetchAndUpdateBlockHeights();
startChunkedSync();
refreshSequencerAddr();
@ -145,8 +168,7 @@ void LEZWalletBackend::finishOpeningWallet()
void LEZWalletBackend::refreshAccounts()
{
QVariantList arr = m_logos->logos_execution_zone.list_accounts();
m_accountModel->replaceFromVariantList(arr);
m_accountModel->replaceFromVariantList(buildEnrichedAccountList());
fetchAndUpdateBlockHeights();
if (!m_syncing)
startChunkedSync();
@ -177,8 +199,7 @@ void LEZWalletBackend::syncNextChunk()
m_syncing = false;
// Sync may have discovered new private accounts (e.g. shielded transfers to a
// foreign NPK/VPK); re-list so the model picks them up without a restart.
QVariantList arr = m_logos->logos_execution_zone.list_accounts();
m_accountModel->replaceFromVariantList(arr);
m_accountModel->replaceFromVariantList(buildEnrichedAccountList());
fetchAndUpdateBlockHeights();
updateBalances();
return;

View File

@ -57,6 +57,7 @@ private:
void applySequencerAddrToConfig(const QString& configPath, const QString& sequencerAddr);
void fetchAndUpdateBlockHeights();
void startChunkedSync();
QVariantList buildEnrichedAccountList();
void updateBalances();
void refreshSequencerAddr();

View File

@ -237,16 +237,6 @@ Rectangle {
clipHelper.selectAll()
clipHelper.copy()
}
onCopyPublicKeysRequested: (accountIdHex) => {
if (!backend) return
logos.watch(backend.getPrivateAccountKeys(accountIdHex),
function(keys) {
clipHelper.text = keys
clipHelper.selectAll()
clipHelper.copy()
},
function(error) { console.warn("getPrivateAccountKeys failed:", error) })
}
}
}
}

View File

@ -12,7 +12,6 @@ ComboBox {
// Forwarded from AccountDelegate's copy button bubble up to the parent
// view, which calls backend.copyToClipboard().
signal copyRequested(string text)
signal copyPublicKeysRequested(string accountIdHex)
leftPadding: 12
rightPadding: 12
@ -62,7 +61,6 @@ ComboBox {
width: root.popup ? (root.popup.width - root.popup.leftPadding - root.popup.rightPadding) : 368
highlighted: root.highlightedIndex === index
onCopyRequested: (text) => root.copyRequested(text)
onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id)
}
popup: Popup {

View File

@ -13,8 +13,10 @@ ItemDelegate {
// to backend.copyToClipboard(...) AccountDelegate doesn't reach into
// the global QML scope for `backend` since it now lives behind the
// logos.module() bridge in the parent view.
// Public accounts only private accounts copy their group's NPK/VPK via
// the section header instead, since an individual account ID isn't an
// address you'd hand out (only the (npk, vpk) group identity is).
signal copyRequested(string text)
signal copyPublicKeysRequested(string accountIdHex)
leftPadding: Theme.spacing.medium
rightPadding: Theme.spacing.medium
@ -78,10 +80,8 @@ ItemDelegate {
LogosCopyButton {
Layout.preferredHeight: 40
Layout.preferredWidth: 40
onCopyText: model.isPublic
? root.copyRequested(Base58.encode(model.accountId ?? ""))
: root.copyPublicKeysRequested(model.accountId ?? "")
visible: addressLabel.text
onCopyText: root.copyRequested(Base58.encode(model.accountId ?? ""))
visible: addressLabel.text && model.isPublic
icon.color: Theme.palette.textMuted
}
}

View File

@ -7,6 +7,7 @@ import Logos.Controls
// TODO: remove relative paths and use qmldir instead
import "../controls"
import "../popups"
import "../Base58.js" as Base58
Rectangle {
id: root
@ -21,7 +22,6 @@ Rectangle {
signal createPrivateAccountRequested()
signal fetchBalancesRequested()
signal copyRequested(string text)
signal copyPublicKeysRequested(string accountIdHex)
radius: Theme.spacing.radiusXlarge
color: Theme.palette.backgroundSecondary
@ -63,7 +63,7 @@ Rectangle {
// Sync progress
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.xsmall
spacing: Theme.spacing.small
visible: root.currentBlockHeight > 0 && root.lastSyncedBlock < root.currentBlockHeight
RowLayout {
@ -127,10 +127,44 @@ Rectangle {
spacing: Theme.spacing.small
model: root.accountModel
delegate: AccountDelegate {
// Each private account's "keysJson"/"sectionKey"/"isFirstInGroup" are plain
// model roles (replicated like any other row data), so the group header for
// the section a row starts is rendered inline here rather than via
// ListView.section section.delegate only gets the section's string value,
// with no way back to that row's data once the model is a remote replica.
delegate: ColumnLayout {
width: listView.width
onCopyRequested: (text) => root.copyRequested(text)
onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id)
spacing: Theme.spacing.small
RowLayout {
Layout.fillWidth: true
visible: model.isFirstInGroup ?? false
spacing: Theme.spacing.small
LogosText {
text: model.isPublic
? qsTr("Public Accounts")
: qsTr("Private — %1").arg(Base58.encode(model.sectionKey ?? "").slice(0, 8))
font.pixelSize: Theme.typography.secondaryText
font.bold: true
color: Theme.palette.textSecondary
}
Item { Layout.fillWidth: true }
LogosCopyButton {
Layout.preferredHeight: 32
Layout.preferredWidth: 32
visible: !model.isPublic
icon.color: Theme.palette.textMuted
onCopyText: root.copyRequested(model.keysJson ?? "")
}
}
AccountDelegate {
Layout.fillWidth: true
onCopyRequested: (text) => root.copyRequested(text)
}
}
}

View File

@ -30,7 +30,6 @@ Rectangle {
signal transferShieldedOwnedRequested(string fromAccountId, string toAccountId, string amount)
signal transferDeshieldedRequested(string fromAccountId, string toAccountId, string amount)
signal copyRequested(string copyText)
signal copyPublicKeysRequested(string accountIdHex)
color: Theme.palette.background
@ -52,7 +51,6 @@ Rectangle {
onCreatePrivateAccountRequested: root.createPrivateAccountRequested()
onFetchBalancesRequested: root.fetchBalancesRequested()
onCopyRequested: (text) => root.copyRequested(text)
onCopyPublicKeysRequested: (id) => root.copyPublicKeysRequested(id)
}
TransferPanel {