The Sent summary card showed 0 while selecting the Sent filter listed transactions. The card counted only plain "send" rows and deliberately excluded both legs of an autoshield pair as an "internal move", but the list shows the merged "shield" row under the Sent filter. With only shielding transactions and no plain sends, the card read 0 against a non-empty Sent list. Count each shield pair toward the Sent card (with the shielded receive-leg amount, which is what the merged row displays), so the card and the filter agree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
958 lines
48 KiB
C++
958 lines
48 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;
|
|
|
|
// --- 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));
|
|
|
|
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));
|
|
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: pending (0-conf) transactions first, then newest-first by timestamp, with txid as
|
|
// a final deterministic tiebreak so same-block transactions keep a stable order across
|
|
// rebuilds (otherwise equal timestamps reorder every time a new block bumps confirmations).
|
|
std::sort(display_txns.begin(), display_txns.end(),
|
|
[](const DisplayTx& a, const DisplayTx& b) {
|
|
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 + 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
|