feat(market): portfolio groups — PORTFOLIO heading, clickable cards, icon + color
- Rename the portfolio heading "MY DRGX" -> "PORTFOLIO". - Group cards are now clickable: clicking one opens the editor directly on that group (edit mode), with a hover highlight + hand cursor. - PortfolioEntry gains an `icon` (project_icons wallet-icon name) and a `color` (packed IM_COL32 accent); both are persisted in settings.json. - Editor edit-mode gains an icon picker (the same wallet icon set as the overview tab's address-label dialog, via material::project_icons) with a "None" option, and a color picker (preset accent palette + a default/no-color swatch). - Group cards render the chosen icon to the left of the name and, when a color is set, a left accent bar + the icon tinted in that color. Full-node + Lite build clean; ctest 1/1; hygiene clean. Rendering change — needs a screenshot check of the Market portfolio + the editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -314,6 +314,10 @@ bool Settings::load(const std::string& path)
|
|||||||
if (e.contains("addresses") && e["addresses"].is_array())
|
if (e.contains("addresses") && e["addresses"].is_array())
|
||||||
for (const auto& a : e["addresses"])
|
for (const auto& a : e["addresses"])
|
||||||
if (a.is_string()) entry.addresses.push_back(a.get<std::string>());
|
if (a.is_string()) entry.addresses.push_back(a.get<std::string>());
|
||||||
|
if (e.contains("icon") && e["icon"].is_string())
|
||||||
|
entry.icon = e["icon"].get<std::string>();
|
||||||
|
if (e.contains("color") && e["color"].is_number())
|
||||||
|
entry.color = e["color"].get<unsigned int>();
|
||||||
if (!entry.label.empty()) portfolio_entries_.push_back(std::move(entry));
|
if (!entry.label.empty()) portfolio_entries_.push_back(std::move(entry));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,6 +467,8 @@ bool Settings::save(const std::string& path)
|
|||||||
entry["label"] = e.label;
|
entry["label"] = e.label;
|
||||||
entry["addresses"] = json::array();
|
entry["addresses"] = json::array();
|
||||||
for (const auto& a : e.addresses) entry["addresses"].push_back(a);
|
for (const auto& a : e.addresses) entry["addresses"].push_back(a);
|
||||||
|
entry["icon"] = e.icon;
|
||||||
|
entry["color"] = e.color;
|
||||||
j["portfolio_entries"].push_back(std::move(entry));
|
j["portfolio_entries"].push_back(std::move(entry));
|
||||||
}
|
}
|
||||||
j["font_scale"] = font_scale_;
|
j["font_scale"] = font_scale_;
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ public:
|
|||||||
struct PortfolioEntry {
|
struct PortfolioEntry {
|
||||||
std::string label;
|
std::string label;
|
||||||
std::vector<std::string> addresses;
|
std::vector<std::string> addresses;
|
||||||
|
std::string icon; // project_icons wallet-icon name; empty = no icon
|
||||||
|
unsigned int color = 0; // packed IM_COL32 accent; 0 = theme default
|
||||||
};
|
};
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
#include "../material/colors.h"
|
#include "../material/colors.h"
|
||||||
#include "../material/typography.h"
|
#include "../material/typography.h"
|
||||||
|
#include "../material/project_icons.h"
|
||||||
#include "../../embedded/IconsMaterialDesign.h"
|
#include "../../embedded/IconsMaterialDesign.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
@@ -95,6 +96,20 @@ static bool s_portfolio_editor_open = false;
|
|||||||
static int s_pf_editing = -1; // -1 = list mode; -2 = adding new; >=0 = editing that index
|
static int s_pf_editing = -1; // -1 = list mode; -2 = adding new; >=0 = editing that index
|
||||||
static char s_pf_label[64] = {0};
|
static char s_pf_label[64] = {0};
|
||||||
static std::vector<std::string> s_pf_addrs;
|
static std::vector<std::string> s_pf_addrs;
|
||||||
|
static std::string s_pf_icon; // selected wallet-icon name ("" = none)
|
||||||
|
static unsigned int s_pf_color = 0; // selected accent (packed IM_COL32; 0 = default)
|
||||||
|
|
||||||
|
// Preset accent palette for portfolio groups (0 slot = "default / no color").
|
||||||
|
static const ImU32 kPfPalette[] = {
|
||||||
|
IM_COL32(0x4F, 0x9D, 0xFF, 0xFF), // blue
|
||||||
|
IM_COL32(0x3D, 0xDC, 0x84, 0xFF), // green
|
||||||
|
IM_COL32(0xFF, 0xB0, 0x3D, 0xFF), // amber
|
||||||
|
IM_COL32(0xFF, 0x6B, 0x6B, 0xFF), // red
|
||||||
|
IM_COL32(0xB0, 0x7B, 0xFF, 0xFF), // purple
|
||||||
|
IM_COL32(0x4F, 0xD6, 0xD6, 0xFF), // teal
|
||||||
|
IM_COL32(0xFF, 0x8F, 0xC7, 0xFF), // pink
|
||||||
|
IM_COL32(0xC7, 0xCE, 0xD6, 0xFF), // slate
|
||||||
|
};
|
||||||
|
|
||||||
// Populate the working buffers for a new (index < 0) or existing entry.
|
// Populate the working buffers for a new (index < 0) or existing entry.
|
||||||
static void PortfolioBeginEdit(App* app, int index)
|
static void PortfolioBeginEdit(App* app, int index)
|
||||||
@@ -103,10 +118,14 @@ static void PortfolioBeginEdit(App* app, int index)
|
|||||||
s_pf_editing = index;
|
s_pf_editing = index;
|
||||||
if (index >= 0 && index < (int)entries.size()) {
|
if (index >= 0 && index < (int)entries.size()) {
|
||||||
snprintf(s_pf_label, sizeof(s_pf_label), "%s", entries[index].label.c_str());
|
snprintf(s_pf_label, sizeof(s_pf_label), "%s", entries[index].label.c_str());
|
||||||
s_pf_addrs = entries[index].addresses;
|
s_pf_addrs = entries[index].addresses;
|
||||||
|
s_pf_icon = entries[index].icon;
|
||||||
|
s_pf_color = entries[index].color;
|
||||||
} else {
|
} else {
|
||||||
s_pf_label[0] = '\0';
|
s_pf_label[0] = '\0';
|
||||||
s_pf_addrs.clear();
|
s_pf_addrs.clear();
|
||||||
|
s_pf_icon.clear();
|
||||||
|
s_pf_color = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +183,82 @@ static void RenderPortfolioEditor(App* app)
|
|||||||
ImGui::InputText("##pfLabel", s_pf_label, sizeof(s_pf_label));
|
ImGui::InputText("##pfLabel", s_pf_label, sizeof(s_pf_label));
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
|
||||||
|
// ---- Icon picker (same wallet icon set as the overview tab) ----
|
||||||
|
{
|
||||||
|
float dp = Layout::dpiScale();
|
||||||
|
ImFont* iconFont = Type().iconSmall();
|
||||||
|
float iconFsz = iconFont->LegacySize;
|
||||||
|
float cell = 30.0f * dp, cellGap = 4.0f * dp;
|
||||||
|
ImGui::TextUnformatted(TR("portfolio_icon"));
|
||||||
|
ImGui::BeginChild("##pfIconGrid", ImVec2(0, cell * 2.0f + cellGap + Layout::spacingSm() * 2.0f), true);
|
||||||
|
ImDrawList* gdl = ImGui::GetWindowDrawList();
|
||||||
|
float availW = ImGui::GetContentRegionAvail().x;
|
||||||
|
int cols = std::max(1, (int)((availW + cellGap) / (cell + cellGap)));
|
||||||
|
int total = material::project_icons::walletIconCount() + 1; // slot 0 = "None"
|
||||||
|
int col = 0;
|
||||||
|
for (int k = 0; k < total; k++) {
|
||||||
|
if (col != 0) ImGui::SameLine(0, cellGap);
|
||||||
|
ImVec2 mn = ImGui::GetCursorScreenPos();
|
||||||
|
ImVec2 mx(mn.x + cell, mn.y + cell);
|
||||||
|
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
|
||||||
|
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
|
||||||
|
bool isNone = (k == 0);
|
||||||
|
const char* nm = isNone ? "" : material::project_icons::walletIconName(k - 1);
|
||||||
|
bool sel = isNone ? s_pf_icon.empty() : (s_pf_icon == nm);
|
||||||
|
if (sel) {
|
||||||
|
gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp);
|
||||||
|
gdl->AddRect(mn, mx, WithAlpha(Primary(), 120), 6.0f * dp, 0, 1.5f * dp);
|
||||||
|
} else if (hov) {
|
||||||
|
gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp);
|
||||||
|
}
|
||||||
|
ImU32 icol = sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium());
|
||||||
|
if (isNone) { // slashed circle = "no icon"
|
||||||
|
float r = cell * 0.26f;
|
||||||
|
gdl->AddCircle(cc, r, icol, 0, 1.5f * dp);
|
||||||
|
gdl->AddLine(ImVec2(cc.x - r * 0.7f, cc.y + r * 0.7f),
|
||||||
|
ImVec2(cc.x + r * 0.7f, cc.y - r * 0.7f), icol, 1.5f * dp);
|
||||||
|
} else {
|
||||||
|
material::project_icons::drawByName(gdl, nm, cc, icol, iconFont, iconFsz);
|
||||||
|
}
|
||||||
|
ImGui::PushID(k);
|
||||||
|
ImGui::InvisibleButton("##pfic", ImVec2(cell, cell));
|
||||||
|
if (ImGui::IsItemClicked()) s_pf_icon = isNone ? std::string() : std::string(nm);
|
||||||
|
if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm);
|
||||||
|
ImGui::PopID();
|
||||||
|
col = (col + 1) % cols;
|
||||||
|
}
|
||||||
|
ImGui::EndChild();
|
||||||
|
}
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
|
||||||
|
// ---- Color picker ----
|
||||||
|
{
|
||||||
|
float dp = Layout::dpiScale();
|
||||||
|
float sw = 24.0f * dp;
|
||||||
|
ImGui::TextUnformatted(TR("portfolio_color"));
|
||||||
|
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
||||||
|
int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0]));
|
||||||
|
for (int c = 0; c <= nColors; c++) { // slot 0 = "default / no color"
|
||||||
|
if (c != 0) ImGui::SameLine(0, 6.0f * dp);
|
||||||
|
ImVec2 mn = ImGui::GetCursorScreenPos();
|
||||||
|
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
|
||||||
|
bool isDefault = (c == 0);
|
||||||
|
bool sel = isDefault ? (s_pf_color == 0u) : (s_pf_color == kPfPalette[c - 1]);
|
||||||
|
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
|
||||||
|
if (isDefault)
|
||||||
|
cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
|
||||||
|
else
|
||||||
|
cdl->AddCircleFilled(cc, sw * 0.40f, kPfPalette[c - 1]);
|
||||||
|
if (sel) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
|
||||||
|
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
|
||||||
|
ImGui::PushID(2000 + c);
|
||||||
|
ImGui::InvisibleButton("##pfcol", ImVec2(sw, sw));
|
||||||
|
if (ImGui::IsItemClicked()) s_pf_color = isDefault ? 0u : kPfPalette[c - 1];
|
||||||
|
ImGui::PopID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
// Quick group selectors.
|
// Quick group selectors.
|
||||||
if (ImGui::Button(TR("portfolio_all_shielded"))) {
|
if (ImGui::Button(TR("portfolio_all_shielded"))) {
|
||||||
for (const auto& a : state.addresses)
|
for (const auto& a : state.addresses)
|
||||||
@@ -211,6 +306,8 @@ static void RenderPortfolioEditor(App* app)
|
|||||||
config::Settings::PortfolioEntry e;
|
config::Settings::PortfolioEntry e;
|
||||||
e.label = s_pf_label;
|
e.label = s_pf_label;
|
||||||
e.addresses = s_pf_addrs;
|
e.addresses = s_pf_addrs;
|
||||||
|
e.icon = s_pf_icon;
|
||||||
|
e.color = s_pf_color;
|
||||||
if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) entries[s_pf_editing] = e;
|
if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) entries[s_pf_editing] = e;
|
||||||
else entries.push_back(e);
|
else entries.push_back(e);
|
||||||
persist(entries);
|
persist(entries);
|
||||||
@@ -863,12 +960,22 @@ void RenderMarketTab(App* app)
|
|||||||
int row = i / pfCardsPerRow;
|
int row = i / pfCardsPerRow;
|
||||||
ImVec2 gcMin(cx + col * (pfCardW + pfCardGap), gy + row * (pfCardH + pfCardGap));
|
ImVec2 gcMin(cx + col * (pfCardW + pfCardGap), gy + row * (pfCardH + pfCardGap));
|
||||||
ImVec2 gcMax(gcMin.x + pfCardW, gcMin.y + pfCardH);
|
ImVec2 gcMax(gcMin.x + pfCardW, gcMin.y + pfCardH);
|
||||||
|
ImU32 accent = e.color ? (ImU32)e.color : 0;
|
||||||
|
bool hov = material::IsRectHovered(gcMin, gcMax);
|
||||||
|
|
||||||
GlassPanelSpec gcGlass;
|
GlassPanelSpec gcGlass;
|
||||||
gcGlass.rounding = 10.0f;
|
gcGlass.rounding = 10.0f;
|
||||||
gcGlass.fillAlpha = 22;
|
gcGlass.fillAlpha = hov ? 34 : 22;
|
||||||
gcGlass.borderAlpha = 40;
|
gcGlass.borderAlpha = 40;
|
||||||
DrawGlassPanel(dl, gcMin, gcMax, gcGlass);
|
DrawGlassPanel(dl, gcMin, gcMax, gcGlass);
|
||||||
|
if (hov) dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
|
||||||
|
|
||||||
|
// Left accent bar in the group's color (if one is set).
|
||||||
|
if (accent) {
|
||||||
|
float bx = gcMin.x + 2.0f;
|
||||||
|
dl->AddRectFilled(ImVec2(bx, gcMin.y + pfCardPad * 0.6f),
|
||||||
|
ImVec2(bx + 3.0f, gcMax.y - pfCardPad * 0.6f), accent, 2.0f);
|
||||||
|
}
|
||||||
|
|
||||||
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
|
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
|
||||||
float rowY = gcMin.y + pfCardPad;
|
float rowY = gcMin.y + pfCardPad;
|
||||||
@@ -889,8 +996,19 @@ void RenderMarketTab(App* app)
|
|||||||
OnSurfaceDisabled(), usd);
|
OnSurfaceDisabled(), usd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group name, top-left, larger — truncated so it never overlaps the amount.
|
// Icon (left) then the group name.
|
||||||
float nameMaxW = (gcRight - drgxW - Layout::spacingMd()) - (gcMin.x + pfCardPad);
|
float nameX = gcMin.x + pfCardPad + (accent ? 6.0f : 0.0f);
|
||||||
|
if (!e.icon.empty()) {
|
||||||
|
ImFont* gIcon = Type().iconSmall();
|
||||||
|
float gIconSz = sub1->LegacySize;
|
||||||
|
material::project_icons::drawByName(
|
||||||
|
dl, e.icon.c_str(), ImVec2(nameX + gIconSz * 0.5f, rowY + sub1->LegacySize * 0.5f),
|
||||||
|
accent ? accent : OnSurface(), gIcon, gIconSz);
|
||||||
|
nameX += gIconSz + Layout::spacingSm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group name, larger — truncated so it never overlaps the amount.
|
||||||
|
float nameMaxW = (gcRight - drgxW - Layout::spacingMd()) - nameX;
|
||||||
std::string label = e.label;
|
std::string label = e.label;
|
||||||
bool truncated = false;
|
bool truncated = false;
|
||||||
while (label.size() > 1 &&
|
while (label.size() > 1 &&
|
||||||
@@ -899,7 +1017,15 @@ void RenderMarketTab(App* app)
|
|||||||
truncated = true;
|
truncated = true;
|
||||||
}
|
}
|
||||||
if (truncated) label += "\xE2\x80\xA6";
|
if (truncated) label += "\xE2\x80\xA6";
|
||||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(gcMin.x + pfCardPad, rowY), OnSurface(), label.c_str());
|
dl->AddText(sub1, sub1->LegacySize, ImVec2(nameX, rowY), OnSurface(), label.c_str());
|
||||||
|
|
||||||
|
// Whole card is clickable → open the editor for this group.
|
||||||
|
ImGui::PushID(3000 + i);
|
||||||
|
ImGui::SetCursorScreenPos(gcMin);
|
||||||
|
ImGui::InvisibleButton("##pfCard", ImVec2(pfCardW, pfCardH));
|
||||||
|
if (ImGui::IsItemClicked()) { PortfolioBeginEdit(app, i); s_portfolio_editor_open = true; }
|
||||||
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1117,9 +1117,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["market_no_price"] = "No price data";
|
strings_["market_no_price"] = "No price data";
|
||||||
strings_["market_now"] = "Now";
|
strings_["market_now"] = "Now";
|
||||||
strings_["market_pct_shielded"] = "%.0f%% Shielded";
|
strings_["market_pct_shielded"] = "%.0f%% Shielded";
|
||||||
strings_["market_portfolio"] = "MY DRGX";
|
strings_["market_portfolio"] = "PORTFOLIO";
|
||||||
strings_["portfolio_all_funds"] = "All funds";
|
strings_["portfolio_all_funds"] = "All funds";
|
||||||
strings_["portfolio_manage"] = "Manage\xE2\x80\xA6";
|
strings_["portfolio_manage"] = "Manage\xE2\x80\xA6";
|
||||||
|
strings_["portfolio_icon"] = "Icon";
|
||||||
|
strings_["portfolio_color"] = "Color";
|
||||||
|
strings_["portfolio_no_icon"] = "None";
|
||||||
strings_["portfolio_manage_title"] = "Manage portfolio";
|
strings_["portfolio_manage_title"] = "Manage portfolio";
|
||||||
strings_["portfolio_add_entry"] = "Add entry";
|
strings_["portfolio_add_entry"] = "Add entry";
|
||||||
strings_["portfolio_new_entry"] = "New entry";
|
strings_["portfolio_new_entry"] = "New entry";
|
||||||
|
|||||||
Reference in New Issue
Block a user