Transactions that carried a chat message (sent or received, messages + contact requests) now show a "Message" badge in the History tab, and a new "Chat" option in the type-filter combo shows only those transactions. Detection reuses what the chat store already tracks: ChatStore gains an inline chatTxids() returning the set of on-chain txids that carried a chat message; the tab builds it once per frame (cheap — O(chat messages)) and tests txid membership for the badge and the filter. Empty when chat is disabled or the wallet has no chat identity. Both variants populate the chat store before this tab renders, so the same code serves full-node and lite with no separate handling. The "Message" badge reuses the stacked top-pill slot (chat txs are send/receive, not the autoshield "shield" type, so it and the "Shielded" badge are mutually exclusive; chat takes precedence) in a distinct Secondary() colour. Guards the summary-card accent idx_map so the new filter value (4) can't index it OOB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
956 lines
50 KiB
C++
956 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 <unordered_set>
|
|
#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;
|
|
}
|
|
|
|
// 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");
|
|
return dragonx::util::formatClockDateTime(timestamp); // honors the app-wide 24h/12h clock
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Draw one Received/Sent/Mined summary card: icon + label + count + signed total, with a
|
|
// hover outline and click-to-toggle type filter. All three cards are identical apart from the
|
|
// per-card data passed here. cMin is the card's top-left; filterValue is the type_filter value
|
|
// this card selects (clicking again clears it).
|
|
static void drawSummaryCard(ImDrawList* dl, ImVec2 cMin, float cardW, float cardH,
|
|
float innerPad, float iconSz, const GlassPanelSpec& glassSpec,
|
|
ImFont* ovFont, ImFont* capFont, ImFont* body2,
|
|
const char* iconType, ImU32 accentCol, const char* labelKey,
|
|
int count, double total, const char* signPrefix,
|
|
int filterValue, int& type_filter)
|
|
{
|
|
char buf[128];
|
|
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, iconType, cx + iconSz, cy + iconSz * 1.33f, iconSz, accentCol);
|
|
|
|
float labelX = cx + iconSz * 3.0f;
|
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), TR(labelKey));
|
|
cy += ovFont->LegacySize + Layout::spacingSm();
|
|
|
|
snprintf(buf, sizeof(buf), TR("txs_count"), count);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
|
|
cy += capFont->LegacySize + Layout::spacingXs();
|
|
|
|
snprintf(buf, sizeof(buf), "%s%.4f %s", signPrefix, total, DRAGONX_TICKER);
|
|
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), accentCol, 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 == filterValue) ? 0 : filterValue;
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
// Txids that carried a chat message (sent or received) — drives the "Message" badge and the Chat
|
|
// filter below. Rebuilt each frame from the in-memory chat store (cheap: O(chat messages), which
|
|
// is small); empty when chat is disabled or the wallet has no chat identity. Both variants populate
|
|
// the chat store before this tab renders, so the same lookup serves full-node and lite.
|
|
const std::unordered_set<std::string> chatTxids = app->chatService().store().chatTxids();
|
|
|
|
// 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 / Sent / Mined cards ---
|
|
// filter values: Received = 2, Sent = 1, Mined = 3 (see idx_map for the accent bar below)
|
|
drawSummaryCard(dl, origin, cardW, cardH, innerPad, iconSz, glassSpec,
|
|
ovFont, capFont, body2, "receive", greenCol, "received_upper",
|
|
recvCount, recvTotal, "+", 2, type_filter);
|
|
drawSummaryCard(dl, ImVec2(origin.x + (cardW + cardGap), origin.y), cardW, cardH,
|
|
innerPad, iconSz, glassSpec, ovFont, capFont, body2, "send", redCol, "sent_upper",
|
|
sendCount, sendTotal, "-", 1, type_filter);
|
|
drawSummaryCard(dl, ImVec2(origin.x + 2 * (cardW + cardGap), origin.y), cardW, cardH,
|
|
innerPad, iconSz, glassSpec, ovFont, capFont, body2, "mined", goldCol, "mined_upper",
|
|
minedCount, minedTotal, "+", 3, type_filter);
|
|
|
|
// Selected card accent (only the three summary cards map to a card position — the Chat filter
|
|
// (4) has no card, so guard the idx_map lookup to 1..3).
|
|
if (type_filter > 0 && type_filter <= 3) {
|
|
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"), TR("chat_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());
|
|
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", txLoadingText.c_str(), material::LoadingDots());
|
|
}
|
|
|
|
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 (type_filter == 4 && chatTxids.count(dtx.txid) == 0) continue; // chat-only
|
|
}
|
|
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) {
|
|
snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), material::LoadingDots());
|
|
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";
|
|
const bool isChatTx = chatTxids.count(tx.txid) > 0; // carried a chat message
|
|
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 = util::truncateMiddle(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 = Success();
|
|
}
|
|
// Position status badge in the middle-right area
|
|
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
|
|
// Optional "top" badge stacked above the status pill: "Message" for a chat tx or
|
|
// "Shielded" for an autoshield. Chat txs are send/receive (not the "shield" type),
|
|
// so the two are mutually exclusive; chat takes precedence.
|
|
const bool showTopBadge = isChatTx || shieldedDisplay;
|
|
const char* topBadgeStr = isChatTx ? TR("tx_chat_badge") : TR("shielded_type");
|
|
const ImU32 topBadgeCol = isChatTx ? Secondary() : Primary();
|
|
ImVec2 topSz = showTopBadge
|
|
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, topBadgeStr)
|
|
: ImVec2(0, 0);
|
|
float topPillW = topSz.x + Layout::spacingSm() * 2.0f;
|
|
float stackW = showTopBadge ? std::max(sSz.x, topPillW) : 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;
|
|
// Pills use a visible tinted fill + a 1px border so each state separates from the
|
|
// row background on every skin (faint alpha-30 fills used to vanish on dark-red /
|
|
// near-white / gradient skins). Fill 48, border 90 of the state color's RGB.
|
|
const float pillRound = schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size;
|
|
if (showTopBadge) {
|
|
float topX = statusX + (stackW - topSz.x) * 0.5f;
|
|
ImVec2 topPillMin(topX - Layout::spacingSm(), cy - 1.0f);
|
|
ImVec2 topPillMax(topX + topSz.x + Layout::spacingSm(),
|
|
topPillMin.y + capFont->LegacySize + Layout::spacingXs());
|
|
dl->AddRectFilled(topPillMin, topPillMax,
|
|
(topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
|
|
dl->AddRect(topPillMin, topPillMax,
|
|
(topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(topX, cy), topBadgeCol, topBadgeStr);
|
|
}
|
|
// Background pill
|
|
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,
|
|
(statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
|
|
dl->AddRect(pillMin, pillMax,
|
|
(statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
|
|
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()) {
|
|
material::Tooltip("%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
|