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>
988 lines
50 KiB
C++
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
|