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:
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user