feat(portfolio): Market card entries + "Manage" editor dialog

Build the configurable-portfolio UI on the persisted model:
- The portfolio card keeps the "all funds" summary and now renders each custom
  entry below it as a compact row (label · summed DRGX · USD), live-computed via
  SumPortfolioBalance over the wallet's per-address balances; the card grows to
  fit the entries.
- A right-aligned "Manage…" button on the card header opens a modal editor
  (material overlay dialog): list entries with Edit/Delete, "Add entry", and an
  edit form = label field + quick "All shielded / All transparent / Clear"
  selectors + a checklist of wallet addresses (with balances). Persists to
  Settings on each mutation.
- i18n English defaults for the new portfolio_* strings.
- Test namespace fix (AddressInfo is dragonx::, not dragonx::data::).

Full-node + lite build clean; ctest green; source hygiene clean. Remaining:
the Overview-tab right-click "add/remove address to a label" integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:41:20 -05:00
parent ecc0043559
commit 95ddfa05ee
3 changed files with 190 additions and 4 deletions

View File

@@ -7,6 +7,7 @@
#include "../../config/version.h"
#include "../../data/wallet_state.h"
#include "../../config/settings.h"
#include "../../data/portfolio.h"
#include "../../data/exchange_info.h"
#include "../../util/i18n.h"
#include "../schema/ui_schema.h"
@@ -89,6 +90,134 @@ static std::string FormatPrice(double price)
return std::string(buf);
}
// ---- Portfolio editor (modal) state ----
static bool s_portfolio_editor_open = false;
static int s_pf_editing = -1; // -1 = list mode; -2 = adding new; >=0 = editing that index
static char s_pf_label[64] = {0};
static std::vector<std::string> s_pf_addrs;
// Populate the working buffers for a new (index < 0) or existing entry.
static void PortfolioBeginEdit(App* app, int index)
{
const auto& entries = app->settings()->getPortfolioEntries();
s_pf_editing = index;
if (index >= 0 && index < (int)entries.size()) {
snprintf(s_pf_label, sizeof(s_pf_label), "%s", entries[index].label.c_str());
s_pf_addrs = entries[index].addresses;
} else {
s_pf_label[0] = '\0';
s_pf_addrs.clear();
}
}
// The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group
// of wallet addresses. Persists to Settings on every mutation (like the address book).
static void RenderPortfolioEditor(App* app)
{
if (!s_portfolio_editor_open) return;
float dialogW = std::min(560.0f, ImGui::GetMainViewport()->Size.x * 0.9f);
if (!material::BeginOverlayDialog(TR("portfolio_manage_title"), &s_portfolio_editor_open, dialogW)) {
return;
}
config::Settings* settings = app->settings();
const auto& state = app->getWalletState();
auto persist = [&](const std::vector<config::Settings::PortfolioEntry>& entries) {
settings->setPortfolioEntries(entries);
settings->save();
};
if (s_pf_editing == -1) {
// ---- LIST MODE ----
const auto& entries = settings->getPortfolioEntries();
if (entries.empty()) {
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("portfolio_no_entries"));
}
for (int i = 0; i < (int)entries.size(); i++) {
ImGui::PushID(i);
double bal = data::SumPortfolioBalance(entries[i].addresses, state.addresses);
char row[160];
snprintf(row, sizeof(row), "%s \xE2\x80\x94 %.4f %s (%d)",
entries[i].label.c_str(), bal, DRAGONX_TICKER, (int)entries[i].addresses.size());
ImGui::TextUnformatted(row);
ImGui::SameLine();
float bw = 72.0f;
float avail = ImGui::GetContentRegionAvail().x;
if (avail > bw * 2 + Layout::spacingSm())
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - bw * 2 - Layout::spacingSm());
if (ImGui::Button(TR("portfolio_edit"), ImVec2(bw, 0))) PortfolioBeginEdit(app, i);
ImGui::SameLine();
if (ImGui::Button(TR("portfolio_delete"), ImVec2(bw, 0))) {
auto e = entries; e.erase(e.begin() + i); persist(e);
ImGui::PopID();
break;
}
ImGui::PopID();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (ImGui::Button(TR("portfolio_add_entry"))) PortfolioBeginEdit(app, -2);
} else {
// ---- EDIT MODE ----
ImGui::TextUnformatted(TR("portfolio_label"));
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##pfLabel", s_pf_label, sizeof(s_pf_label));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Quick group selectors.
if (ImGui::Button(TR("portfolio_all_shielded"))) {
for (const auto& a : state.addresses)
if (a.type == "shielded") data::PortfolioEntryAdd(s_pf_addrs, a.address);
}
ImGui::SameLine();
if (ImGui::Button(TR("portfolio_all_transparent"))) {
for (const auto& a : state.addresses)
if (a.type == "transparent") data::PortfolioEntryAdd(s_pf_addrs, a.address);
}
ImGui::SameLine();
if (ImGui::Button(TR("portfolio_clear_sel"))) s_pf_addrs.clear();
char selc[48];
snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pf_addrs.size());
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc);
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Address checklist.
ImGui::BeginChild("##pfAddrList", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() * 1.5f), true);
for (const auto& a : state.addresses) {
bool inSet = data::PortfolioEntryContains(s_pf_addrs, a.address);
char line[192];
snprintf(line, sizeof(line), "%s (%.4f %s)", a.address.c_str(), a.balance, DRAGONX_TICKER);
ImGui::PushID(a.address.c_str());
if (ImGui::Checkbox(line, &inSet)) {
if (inSet) data::PortfolioEntryAdd(s_pf_addrs, a.address);
else data::PortfolioEntryRemove(s_pf_addrs, a.address);
}
ImGui::PopID();
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
bool canSave = s_pf_label[0] != '\0';
ImGui::BeginDisabled(!canSave);
if (ImGui::Button(TR("portfolio_save"))) {
auto entries = settings->getPortfolioEntries();
config::Settings::PortfolioEntry e;
e.label = s_pf_label;
e.addresses = s_pf_addrs;
if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) entries[s_pf_editing] = e;
else entries.push_back(e);
persist(entries);
s_pf_editing = -1;
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button(TR("portfolio_cancel"))) s_pf_editing = -1;
}
material::EndOverlayDialog();
}
void RenderMarketTab(App* app)
{
auto& S = schema::UI();
@@ -591,14 +720,31 @@ void RenderMarketTab(App* app)
// ================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
// "Manage…" button, right-aligned on the header row — opens the portfolio editor.
{
const char* ml = TR("portfolio_manage");
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
ImGui::SameLine();
float availX = ImGui::GetContentRegionAvail().x;
if (availX > mBtnW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availX - mBtnW);
if (ImGui::Button(ml, ImVec2(mBtnW, 0))) s_portfolio_editor_open = true;
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
double total_balance = state.totalBalance;
double private_balance = state.privateBalance;
double transparent_balance = state.transparentBalance;
// Custom portfolio entries render below the "all funds" summary; grow the card to fit.
const auto& pfEntries = app->settings()->getPortfolioEntries();
float entryRowH = body2->LegacySize + Layout::spacingSm();
float entriesBlockH = pfEntries.empty()
? 0.0f
: (Layout::spacingSm() * 2.0f + (float)pfEntries.size() * entryRowH);
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float cardH = std::max(50.0f, portfolioBudgetH);
float baseH = std::max(60.0f, portfolioBudgetH);
float cardH = baseH + entriesBlockH;
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
@@ -676,13 +822,36 @@ void RenderMarketTab(App* app)
ImVec2(cx, cy + barH + 2), OnSurfaceDisabled(), buf);
}
float actualCardH = (total_balance > 0) ? std::max(60.0f, portfolioBudgetH) : cardH;
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + actualCardH));
// ---- CUSTOM PORTFOLIO ENTRIES (below the all-funds summary) ----
if (!pfEntries.empty()) {
float ey = cardMin.y + baseH - Layout::spacingSm();
dl->AddLine(ImVec2(cardMin.x + glassSpec.rounding * 0.5f, ey),
ImVec2(cardMax.x - glassSpec.rounding * 0.5f, ey), WithAlpha(OnSurface(), 20), 1.0f);
ey += Layout::spacingSm();
for (const auto& e : pfEntries) {
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, ey), OnSurface(), e.label.c_str());
char amt[96];
if (market.price_usd > 0)
snprintf(amt, sizeof(amt), "%.4f %s ($%.2f)", bal, DRAGONX_TICKER, bal * market.price_usd);
else
snprintf(amt, sizeof(amt), "%.4f %s", bal, DRAGONX_TICKER);
float amtW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, amt).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMax.x - amtW - pad, ey + 1), OnSurfaceMedium(), amt);
ey += entryRowH;
}
}
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + cardH));
ImGui::Dummy(ImVec2(availWidth, 0));
ImGui::Dummy(ImVec2(0, gap));
}
ImGui::EndChild(); // ##MarketScroll
// Portfolio editor modal (rendered outside the scroll child so it overlays the page).
RenderPortfolioEditor(app);
}
} // namespace ui

