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