From 95ddfa05eee3578c829d5643140252505621dd4b Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 1 Jul 2026 22:41:20 -0500 Subject: [PATCH] feat(portfolio): Market card entries + "Manage" editor dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ui/windows/market_tab.cpp | 175 +++++++++++++++++++++++++++++++++- src/util/i18n.cpp | 17 ++++ tests/test_phase4.cpp | 2 +- 3 files changed, 190 insertions(+), 4 deletions(-) diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index cb7287f..6b34914 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -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 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& 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 diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 2ace211..42731df 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index aa17a4c..4bc8ba3 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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;