Files
ObsidianDragon/src/ui/windows/transactions_tab.cpp
DanS 5796664b51 feat(history): add date and amount sorting to the History tab
Add a sort selector next to the type filter with four modes: Newest first
(default), Oldest first, Largest amount, Smallest amount. The mode folds into
the merged-list memoization cache key (so the list re-sorts only when the mode
changes) and the comparator branches on it, keeping txid as a deterministic
tiebreak. Changing the sort resets to page 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 09:55:24 -05:00

988 lines
50 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "transactions_tab.h"
#include "transaction_details_dialog.h"
#include "export_transactions_dialog.h"
#include "../../app.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/text_format.h"
#include "../../config/settings.h"
#include "../../config/version.h"
#include "../theme.h"
#include "../effects/imgui_acrylic.h"
#include "../layout.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../material/colors.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <ctime>
#include <cmath>
#include <unordered_map>
#include <vector>
namespace dragonx {
namespace ui {
using namespace material;
static std::string TrId(const char* key, const char* id) {
return std::string(TR(key)) + "##" + id;
}
// Helper to truncate strings
static std::string truncateString(const std::string& str, int maxLen = 16) {
if (str.length() <= static_cast<size_t>(maxLen)) return str;
int half = (maxLen - 3) / 2;
return str.substr(0, half) + "..." + str.substr(str.length() - half);
}
// Case-insensitive string search
static bool containsIgnoreCase(const std::string& str, const std::string& search) {
return dragonx::util::containsIgnoreCase(str, search);
}
// A display-ready transaction that may be a merged autoshield pair.
// For non-merged entries, send_idx or recv_idx is -1.
struct DisplayTx {
std::string txid;
std::string display_type; // "send", "receive", "mined", "immature", "shield"
double amount = 0.0;
int64_t timestamp = 0;
int confirmations = 0;
std::string address; // primary display address
std::string from_address;
std::string memo;
int orig_idx = -1; // index into state.transactions (first/primary)
int send_idx = -1; // index of send leg (shield only)
int recv_idx = -1; // index of recv leg (shield only)
bool is_shield = false;
bool isConfirmed() const { return confirmations >= 1; }
std::string getTimeString() const;
};
std::string DisplayTx::getTimeString() const {
if (timestamp <= 0) return TR("pending");
std::time_t t = static_cast<std::time_t>(timestamp);
char buf[64];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", std::localtime(&t));
return buf;
}
// Relative time string (localized long form, e.g. "5 minutes ago")
static std::string timeAgo(int64_t timestamp) {
return dragonx::util::formatTimeAgo(timestamp);
}
// Draw a small transaction-type icon
static void DrawTxIcon(ImDrawList* dl, const std::string& type,
float cx, float cy, float /*s*/, ImU32 col)
{
using namespace material;
ImFont* iconFont = Type().iconSmall();
const char* icon;
if (type == "send") {
icon = ICON_MD_CALL_MADE;
} else if (type == "receive") {
icon = ICON_MD_CALL_RECEIVED;
} else if (type == "shield") {
icon = ICON_MD_CALL_MADE;
} else {
icon = ICON_MD_CONSTRUCTION;
}
ImVec2 sz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(cx - sz.x * 0.5f, cy - sz.y * 0.5f), col, icon);
}
void RenderTransactionsTab(App* app)
{
auto& S = schema::UISchema::instance();
const auto searchInput = S.input("tabs.transactions", "search-input");
const auto filterCombo = S.combo("tabs.transactions", "filter-combo");
const auto filterGapEl = S.drawElement("tabs.transactions", "filter-gap");
const auto txTable = S.table("tabs.transactions", "transaction-table");
const auto addrLabel = S.label("tabs.transactions", "address-label");
const auto& state = app->state();
// Responsive scale factors (recomputed every frame)
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
const float hs = Layout::hScale(contentAvail.x);
const float vs = Layout::vScale(contentAvail.y);
const float glassRound = Layout::glassRounding();
const float innerPad = Layout::cardInnerPadding();
const float cGap = Layout::cardGap();
// Non-scrolling container — content resizes to fit available height
ImVec2 txAvail = ImGui::GetContentRegionAvail();
ImGui::BeginChild("##TxScroll", txAvail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImDrawList* dl = ImGui::GetWindowDrawList();
GlassPanelSpec glassSpec;
glassSpec.rounding = glassRound;
ImFont* ovFont = Type().overline();
ImFont* capFont = Type().caption();
ImFont* body2 = Type().body2();
char buf[128];
ImU32 greenCol = Success();
ImU32 redCol = Error();
ImU32 goldCol = Warning();
std::string txLoadingText = app->transactionRefreshProgressText();
bool txLoading = !txLoadingText.empty();
// Expanded row index for inline detail
static int s_expanded_row = -1;
// Pagination state
static int s_current_page = 0;
static int s_prev_filter_hash = 0; // detect filter changes to reset page
// ================================================================
// Summary Cards — Received | Sent | Mined
// ================================================================
{
int recvCount = 0, sendCount = 0, minedCount = 0;
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg):
// that pair is a single internal shielding move — shown as one "Shield" row in the
// list below — not income or spending. Counting both legs double-counts the amount
// into BOTH the Sent and Received totals, so the cards disagree with the list.
// Mirror the list's pairing logic and exclude both legs from the totals.
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
for (size_t i = 0; i < state.transactions.size(); i++)
summaryTxidMap[state.transactions[i].txid].push_back(i);
std::vector<bool> isShieldLeg(state.transactions.size(), false);
for (const auto& kv : summaryTxidMap) {
if (kv.second.size() < 2) continue;
int send_i = -1, recv_i = -1;
for (size_t si : kv.second) {
const auto& stx = state.transactions[si];
if (stx.type == "send" && send_i < 0) send_i = (int)si;
else if (stx.type == "receive" && recv_i < 0 &&
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
}
if (send_i >= 0 && recv_i >= 0) {
isShieldLeg[send_i] = true;
isShieldLeg[recv_i] = true;
// The list shows the merged shield under the "Sent" filter, so count it there too —
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
// (receive-leg) amount, which is what the merged row displays.
sendCount++;
sendTotal += std::abs(state.transactions[recv_i].amount);
}
}
for (size_t i = 0; i < state.transactions.size(); i++) {
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent
const auto& tx = state.transactions[i];
if (tx.type == "receive") {
recvCount++;
recvTotal += std::abs(tx.amount);
} else if (tx.type == "send") {
sendCount++;
sendTotal += std::abs(tx.amount);
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") {
minedCount++;
minedTotal += std::abs(tx.amount);
}
}
float availWidth = ImGui::GetContentRegionAvail().x;
float cardGap = cGap;
float cardW = (availWidth - 2 * cardGap) / 3.0f;
float cardH = Layout::cardHeight(70.0f, vs);
float iconSz = std::max(4.0f, schema::UI().drawElement("tabs.transactions", "summary-icon-size").size * hs);
ImVec2 origin = ImGui::GetCursorScreenPos();
// Clickable type filter (clicking a card sets the type filter)
static int type_filter = 0;
// Sort mode: 0 = newest first, 1 = oldest first, 2 = largest amount, 3 = smallest amount.
static int s_sort_mode = 0;
// --- Received card ---
{
ImVec2 cMin = origin;
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, glassSpec);
float cx = cMin.x + innerPad;
float cy = cMin.y + Layout::spacingMd();
// Icon
DrawTxIcon(dl, "receive", cx + iconSz, cy + iconSz * 1.33f, iconSz, greenCol);
float labelX = cx + iconSz * 3.0f;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), TR("received_upper"));
cy += ovFont->LegacySize + Layout::spacingSm();
snprintf(buf, sizeof(buf), TR("txs_count"), recvCount);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
cy += capFont->LegacySize + Layout::spacingXs();
snprintf(buf, sizeof(buf), "+%.4f %s", recvTotal, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), greenCol, buf);
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, 40), glassSpec.rounding, 0, 1.5f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) type_filter = (type_filter == 2) ? 0 : 2;
}
}
// --- Sent card ---
{
float xOff = cardW + cardGap;
ImVec2 cMin(origin.x + xOff, origin.y);
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, glassSpec);
float cx = cMin.x + innerPad;
float cy = cMin.y + Layout::spacingMd();
DrawTxIcon(dl, "send", cx + iconSz, cy + iconSz * 1.33f, iconSz, redCol);
float labelX = cx + iconSz * 3.0f;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), TR("sent_upper"));
cy += ovFont->LegacySize + Layout::spacingSm();
snprintf(buf, sizeof(buf), TR("txs_count"), sendCount);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
cy += capFont->LegacySize + Layout::spacingXs();
snprintf(buf, sizeof(buf), "-%.4f %s", sendTotal, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), redCol, buf);
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, 40), glassSpec.rounding, 0, 1.5f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) type_filter = (type_filter == 1) ? 0 : 1;
}
}
// --- Mined card ---
{
float xOff = 2 * (cardW + cardGap);
ImVec2 cMin(origin.x + xOff, origin.y);
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, glassSpec);
float cx = cMin.x + innerPad;
float cy = cMin.y + Layout::spacingMd();
DrawTxIcon(dl, "mined", cx + iconSz, cy + iconSz * 1.33f, iconSz, goldCol);
float labelX = cx + iconSz * 3.0f;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), TR("mined_upper"));
cy += ovFont->LegacySize + Layout::spacingSm();
snprintf(buf, sizeof(buf), TR("txs_count"), minedCount);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
cy += capFont->LegacySize + Layout::spacingXs();
snprintf(buf, sizeof(buf), "+%.4f %s", minedTotal, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), goldCol, buf);
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, 40), glassSpec.rounding, 0, 1.5f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) type_filter = (type_filter == 3) ? 0 : 3;
}
}
// Selected card accent
if (type_filter > 0) {
int idx_map[] = {-1, 1, 0, 2};
int idx = idx_map[type_filter];
float xOff = idx * (cardW + cardGap);
ImVec2 acMin(origin.x + xOff, origin.y + cardH - 3);
ImVec2 acMax(origin.x + xOff + cardW, origin.y + cardH);
ImU32 acCol = (type_filter == 1) ? redCol : (type_filter == 2) ? greenCol : goldCol;
dl->AddRectFilled(acMin, acMax, acCol, 2.0f);
}
ImGui::Dummy(ImVec2(availWidth, cardH));
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// ================================================================
// Search & Filter bar
// ================================================================
static char search_filter[128] = "";
float searchMaxW = (searchInput.maxWidth >= 0) ? searchInput.maxWidth : 300.0f;
float searchRatio = (searchInput.widthRatio >= 0) ? searchInput.widthRatio : 0.30f;
float searchWidth = std::min(searchMaxW * hs, availWidth * searchRatio);
ImGui::SetNextItemWidth(searchWidth);
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, Layout::spacingSm());
ImGui::InputTextWithHint("##TxSearch", TR("search_placeholder"), search_filter, sizeof(search_filter));
ImGui::PopStyleVar();
float filterGap = std::max(8.0f, ((filterGapEl.size > 0) ? filterGapEl.size : 20.0f) * hs);
ImGui::SameLine(0, filterGap);
float comboW = std::max(80.0f, ((filterCombo.width > 0) ? filterCombo.width : 120.0f) * hs);
ImGui::SetNextItemWidth(comboW);
const char* types[] = { TR("all_filter"), TR("sent_filter"), TR("received_filter"), TR("mined_filter") };
ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types));
// Sort selector
ImGui::SameLine(0, filterGap);
ImGui::SetNextItemWidth(comboW);
const char* sorts[] = { TR("sort_date_newest"), TR("sort_date_oldest"),
TR("sort_amount_high"), TR("sort_amount_low") };
ImGui::Combo("##TxSort", &s_sort_mode, sorts, IM_ARRAYSIZE(sorts));
ImGui::SameLine(0, filterGap);
if (TactileButton(TrId("refresh", "tx").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
app->refreshNow();
}
ImGui::SameLine(0, filterGap);
if (TactileButton(TrId("export_csv", "tx").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
ExportTransactionsDialog::show();
}
if (txLoading) {
ImGui::SameLine(0, filterGap);
ImGui::PushFont(Type().iconSmall());
float pulse = 0.55f + 0.45f * std::sin((float)ImGui::GetTime() * 3.0f);
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_HOURGLASS_EMPTY);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4;
const char* dotStr[] = {"", ".", "..", "..."};
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", txLoadingText.c_str(), dotStr[dots]);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm() + Layout::spacingXs()));
// ================================================================
// Transaction list — DrawList-based rows in scrollable child
// ================================================================
int filtered_count = 0;
std::string search_str(search_filter);
// Build display list, merging autoshield send+receive pairs.
// Two entries sharing the same txid where one is "send" and
// one is "receive" to a z-address are combined into a single
// "shield" entry. We first index by txid, then build the list.
//
// This merge + sort is O(N log N) with several heap allocations, so it is MEMOIZED: it only
// rebuilds when the underlying transactions actually change, not every frame. The cache key
// is a cheap, allocation-free FNV-1a fingerprint over the fields that affect the displayed
// rows (count, last update time, and each tx's confirmations / timestamp / type+address
// first char). A new block bumps every confirmation, so the key changes and we rebuild;
// between changes (the common case while the user reads/scrolls) we reuse the cache. The
// result is already sorted newest-first by the refresh service, but we re-sort here to apply
// the "pending first" ordering — also folded into the memoized build.
static std::vector<DisplayTx> s_display_cache;
static std::uint64_t s_display_cache_key = 0;
static bool s_display_cache_valid = false;
std::uint64_t displayKey;
{
std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis
auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
mix(state.transactions.size());
mix(static_cast<std::uint64_t>(state.last_tx_update));
mix(static_cast<std::uint64_t>(s_sort_mode)); // re-sort the cache when the mode changes
for (const auto& t : state.transactions) {
mix(static_cast<std::uint64_t>(t.confirmations));
mix(static_cast<std::uint64_t>(t.timestamp));
mix(t.type.empty() ? 0u : static_cast<unsigned char>(t.type[0]));
mix(t.address.empty() ? 0u : static_cast<unsigned char>(t.address[0]));
}
displayKey = h;
}
if (!s_display_cache_valid || displayKey != s_display_cache_key) {
s_display_cache.clear();
std::vector<DisplayTx>& display_txns = s_display_cache;
// Map txid -> indices in state.transactions
std::unordered_map<std::string, std::vector<size_t>> txid_map;
for (size_t i = 0; i < state.transactions.size(); i++) {
txid_map[state.transactions[i].txid].push_back(i);
}
std::vector<bool> consumed(state.transactions.size(), false);
for (size_t i = 0; i < state.transactions.size(); i++) {
if (consumed[i]) continue;
const auto& tx = state.transactions[i];
// Try to find an autoshield pair (same txid, send+receive to z-addr)
bool merged = false;
const auto& siblings = txid_map[tx.txid];
if (siblings.size() >= 2) {
int send_i = -1, recv_i = -1;
for (size_t si : siblings) {
if (consumed[si]) continue;
const auto& stx = state.transactions[si];
if (stx.type == "send" && send_i < 0) send_i = (int)si;
else if (stx.type == "receive" && recv_i < 0) recv_i = (int)si;
}
if (send_i >= 0 && recv_i >= 0) {
const auto& stx = state.transactions[send_i];
const auto& rtx = state.transactions[recv_i];
// Confirm receive goes to a z-address (shielded)
bool recv_is_shielded = !rtx.address.empty() && rtx.address[0] == 'z';
if (recv_is_shielded) {
DisplayTx dtx;
dtx.txid = tx.txid;
dtx.display_type = "shield";
dtx.is_shield = true;
dtx.amount = rtx.amount; // positive receive amount
dtx.timestamp = std::max(stx.timestamp, rtx.timestamp);
// Both legs are the SAME transaction, so they share one real confirmation
// count — but the send leg (from z_viewtransaction) often comes through
// with confirmations=0. min() would then make a long-confirmed shield look
// pending (conf==0), which the sort floats to the very top, out of date
// order. Take the populated value.
dtx.confirmations = std::max(stx.confirmations, rtx.confirmations);
dtx.address = rtx.address; // shielded destination
dtx.from_address = stx.address.empty() ? stx.from_address : stx.address;
dtx.memo = rtx.memo.empty() ? stx.memo : rtx.memo;
dtx.orig_idx = send_i;
dtx.send_idx = send_i;
dtx.recv_idx = recv_i;
consumed[send_i] = true;
consumed[recv_i] = true;
display_txns.push_back(std::move(dtx));
merged = true;
}
}
}
if (!merged) {
consumed[i] = true;
DisplayTx dtx;
dtx.txid = tx.txid;
dtx.display_type = tx.type;
dtx.amount = tx.amount;
dtx.timestamp = tx.timestamp;
dtx.confirmations = tx.confirmations;
dtx.address = tx.address;
dtx.from_address = tx.from_address;
dtx.memo = tx.memo;
dtx.orig_idx = (int)i;
display_txns.push_back(std::move(dtx));
}
}
// Sort per the selected mode. txid is the final deterministic tiebreak so same-block
// transactions keep a stable order across rebuilds (otherwise equal keys reorder every
// time a new block bumps confirmations).
const int sortMode = s_sort_mode;
std::sort(display_txns.begin(), display_txns.end(),
[sortMode](const DisplayTx& a, const DisplayTx& b) {
switch (sortMode) {
case 1: // Oldest first
if (a.timestamp != b.timestamp) return a.timestamp < b.timestamp;
return a.txid < b.txid;
case 2: { // Largest amount first
double aa = std::abs(a.amount), ba = std::abs(b.amount);
if (aa != ba) return aa > ba;
if (a.timestamp != b.timestamp) return a.timestamp > b.timestamp;
return a.txid > b.txid;
}
case 3: { // Smallest amount first
double aa = std::abs(a.amount), ba = std::abs(b.amount);
if (aa != ba) return aa < ba;
if (a.timestamp != b.timestamp) return a.timestamp > b.timestamp;
return a.txid > b.txid;
}
default: { // 0: Newest first — pending (0-conf) on top, then by date
bool aPending = (a.confirmations == 0);
bool bPending = (b.confirmations == 0);
if (aPending != bPending) return aPending;
if (a.timestamp != b.timestamp) return a.timestamp > b.timestamp;
return a.txid > b.txid;
}
}
});
s_display_cache_key = displayKey;
s_display_cache_valid = true;
}
// The merged + sorted list (rebuilt above only on change). Filtering/search/pagination below
// run per-frame over this cache — they're cheap (a linear scan) and depend on interactive
// filter/search state, so they are intentionally not memoized.
const std::vector<DisplayTx>& display_txns = s_display_cache;
// Apply type + search filters
std::vector<size_t> filtered_indices;
for (size_t i = 0; i < display_txns.size(); i++) {
const auto& dtx = display_txns[i];
if (type_filter != 0) {
if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue;
if (type_filter == 2 && dtx.display_type != "receive") continue;
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
}
if (!search_str.empty()) {
if (!containsIgnoreCase(dtx.address, search_str) &&
!containsIgnoreCase(dtx.txid, search_str) &&
!containsIgnoreCase(dtx.memo, search_str) &&
!(dtx.is_shield && containsIgnoreCase(std::string("shielded"), search_str))) {
continue;
}
}
filtered_indices.push_back(i);
}
filtered_count = static_cast<int>(filtered_indices.size());
// Pagination — slice filtered results into pages
int perPage = std::max(10, (int)schema::UI().drawElement("tabs.transactions", "rows-per-page").sizeOr(50.0f));
int totalPages = std::max(1, (filtered_count + perPage - 1) / perPage);
// Reset page when filters change
int filterHash = type_filter * 1000003 + s_sort_mode * 7919 + filtered_count * 31 + static_cast<int>(search_str.size());
if (filterHash != s_prev_filter_hash) {
s_current_page = 0;
s_prev_filter_hash = filterHash;
}
if (s_current_page >= totalPages) s_current_page = totalPages - 1;
if (s_current_page < 0) s_current_page = 0;
int pageStart = s_current_page * perPage;
int pageEnd = std::min(pageStart + perPage, filtered_count);
// ---- Heading line: "TRANSACTIONS" left, pagination right ----
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transactions_upper"));
if (totalPages > 1) {
float paginationH = ImGui::GetFrameHeight();
float btnW = paginationH;
float gap = Layout::spacingSm();
float pageNumW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, " 999 / 999 ").x + Layout::spacingLg();
float totalPagW = btnW * 4 + pageNumW + gap * 4;
// Right-align: position cursor so the group ends at the right edge
ImGui::SameLine();
float startX = ImGui::GetContentRegionMax().x - totalPagW;
ImGui::SetCursorPosX(startX);
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, Layout::spacingSm());
ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f));
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 15)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 30)));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 45)));
// First page
ImGui::BeginDisabled(s_current_page == 0);
ImGui::PushFont(Type().iconSmall());
if (ImGui::Button(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW))) {
s_current_page = 0;
s_expanded_row = -1;
}
ImGui::PopFont();
ImGui::EndDisabled();
ImGui::SameLine(0, gap);
// Previous page
ImGui::BeginDisabled(s_current_page == 0);
ImGui::PushFont(Type().iconSmall());
if (ImGui::Button(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW))) {
s_current_page--;
s_expanded_row = -1;
}
ImGui::PopFont();
ImGui::EndDisabled();
ImGui::SameLine(0, gap);
// Page indicator — render centered text over a fixed-width dummy
{
snprintf(buf, sizeof(buf), "%d / %d", s_current_page + 1, totalPages);
ImVec2 pageSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
ImVec2 regionPos = ImGui::GetCursorScreenPos();
// Reserve the fixed width without advancing to a new line
ImGui::InvisibleButton("##pageNum", ImVec2(pageNumW, paginationH));
// Draw the text centered within that reserved area
ImVec2 textPos(
regionPos.x + (pageNumW - pageSz.x) * 0.5f,
regionPos.y + (paginationH - pageSz.y) * 0.5f
);
ImGui::GetWindowDrawList()->AddText(capFont, capFont->LegacySize, textPos, OnSurfaceMedium(), buf);
}
ImGui::SameLine(0, gap);
// Next page
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
ImGui::PushFont(Type().iconSmall());
if (ImGui::Button(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW))) {
s_current_page++;
s_expanded_row = -1;
}
ImGui::PopFont();
ImGui::EndDisabled();
ImGui::SameLine(0, gap);
// Last page
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
ImGui::PushFont(Type().iconSmall());
if (ImGui::Button(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW))) {
s_current_page = totalPages - 1;
s_expanded_row = -1;
}
ImGui::PopFont();
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
ImGui::PopStyleVar(2);
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Glass panel wrapping the list area — scale reserve with vScale
float scaledReserve = (txTable.bottomReserve > 0) ? txTable.bottomReserve : std::max(schema::UI().drawElement("tabs.transactions", "bottom-reserve-min").size, schema::UI().drawElement("tabs.transactions", "bottom-reserve-base").size * vs);
float listH = ImGui::GetContentRegionAvail().y - scaledReserve;
float minListH = std::max(schema::UI().drawElement("tabs.transactions", "list-min-height").size, schema::UI().drawElement("tabs.transactions", "list-base-height").size * vs);
if (listH < minListH) listH = minListH;
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
ImVec2 listPanelMax(listPanelMin.x + availWidth, listPanelMin.y + listH);
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
// Scroll state for clipping mask (captured inside child, used after EndChild)
float scrollY = 0.0f;
float scrollMaxY = 0.0f;
// Vertex start indices for CSS-style clipping mask (alpha fade at edges)
int vtxMaskStart = dl->VtxBuffer.Size;
ImGui::BeginChild("##TxList", ImVec2(availWidth, listH), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* childDL = ImGui::GetWindowDrawList();
int childVtxStart = childDL->VtxBuffer.Size;
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
{
if (!app->isConnected()) {
ImGui::Dummy(ImVec2(0, 20));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected"));
} else if (state.transactions.empty()) {
ImGui::Dummy(ImVec2(0, 20));
if (txLoading) {
int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4;
const char* dotStr[] = {"", ".", "..", "..."};
snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), dotStr[dots]);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions"));
}
} else if (filtered_indices.empty()) {
ImGui::Dummy(ImVec2(0, 20));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_matching"));
} else {
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
float innerW = ImGui::GetContentRegionAvail().x;
float rowIconSz = std::max(3.5f, schema::UI().drawElement("tabs.transactions", "row-icon-size").size * hs);
float rowPadLeft = Layout::spacingLg();
// Scroll state for gradient overlay fade (drawn after EndChild)
scrollY = ImGui::GetScrollY();
scrollMaxY = ImGui::GetScrollMaxY();
// Render only the current page slice (pagination already bounds per-frame work)
for (int fi = pageStart; fi < pageEnd; fi++) {
size_t i = filtered_indices[fi];
const auto& tx = display_txns[i];
bool is_expanded = (s_expanded_row == static_cast<int>(i));
ImGui::PushID(static_cast<int>(i));
ImVec2 rowPos = ImGui::GetCursorScreenPos();
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
// Determine type info
bool shieldedDisplay = tx.display_type == "shield";
ImU32 iconCol;
const char* typeStr;
if (shieldedDisplay) {
iconCol = redCol; typeStr = TR("sent_type");
} else if (tx.display_type == "receive") {
iconCol = greenCol; typeStr = TR("recv_type");
} else if (tx.display_type == "send") {
iconCol = redCol; typeStr = TR("sent_type");
} else if (tx.display_type == "immature") {
iconCol = Warning(); typeStr = TR("immature_type");
} else {
iconCol = goldCol; typeStr = TR("mined_type");
}
// Expanded selection accent
if (is_expanded) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), schema::UI().drawElement("tabs.transactions", "row-hover-rounding").size);
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + schema::UI().drawElement("tabs.transactions", "row-accent-width").size, rowEnd.y), Primary(), schema::UI().drawElement("tabs.transactions", "accent-bar-rounding").size);
}
// Hover glow
bool hovered = material::IsRectHovered(rowPos, rowEnd);
if (hovered && !is_expanded) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), schema::UI().drawElement("tabs.transactions", "row-hover-rounding").size);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
float cx = rowPos.x + rowPadLeft;
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawTxIcon(dl, shieldedDisplay ? "send" : tx.display_type,
cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
// Type label
float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), iconCol, typeStr);
// Time (next to type)
std::string ago = timeAgo(tx.timestamp);
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeStr).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
OnSurfaceDisabled(), ago.c_str());
// Address (second line, left side)
std::string addr_display = truncateString(tx.address, (addrLabel.truncate > 0) ? addrLabel.truncate : 20);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), addr_display.c_str());
// Amount (right-aligned, first line)
ImU32 amtCol = (tx.amount >= 0) ? greenCol : redCol;
if (tx.amount >= 0)
snprintf(buf, sizeof(buf), "+%.8f", tx.amount);
else
snprintf(buf, sizeof(buf), "%.8f", tx.amount);
ImVec2 amtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf);
float amtX = rowPos.x + innerW - amtSz.x - Layout::spacingLg();
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(amtX, cy), amtCol, buf,
1.0f, 1.0f, IM_COL32(0, 0, 0, 120));
// USD equivalent (right-aligned, second line)
double priceUsd = state.market.price_usd;
if (priceUsd > 0.0) {
double usdVal = std::abs(tx.amount) * priceUsd;
if (usdVal >= 1.0)
snprintf(buf, sizeof(buf), "$%.2f", usdVal);
else if (usdVal >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", usdVal);
else
snprintf(buf, sizeof(buf), "$%.6f", usdVal);
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowPos.x + innerW - usdSz.x - Layout::spacingLg(), cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceDisabled(), buf);
}
// Status badge (centered area, second line)
{
const char* statusStr;
ImU32 statusCol;
if (tx.confirmations == 0) {
statusStr = TR("pending"); statusCol = Warning();
} else if (tx.confirmations < 10) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
statusStr = buf; statusCol = Warning();
} else if (tx.confirmations >= 100 && (tx.display_type == "generate" || tx.display_type == "mined")) {
statusStr = TR("mature"); statusCol = greenCol;
} else {
statusStr = TR("confirmed"); statusCol = WithAlpha(Success(), 140);
}
// Position status badge in the middle-right area
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
const char* shieldedStr = TR("shielded_type");
ImVec2 shieldSz = shieldedDisplay
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, shieldedStr)
: ImVec2(0, 0);
float shieldPillW = shieldSz.x + Layout::spacingSm() * 2.0f;
float stackW = shieldedDisplay ? std::max(sSz.x, shieldPillW) : sSz.x;
float statusX = amtX - stackW - Layout::spacingXxl();
float minStatusX = cx + innerW * 0.25f; // don't overlap address
if (statusX < minStatusX) statusX = minStatusX;
float statusTextX = statusX + (stackW - sSz.x) * 0.5f;
if (shieldedDisplay) {
float shieldX = statusX + (stackW - shieldSz.x) * 0.5f;
ImU32 shieldCol = Primary();
ImU32 shieldBg = (shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
ImVec2 shieldPillMin(shieldX - Layout::spacingSm(), cy - 1.0f);
ImVec2 shieldPillMax(shieldX + shieldSz.x + Layout::spacingSm(),
shieldPillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(shieldPillMin, shieldPillMax, shieldBg,
schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(shieldX, cy), shieldCol, shieldedStr);
}
// Background pill
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1);
ImVec2 pillMax(statusTextX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(statusTextX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
}
// Click to expand/collapse + invisible button for interaction
ImGui::InvisibleButton("##txRow", ImVec2(innerW, rowH));
if (ImGui::IsItemClicked(0)) {
s_expanded_row = is_expanded ? -1 : static_cast<int>(i);
}
// Tooltip
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s\n%s\n%s", tx.address.c_str(),
tx.txid.c_str(), tx.getTimeString().c_str());
}
// Context menu
const auto& acrylicTheme = GetCurrentAcrylicTheme();
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
ImGui::SetClipboardText(tx.address.c_str());
}
if (ImGui::MenuItem(TR("copy_txid"))) {
ImGui::SetClipboardText(tx.txid.c_str());
}
ImGui::Separator();
if (ImGui::MenuItem(TR("view_on_explorer"))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
util::Platform::openUrl(url);
}
if (ImGui::MenuItem(TR("view_details"))) {
if (tx.orig_idx >= 0 && tx.orig_idx < (int)state.transactions.size())
TransactionDetailsDialog::show(state.transactions[tx.orig_idx]);
}
effects::ImGuiAcrylic::EndAcrylicPopup();
}
// ---- Inline detail expansion ----
if (is_expanded) {
ImVec2 detailPos = ImGui::GetCursorScreenPos();
// We'll draw the glass panel after measuring the content
float detailPad = Layout::spacingLg();
float detailW = innerW - detailPad * 2;
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + detailPad);
ImGui::BeginGroup();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + detailW);
// From address
if (!tx.from_address.empty()) {
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("from_upper"));
ImGui::TextWrapped("%s", tx.from_address.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
// To address
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(),
tx.display_type == "send" ? TR("to_upper") : (tx.display_type == "shield" ? TR("shielded_to") : TR("address_upper")));
ImGui::TextWrapped("%s", tx.address.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// TxID (full, copyable)
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
ImGui::TextWrapped("%s", tx.txid.c_str());
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsItemClicked()) ImGui::SetClipboardText(tx.txid.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Memo
if (!tx.memo.empty()) {
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("memo_upper"));
ImGui::TextWrapped("%s", tx.memo.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
// Confirmations + time
snprintf(buf, sizeof(buf), TR("confirmations_display"),
tx.confirmations, tx.getTimeString().c_str());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Action buttons
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 15)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 30)));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.txid.c_str());
}
ImGui::SameLine();
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.address.c_str());
}
ImGui::SameLine();
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
util::Platform::openUrl(url);
}
ImGui::SameLine();
if (TactileSmallButton(TrId("full_details", "detail").c_str(), S.resolveFont("button"))) {
if (tx.orig_idx >= 0 && tx.orig_idx < (int)state.transactions.size())
TransactionDetailsDialog::show(state.transactions[tx.orig_idx]);
}
ImGui::PopStyleVar();
ImGui::PopStyleColor(2);
ImGui::PopTextWrapPos();
ImGui::EndGroup();
// Draw glass panel behind detail area
ImVec2 detailEnd = ImGui::GetCursorScreenPos();
float detailH = detailEnd.y - detailPos.y + Layout::spacingMd();
GlassPanelSpec detailGlass;
detailGlass.rounding = glassRound * 0.75f;
detailGlass.fillAlpha = 25;
DrawGlassPanel(dl, ImVec2(detailPos.x + Layout::spacingSm() + Layout::spacingXs(), detailPos.y),
ImVec2(detailPos.x + innerW - Layout::spacingSm() - Layout::spacingXs(), detailPos.y + detailH), detailGlass);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
// Subtle divider between rows
if (fi < pageEnd - 1 && !is_expanded) {
ImVec2 divStart = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(divStart.x + rowPadLeft + rowIconSz * 2.0f, divStart.y),
ImVec2(divStart.x + innerW - Layout::spacingLg(), divStart.y),
IM_COL32(255, 255, 255, 15));
}
ImGui::PopID();
}
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::EndChild();
// CSS-style clipping mask
{
float fadeZone = std::min(
(body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd()) * 1.2f,
listH * 0.18f);
ApplyScrollEdgeMask(dl, vtxMaskStart, childDL, childVtxStart,
listPanelMin.y, listPanelMax.y, fadeZone, scrollY, scrollMaxY);
}
// Status line with page info
snprintf(buf, sizeof(buf), TR("showing_transactions"),
filtered_count > 0 ? pageStart + 1 : 0, pageEnd, filtered_count, state.transactions.size());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
ImGui::EndChild(); // ##TxScroll
}
} // namespace ui
} // namespace dragonx