feat(balance): inline period-earnings in the address list and totals

Surfaces the period-earnings aggregator in the Balance tab:
- Toolbar (shared address list): interval pills (1H/1D/1M/1Y), a received-
  vs-net mode toggle, and a global show-all toggle, placed right of "Hide 0
  Balances". The New Z-Address / New mining address buttons move to a footer
  row across from "Showing X of Y" to make room.
- Per address: the row draws its earnings on line 2, left of the $USD value
  (green positive, red negative in net mode), shown when the global toggle
  is on or the address is opted in via a new "Show/Hide earnings" right-click
  item. Works in every layout via the shared list.
- Total: FormatTotalEarnings (gated on the global toggle, so the default
  appearance is unchanged) is wired into the compact hero (dashboard/minimal)
  and the classic layout, left of / beside their USD figure. The remaining
  layouts' totals are not wired yet.
- Settings > Appearance: an "Earnings Display Format" selector (Coin /
  Coin + fiat / Fiat).

New i18n keys are translated into all eight locales; the CJK subset font is
rebuilt for the new glyphs. Builds + test suites pass on both variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:22:12 -05:00
co-authored by Claude Opus 4.8
parent 2ef91ea7d4
commit 064c9d1b61
14 changed files with 335 additions and 40 deletions
+191 -39
View File
@@ -36,6 +36,8 @@
#include <cstring>
#include <cstdio>
#include <ctime>
#include <chrono>
#include "../../data/earnings_series.h"
#include <cmath>
#include <string>
#include <vector>
@@ -56,6 +58,44 @@ void UpdateBalanceLerp(App* app) {
StepDisplayLerp(s_dispUnconfirmed, state.unconfirmed_balance, dt, kBalanceLerpSpeed, instant);
}
// Total period-earnings for the whole wallet, formatted per the earnings settings. Only shown when the
// global earnings toggle is on, so the default hero/layout appearance is unchanged. Own revision-keyed
// cache (independent of the per-row one in RenderSharedAddressList).
bool FormatTotalEarnings(App* app, std::string& outText, ImU32& outCol) {
using namespace material;
if (!app->settings() || !app->settings()->getEarningsShowAll()) return false;
auto& S = schema::UI();
const auto& state = app->state();
auto* est = app->settings();
static dragonx::data::EarningsCache s_totalEarnCache;
auto iv = (dragonx::data::EarningsInterval)est->getEarningsInterval();
auto md = (dragonx::data::EarningsMode)est->getEarningsMode();
const int fmt = est->getEarningsFormat();
const std::int64_t nowSec = (std::int64_t)std::time(nullptr);
const std::int64_t nowMs = (std::int64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
dragonx::data::EarningsKey ek;
ek.txRevision = state.tx_revision;
ek.txCount = state.transactions.size();
ek.interval = iv;
ek.mode = md;
ek.periodStart = dragonx::data::earningsPeriodStart(nowSec, iv);
const auto& r = s_totalEarnCache.get(ek, state.transactions, nowSec, nowMs);
const double ev = r.total;
const double fiat = ev * state.market.price_usd;
const bool haveFiat = state.market.price_usd > 0.0;
const int f = haveFiat ? fmt : 0;
char buf[64];
if (f == 0) snprintf(buf, sizeof(buf), "%+.4f", ev);
else if (f == 2) snprintf(buf, sizeof(buf), "%s$%.2f", ev < 0 ? "-" : "+", std::fabs(fiat));
else snprintf(buf, sizeof(buf), "%+.4f ($%.2f)", ev, fiat);
outText = buf;
outCol = ev > 1e-12 ? S.resolveColor("var(--accent-shielded)", Success())
: (ev < -1e-12 ? S.resolveColor("var(--accent-error)", ReadableError())
: OnSurfaceDisabled());
return true;
}
// Render compact hero line: logo + balance + USD + mining on one line
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs, float heroHeightOverride) {
using namespace material;
@@ -96,6 +136,18 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
ImGui::Dummy(ImVec2(capFont->CalcTextSizeA(tickerFontSize, 10000, 0, DRAGONX_TICKER).x + Layout::spacingLg(), lineH));
ImGui::SameLine();
// Total period-earnings (left of the USD) when the global earnings toggle is on.
{
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
dl->AddText(capFont, tickerFontSize,
ImVec2(ImGui::GetCursorScreenPos().x, tickerY), earnCol, earnText.c_str());
float ew = capFont->CalcTextSizeA(tickerFontSize, 10000, 0, earnText.c_str()).x;
ImGui::Dummy(ImVec2(ew + Layout::spacingLg(), lineH));
ImGui::SameLine();
}
}
// USD value
const auto& state = app->state();
double usd_value = state.getBalanceUSD();
@@ -164,6 +216,29 @@ void RenderSharedAddressList(App* app, float listH, float availW,
addRows(state.t_addresses, false);
auto rows = BuildAddressListRows(rowInputs, addr_search, s_hideZeroBalances, s_showHidden);
// ---- Inline period-earnings: compute once (cached on tx_revision + interval/mode/period) ----
static dragonx::data::EarningsCache s_earnCache;
const dragonx::data::EarningsResult* earnResult = nullptr;
bool earnShowAll = false;
int earnFormat = 1; // 0=coin 1=coin+fiat 2=fiat
if (app->settings()) {
auto* est = app->settings();
earnShowAll = est->getEarningsShowAll();
earnFormat = est->getEarningsFormat();
auto iv = (dragonx::data::EarningsInterval)est->getEarningsInterval();
auto md = (dragonx::data::EarningsMode)est->getEarningsMode();
const std::int64_t nowSec = (std::int64_t)std::time(nullptr);
const std::int64_t nowMs = (std::int64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
dragonx::data::EarningsKey ek;
ek.txRevision = state.tx_revision;
ek.txCount = state.transactions.size();
ek.interval = iv;
ek.mode = md;
ek.periodStart = dragonx::data::earningsPeriodStart(nowSec, iv);
earnResult = &s_earnCache.get(ek, state.transactions, nowSec, nowMs);
}
// ---- Toolbar: search + checkboxes + create buttons ----
float avail = ImGui::GetContentRegionAvail().x;
float schemaMaxW = (searchIn.maxWidth >= 0) ? searchIn.maxWidth : 250.0f;
@@ -185,39 +260,46 @@ void RenderSharedAddressList(App* app, float listH, float availW,
}
}
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
float totalButtonsWidth = buttonWidth * 2 + spacing;
float kMinButtonsPosition = std::max(S.drawElement("tabs.balance", "min-buttons-position").size,
S.drawElement("tabs.balance", "buttons-position").size * hs);
ImGui::SameLine(std::max(kMinButtonsPosition,
ImGui::GetWindowWidth() - totalButtonsWidth -
S.drawElement("tabs.balance", "button-row-right-margin").sizeOr(16.0f)));
// ---- Inline period-earnings controls: interval pills (1H/1D/1M/1Y), mode toggle (received /
// net), and a global show-all toggle. The create-address buttons moved below the list (footer). ----
if (app->settings()) {
auto* est = app->settings();
ImFont* icoF = Type().iconSmall();
const float segH = ImGui::GetFrameHeight(); // fixed height so pills + icon buttons align
// A compact selected/unselected toggle. `font` nullptr = default font (text pills).
auto segBtn = [&](const char* idlabel, bool selected, const char* tip, ImFont* font) -> bool {
if (font) ImGui::PushFont(font);
ImGui::PushStyleColor(ImGuiCol_Button, selected ? WithAlpha(Primary(), 70) : IM_COL32(255, 255, 255, 12));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, WithAlpha(Primary(), 110));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, WithAlpha(Primary(), 140));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(selected ? OnSurface() : OnSurfaceMedium()));
bool clk = ImGui::Button(idlabel, ImVec2(0, segH));
ImGui::PopStyleColor(4);
if (font) ImGui::PopFont();
if (tip && ImGui::IsItemHovered()) material::Tooltip("%s", tip);
return clk;
};
bool sharedAddrSyncing = state.sync.syncing && !state.sync.isSynced();
ImGui::BeginDisabled(sharedAddrSyncing);
if (s_generating_z_address) {
char genLabel[64];
snprintf(genLabel, sizeof(genLabel), "%s%s##shared_z", TR("generating"), material::LoadingDots());
TactileButton(genLabel, ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font));
} else if (TactileButton(TR("new_z_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
s_generating_z_address = true;
app->createNewZAddress([](const std::string& addr) {
s_generating_z_address = false;
DEBUG_LOGF("Created new z-address: %s\n", addr.c_str());
});
ImGui::SameLine(0, Layout::spacingLg());
// Interval pills
const char* ivText[4] = { "1H", "1D", "1M", "1Y" };
int iv = est->getEarningsInterval();
for (int k = 0; k < 4; ++k) {
char lbl[16]; snprintf(lbl, sizeof(lbl), "%s##earniv%d", ivText[k], k);
if (segBtn(lbl, iv == k, TR("tt_earn_interval"), nullptr)) { est->setEarningsInterval(k); est->save(); }
if (k < 3) ImGui::SameLine(0, 2.0f * dp);
}
// Mode toggle (received-only vs net)
ImGui::SameLine(0, Layout::spacingMd());
int emode = est->getEarningsMode();
if (segBtn(ICON_MD_CALL_RECEIVED "##earnmodein", emode == 0, TR("tt_earn_mode_inflows"), icoF)) { est->setEarningsMode(0); est->save(); }
ImGui::SameLine(0, 2.0f * dp);
if (segBtn(ICON_MD_SWAP_VERT "##earnmodenet", emode == 1, TR("tt_earn_mode_net"), icoF)) { est->setEarningsMode(1); est->save(); }
// Global earnings-column toggle
ImGui::SameLine(0, Layout::spacingMd());
bool showAll = est->getEarningsShowAll();
if (segBtn(ICON_MD_TRENDING_UP "##earnshowall", showAll, TR("tt_earn_show_all"), icoF)) { est->setEarningsShowAll(!showAll); est->save(); }
}
ImGui::SameLine();
if (TactileButton(TR("new_mining_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
app->createNewTAddress([](const std::string& addr) {
DEBUG_LOGF("Created new t-address: %s\n", addr.c_str());
});
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_new_mining_address"));
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -279,6 +361,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
addrScrollMaxY = ImGui::GetScrollMaxY();
ImU32 greenCol = S.resolveColor("var(--accent-shielded)", Success());
ImU32 goldCol = S.resolveColor("var(--accent-transparent)", Warning());
ImU32 earnNegCol = S.resolveColor("var(--accent-error)", material::ReadableError()); // negative net earnings
float rowPadLeft = Layout::cardInnerPadding();
float rowPadRight = Layout::cardInnerPadding();
float rowIconSz = std::max(S.drawElement("tabs.balance", "address-icon-min-size").size,
@@ -594,9 +677,33 @@ void RenderSharedAddressList(App* app, float listH, float availW,
}
}
// ---- Line-2 right content widths: USD + optional period-earnings to its left ----
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
float usdW = usdText.empty() ? 0.0f
: capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str()).x;
const bool haveFiat = state.market.price_usd > 0.0;
bool earnShown = earnResult && app->settings() &&
(earnShowAll || app->settings()->isAddressEarningsShown(addr.address));
char earnBuf[48]; earnBuf[0] = '\0';
float earnW = 0.0f; ImU32 earnCol = OnSurfaceDisabled();
if (earnShown) {
double ev = dragonx::data::earningsForAddress(*earnResult, addr.address);
double fiat = ev * state.market.price_usd;
int fmt = (!haveFiat) ? 0 : earnFormat; // no price -> coin only
if (fmt == 0) snprintf(earnBuf, sizeof(earnBuf), "%+.4f", ev);
else if (fmt == 2) snprintf(earnBuf, sizeof(earnBuf), "%s$%.2f", ev < 0 ? "-" : "+", std::fabs(fiat));
else snprintf(earnBuf, sizeof(earnBuf), "%+.4f ($%.2f)", ev, fiat);
earnW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, earnBuf).x;
earnCol = ev > 1e-12 ? greenCol : (ev < -1e-12 ? earnNegCol : OnSurfaceDisabled());
if (row.hidden) earnCol = OnSurfaceDisabled();
}
// Left edge of the line-2 right content, so the address truncates before it.
const float line2RightStart = contentRight - usdW
- (earnShown && earnBuf[0] ? (earnW + Layout::spacingMd()) : 0.0f);
// ---- Address (second line) ----
{
float addrAvailW = contentRight - labelX;
float addrAvailW = line2RightStart - Layout::spacingMd() - labelX;
ImVec2 fullAddrSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, addr.address.c_str());
std::string display_addr;
if (fullAddrSz.x <= addrAvailW) {
@@ -624,14 +731,17 @@ void RenderSharedAddressList(App* app, float listH, float availW,
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(balX, cy), balCol, balBuf);
}
// ---- USD value (second line, right-aligned) ----
// ---- Line 2 right: period-earnings (left of) + USD value, right-aligned ----
{
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
if (!usdText.empty()) {
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str());
const float line2Y = cy + body2->LegacySize + Layout::spacingXs();
if (earnShown && earnBuf[0]) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(contentRight - usdSz.x,
cy + body2->LegacySize + Layout::spacingXs()),
ImVec2(contentRight - usdW - Layout::spacingMd() - earnW, line2Y),
earnCol, earnBuf);
}
if (!usdText.empty()) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(contentRight - usdW, line2Y),
OnSurfaceDisabled(), usdText.c_str());
}
}
@@ -728,6 +838,15 @@ void RenderSharedAddressList(App* app, float listH, float availW,
if (ImGui::MenuItem(TR("favorite_address")))
app->favoriteAddress(addr.address);
}
// Per-address earnings column toggle (when the global show-all toggle is off).
if (app->settings()) {
const bool shown = app->settings()->isAddressEarningsShown(addr.address);
if (ImGui::MenuItem(shown ? TR("hide_earnings") : TR("show_earnings"))) {
if (shown) app->settings()->hideAddressEarnings(addr.address);
else app->settings()->showAddressEarnings(addr.address);
app->settings()->save();
}
}
// Portfolio membership — toggle this address in each Market-tab portfolio entry.
ImGui::Separator();
if (ImGui::BeginMenu(TR("portfolio_add_to"))) {
@@ -818,7 +937,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
listPanelMin.y, listPanelMax.y, fadeZone, addrScrollY, addrScrollMaxY);
}
// Address count
// Address count + create-address buttons (relocated below the list to free the toolbar row for
// the earnings controls). Count on the left, New Z / New mining right-aligned on the same row.
{
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
char countBuf[128];
@@ -826,6 +946,38 @@ void RenderSharedAddressList(App* app, float listH, float availW,
snprintf(countBuf, sizeof(countBuf), TR("showing_x_of_y"),
(int)rows.size(), totalAddrs);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), countBuf);
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
float totalButtonsWidth = buttonWidth * 2 + spacing;
float rightMargin = S.drawElement("tabs.balance", "button-row-right-margin").sizeOr(16.0f);
ImGui::SameLine(std::max(ImGui::GetCursorPosX(),
ImGui::GetWindowWidth() - totalButtonsWidth - rightMargin));
bool sharedAddrSyncing = state.sync.syncing && !state.sync.isSynced();
ImGui::BeginDisabled(sharedAddrSyncing);
if (s_generating_z_address) {
char genLabel[64];
snprintf(genLabel, sizeof(genLabel), "%s%s##shared_z", TR("generating"), material::LoadingDots());
TactileButton(genLabel, ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font));
} else if (TactileButton(TR("new_z_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
s_generating_z_address = true;
app->createNewZAddress([](const std::string& addr) {
s_generating_z_address = false;
DEBUG_LOGF("Created new z-address: %s\n", addr.c_str());
});
}
ImGui::SameLine();
if (TactileButton(TR("new_mining_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
app->createNewTAddress([](const std::string& addr) {
DEBUG_LOGF("Created new t-address: %s\n", addr.c_str());
});
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_new_mining_address"));
ImGui::EndDisabled();
}
}