View File

@@ -1063,6 +1063,23 @@ void I18n::loadBuiltinEnglish()
strings_["market_now"] = "Now";
strings_["market_pct_shielded"] = "%.0f%% Shielded";
strings_["market_portfolio"] = "MY DRGX";
strings_["portfolio_all_funds"] = "All funds";
strings_["portfolio_manage"] = "Manage\xE2\x80\xA6";
strings_["portfolio_manage_title"] = "Manage portfolio";
strings_["portfolio_add_entry"] = "Add entry";
strings_["portfolio_new_entry"] = "New entry";
strings_["portfolio_label"] = "Label";
strings_["portfolio_addresses_sel"] = "%d selected";
strings_["portfolio_all_shielded"] = "All shielded";
strings_["portfolio_all_transparent"] = "All transparent";
strings_["portfolio_clear_sel"] = "Clear";
strings_["portfolio_no_entries"] = "No custom entries yet. Add one to track a group of addresses.";
strings_["portfolio_edit"] = "Edit";
strings_["portfolio_delete"] = "Delete";
strings_["portfolio_save"] = "Save";
strings_["portfolio_cancel"] = "Cancel";
strings_["portfolio_add_to"] = "Add to portfolio";
strings_["portfolio_remove_from"] = "Remove from portfolio";
strings_["market_24h_change"] = "24h";
strings_["market_price_loading"] = "Loading price data...";
strings_["market_price_unavailable"] = "Price data unavailable";

View File

@@ -2585,7 +2585,7 @@ void testConsoleModel()
// Portfolio helpers — sum/query a labeled address group against per-address balances.
void testPortfolioHelpers()
{
using dragonx::data::AddressInfo;
using dragonx::AddressInfo;
using dragonx::data::SumPortfolioBalance;
using dragonx::data::PortfolioEntryContains;
using dragonx::data::PortfolioEntryAdd;