// 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 <cstring>
#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
    // ================================================================
    {
        // Content fingerprint over state.transactions, shared by the summary-card totals (below) and
        // the display-list memoization (further down). This is a cheap, allocation-free FNV-1a hash
        // computed ONCE per frame and folds in every field the *totals* and the *displayed rows*
        // depend on: tx count, last update time, and per-tx txid (drives autoshield pairing),
        // amount (drives the totals — bit-exact), confirmations, timestamp, type, and address. It is
        // sort-independent (s_sort_mode is NOT folded here); the display key mixes s_sort_mode on top
        // so a sort change re-sorts the display cache without invalidating the totals (which don't
        // depend on order). NB vs. the old display key: this additionally folds full txid + amount +
        // full type/address (not just first char), which the summary totals require — the old key
        // omitted them, so it could not have gated the totals correctly.
        std::uint64_t contentKey;
        {
            std::uint64_t h = 1469598103934665603ULL;  // FNV-1a offset basis
            auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
            auto mixBytes = [&mix](const std::string& s) {
                mix(s.size());
                for (unsigned char c : s) mix(static_cast<std::uint64_t>(c));
            };
            mix(state.transactions.size());
            mix(static_cast<std::uint64_t>(state.last_tx_update));
            for (const auto& t : state.transactions) {
                mixBytes(t.txid);
                std::uint64_t amtBits = 0;
                std::memcpy(&amtBits, &t.amount, sizeof(double));  // bit-exact fold of the double
                mix(amtBits);
                mix(static_cast<std::uint64_t>(t.confirmations));
                mix(static_cast<std::uint64_t>(t.timestamp));
                mixBytes(t.type);
                mixBytes(t.address);
            }
            contentKey = h;
        }

        // Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
        // numbers that only change when the tx list changes, so memoize them behind contentKey and
        // recompute only when it moves. File-scope-style function statics persist across wallet
        // switches, but a switch/refresh mutates state.transactions (count, txids, amounts …), which
        // bumps contentKey, so the sentinel initial key + change detection covers that for free.
        static int    s_recvCount = 0, s_sendCount = 0, s_minedCount = 0;
        static double s_recvTotal = 0.0, s_sendTotal = 0.0, s_minedTotal = 0.0;
        static std::uint64_t s_summaryKey = 0;  // sentinel; first frame always recomputes

        if (contentKey != s_summaryKey) {
            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);
                }
            }

            s_recvCount = recvCount;   s_recvTotal = recvTotal;
            s_sendCount = sendCount;   s_sendTotal = sendTotal;
            s_minedCount = minedCount; s_minedTotal = minedTotal;
            s_summaryKey = contentKey;
        }

        // Render from the cached totals (identical values — only the recomputation is skipped).
        const int    recvCount = s_recvCount,  sendCount = s_sendCount,  minedCount = s_minedCount;
        const double recvTotal = s_recvTotal,  sendTotal = s_sendTotal,  minedTotal = s_minedTotal;

        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 * vs);
            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 * hs);
        }

        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 — the sort labels ("Newest first"/localized) run longer than the type
        // labels, so this combo needs its own width. Size it to the widest localized sort option
        // measured with the active (default) font — the same font Combo renders with — plus ImGui's
        // combo chrome (2x horizontal frame padding + the dropdown arrow button, GetFrameHeight()).
        // CalcTextSize and the style paddings are already DPI-scaled, so this grows at font_scale 1.5
        // without any hs multiply; clamp to at least the shared type-filter comboW.
        ImGui::SameLine(0, filterGap);
        const char* sorts[] = { TR("sort_date_newest"), TR("sort_date_oldest"),
                                TR("sort_amount_high"), TR("sort_amount_low") };
        float sortTextW = 0.0f;
        for (const char* s : sorts) sortTextW = std::max(sortTextW, ImGui::CalcTextSize(s).x);
        float sortComboW = std::max(comboW,
                                    sortTextW + ImGui::GetStyle().FramePadding.x * 2.0f
                                        + ImGui::GetFrameHeight());
        ImGui::SetNextItemWidth(sortComboW);
        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
        // reuses the shared per-frame contentKey computed above (a cheap, allocation-free FNV-1a
        // fingerprint over count / last update time / every tx's txid+amount+confirmations+timestamp+
        // type+address) and mixes in s_sort_mode on top, so the display cache also rebuilds on a sort
        // change. contentKey already folds in more than the display list strictly needs (amount,
        // full txid/type/address) — a superset, so any change that would have flipped the old
        // per-first-char key still flips this one. 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;

        // Derive the display key from the shared contentKey (avoids a second full pass over the
        // transactions) plus the sort mode — the only display-affecting input contentKey omits.
        std::uint64_t displayKey =
            (contentKey ^ (static_cast<std::uint64_t>(s_sort_mode) + 0x9E3779B97F4A7C15ULL))
            * 1099511628211ULL;

        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);

                // First page — TactileButton (shared glass press, matches the explorer pager)
                ImGui::BeginDisabled(s_current_page == 0);
                if (TactileButton(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW), Type().iconSmall())) {
                    s_current_page = 0;
                    s_expanded_row = -1;
                }
                ImGui::EndDisabled();

                ImGui::SameLine(0, gap);

                // Previous page
                ImGui::BeginDisabled(s_current_page == 0);
                if (TactileButton(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW), Type().iconSmall())) {
                    s_current_page--;
                    s_expanded_row = -1;
                }
                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);
                if (TactileButton(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW), Type().iconSmall())) {
                    s_current_page++;
                    s_expanded_row = -1;
                }
                ImGui::EndDisabled();

                ImGui::SameLine(0, gap);

                // Last page
                ImGui::BeginDisabled(s_current_page >= totalPages - 1);
                if (TactileButton(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW), Type().iconSmall())) {
                    s_current_page = totalPages - 1;
                    s_expanded_row = -1;
                }
                ImGui::EndDisabled();
            }
        }
        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()) {
                material::DrawEmptyState(ICON_MD_CLOUD_OFF,
                                         TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected"));
            } else if (state.transactions.empty()) {
                if (txLoading) {
                    snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), material::LoadingDots());
                    material::DrawEmptyState(ICON_MD_HOURGLASS_EMPTY, buf);
                } else {
                    material::DrawEmptyState(ICON_MD_RECEIPT_LONG, TR("no_transactions"));
                }
            } else if (filtered_indices.empty()) {
                material::DrawEmptyState(ICON_MD_SEARCH_OFF, 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
