Files
ObsidianDragon/src/ui/windows/send_tab.cpp
dan_s d684db446e Refactor app services and stabilize refresh/UI flows
- Add refresh scheduler and network refresh service boundaries for typed
  refresh results, ordered RPC collectors, applicators, and price parsing.
- Add daemon lifecycle and wallet security workflow helpers while preserving
  App-owned command RPC, decrypt, cancellation, and UI handoff behavior.
- Split balance, console, mining, amount formatting, and async task logic into
  focused modules with expanded Phase 4 test coverage.
- Fix market price loading by triggering price refresh immediately, avoiding
  queue-pressure drops, tracking loading/error state, and adding translations.
- Polish send, explorer, peers, settings, theme/schema, and related tab UI.
- Replace checked-in generated language headers with build-generated resources.
- Document the cleanup audit, UI static-state guidance, and architecture updates.
2026-04-29 12:47:57 -05:00

1530 lines
73 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Layout E: Top Bar Source + Bottom Form
// - Persistent source address bar at top (always visible)
// - Centered compose form below
// - Recent sends at bottom
#include "send_tab.h"
#include "../../app.h"
#include "../../config/version.h"
#include "../../data/wallet_state.h"
#include "../../util/i18n.h"
#include "../notifications.h"
#include "../layout.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../material/colors.h"
#include "../theme.h"
#include "../effects/imgui_acrylic.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <string>
#include <vector>
#include <algorithm>
#include <cmath>
#include <ctime>
namespace dragonx {
namespace ui {
using namespace material;
// ============================================================================
// Form state
// ============================================================================
static char s_from_address[512] = "";
static char s_to_address[512] = "";
static double s_amount = 0.0;
static char s_memo[512] = "";
static double s_fee = DRAGONX_DEFAULT_FEE;
static bool s_send_max = false;
static int s_selected_from_idx = -1;
static std::string s_tx_status = "";
static bool s_sending = false;
// Transaction progress state
static double s_status_timestamp = 0.0;
static bool s_status_success = false;
static std::string s_result_txid = "";
static double s_send_start_time = 0.0;
// Undo buffer for clear action
struct FormSnapshot {
char from_address[512];
char to_address[512];
double amount;
char memo[512];
double fee;
bool send_max;
int selected_from_idx;
};
static FormSnapshot s_undo_snapshot;
static double s_undo_timestamp = 0.0;
static constexpr double UNDO_TIMEOUT = 8.0;
// Fee selection
static int s_fee_tier = 1; // 0=low, 1=normal, 2=high
static constexpr double FEE_LOW = DRAGONX_DEFAULT_FEE * 0.5;
static constexpr double FEE_NORMAL = DRAGONX_DEFAULT_FEE;
static constexpr double FEE_HIGH = DRAGONX_DEFAULT_FEE * 2.0;
static bool s_clear_confirm_pending = false;
// Source dropdown preview string
static std::string s_source_preview;
// Amount input mode: false = DRGX, true = USD
static bool s_input_usd = false;
static double s_usd_amount = 0.0; // tracks USD input when in USD mode
static bool s_auto_selected = false; // tracks if auto-selection has been done
static bool s_show_confirm = false; // persistent: keeps popup open at top-level scope
// ============================================================================
// Helpers
// ============================================================================
static bool FormHasData() {
return (s_to_address[0] != '\0' || s_amount > 0.0 || s_memo[0] != '\0');
}
static void SaveFormSnapshot() {
snprintf(s_undo_snapshot.from_address, sizeof(s_undo_snapshot.from_address), "%s", s_from_address);
snprintf(s_undo_snapshot.to_address, sizeof(s_undo_snapshot.to_address), "%s", s_to_address);
s_undo_snapshot.amount = s_amount;
snprintf(s_undo_snapshot.memo, sizeof(s_undo_snapshot.memo), "%s", s_memo);
s_undo_snapshot.fee = s_fee;
s_undo_snapshot.send_max = s_send_max;
s_undo_snapshot.selected_from_idx = s_selected_from_idx;
s_undo_timestamp = ImGui::GetTime();
}
static void RestoreFormSnapshot() {
snprintf(s_from_address, sizeof(s_from_address), "%s", s_undo_snapshot.from_address);
snprintf(s_to_address, sizeof(s_to_address), "%s", s_undo_snapshot.to_address);
s_amount = s_undo_snapshot.amount;
snprintf(s_memo, sizeof(s_memo), "%s", s_undo_snapshot.memo);
s_fee = s_undo_snapshot.fee;
s_send_max = s_undo_snapshot.send_max;
s_selected_from_idx = s_undo_snapshot.selected_from_idx;
s_undo_timestamp = 0.0;
}
static void ClearFormWithUndo() {
SaveFormSnapshot();
s_from_address[0] = '\0';
s_to_address[0] = '\0';
s_amount = 0.0;
s_memo[0] = '\0';
s_fee = DRAGONX_DEFAULT_FEE;
s_send_max = false;
s_selected_from_idx = -1;
s_auto_selected = false;
s_tx_status = "";
s_fee_tier = 1;
}
static double GetAvailableBalance(App* app) {
const auto& state = app->getWalletState();
if (s_selected_from_idx >= 0 && s_selected_from_idx < static_cast<int>(state.addresses.size())) {
return state.addresses[s_selected_from_idx].balance;
}
return 0.0;
}
static std::string TruncateAddress(const std::string& addr, size_t maxLen = 40) {
if (addr.length() <= maxLen) return addr;
size_t halfLen = (maxLen - 3) / 2;
return addr.substr(0, halfLen) + "..." + addr.substr(addr.length() - halfLen);
}
static std::string timeAgo(int64_t timestamp) {
if (timestamp <= 0) return "";
int64_t now = (int64_t)std::time(nullptr);
int64_t diff = now - timestamp;
if (diff < 0) diff = 0;
if (diff < 60) return std::to_string(diff) + "s ago";
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
return std::to_string(diff / 86400) + "d ago";
}
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 {
icon = ICON_MD_CONSTRUCTION; // mined
}
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);
}
static void PasteClipboardToAddress() {
const char* clipboard = ImGui::GetClipboardText();
if (clipboard) {
std::string trimmed(clipboard);
while (!trimmed.empty() && (trimmed.front() == ' ' || trimmed.front() == '\t' ||
trimmed.front() == '\n' || trimmed.front() == '\r'))
trimmed.erase(trimmed.begin());
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\t' ||
trimmed.back() == '\n' || trimmed.back() == '\r'))
trimmed.pop_back();
snprintf(s_to_address, sizeof(s_to_address), "%s", trimmed.c_str());
}
}
// ============================================================================
// Sync banner
// ============================================================================
static void RenderSyncBanner(const WalletState& state) {
if (!state.sync.syncing || state.sync.isSynced()) return;
float syncPct = (state.sync.headers > 0)
? (float)state.sync.blocks / state.sync.headers * 100.0f : 0.0f;
char syncBuf[128];
snprintf(syncBuf, sizeof(syncBuf),
TR("blockchain_syncing"), syncPct);
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(schema::UI().resolveColor(schema::UI().drawElement("tabs.send", "sync-banner-bg-color").color)));
float syncH = std::max(schema::UI().drawElement("tabs.send", "sync-banner-min-height").size, schema::UI().drawElement("tabs.send", "sync-banner-height").size * Layout::vScale());
ImGui::BeginChild("##SyncBanner", ImVec2(ImGui::GetContentRegionAvail().x, syncH),
false, ImGuiWindowFlags_NoScrollbar);
ImGui::SetCursorPos(ImVec2(Layout::spacingLg(), (syncH - Type().caption()->LegacySize) * 0.5f));
Type().textColored(TypeStyle::Caption, Warning(), syncBuf);
ImGui::EndChild();
ImGui::PopStyleColor();
}
// ============================================================================
// Source Address Dropdown — simple combo selector
// ============================================================================
static void RenderSourceDropdown(App* app, float width) {
const auto& state = app->getWalletState();
auto& S = schema::UI();
char buf[256];
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_sending_from"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Auto-select the address with the largest balance on first load
if (!s_auto_selected && app->isConnected() && !state.addresses.empty()) {
int bestIdx = bestSpendableAddressIndex(state.addresses);
if (bestIdx >= 0) {
s_selected_from_idx = bestIdx;
snprintf(s_from_address, sizeof(s_from_address), "%s",
state.addresses[bestIdx].address.c_str());
}
s_auto_selected = true;
}
// Build preview string for selected address
if (!app->isConnected()) {
s_source_preview = TR("not_connected");
} else if (s_selected_from_idx >= 0 &&
s_selected_from_idx < (int)state.addresses.size()) {
const auto& addr = state.addresses[s_selected_from_idx];
bool isZ = addr.type == "shielded";
const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = TruncateAddress(addr.address,
static_cast<size_t>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
s_source_preview = buf;
} else {
s_source_preview = TR("send_select_source");
}
ImGui::SetNextItemWidth(width);
ImGui::PushFont(Type().getFont(TypeStyle::Body2));
if (ImGui::BeginCombo("##SendFrom", s_source_preview.c_str())) {
if (!app->isConnected() || state.addresses.empty()) {
ImGui::TextDisabled("%s", TR("no_addresses_available"));
} else {
// Sort by balance descending, only show spendable addresses with balance
std::vector<size_t> sortedIdx = sortedSpendableAddressIndices(state.addresses);
if (sortedIdx.empty()) {
ImGui::TextDisabled("%s", TR("send_no_balance"));
} else {
size_t addrTruncLen = static_cast<size_t>(std::max(S.drawElement("tabs.send", "addr-dropdown-trunc-min").size, width / S.drawElement("tabs.send", "addr-dropdown-trunc-divisor").size));
for (size_t si = 0; si < sortedIdx.size(); si++) {
size_t i = sortedIdx[si];
const auto& addr = state.addresses[i];
bool isCurrent = (s_selected_from_idx == static_cast<int>(i));
bool isZ = addr.type == "shielded";
const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = TruncateAddress(addr.address, addrTruncLen);
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(buf, isCurrent)) {
s_selected_from_idx = static_cast<int>(i);
snprintf(s_from_address, sizeof(s_from_address), "%s",
addr.address.c_str());
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s\nBalance: %.8f %s",
addr.address.c_str(), addr.balance, DRAGONX_TICKER);
}
ImGui::PopID();
}
}
}
ImGui::EndCombo();
}
ImGui::PopFont();
}
// ============================================================================
// Address suggestions dropdown
// ============================================================================
static void RenderAddressSuggestions(const WalletState& state, float width, const char* childId) {
std::string partial(s_to_address);
if (partial.length() < 2) return;
bool is_valid_z = (s_to_address[0] == 'z' && s_to_address[1] == 's' && strlen(s_to_address) > 60);
bool is_valid_t = (s_to_address[0] == 'R' && strlen(s_to_address) >= 34);
if (is_valid_z || is_valid_t) return;
std::vector<std::string> suggestions;
for (const auto& tx : state.transactions) {
if (tx.type != "send" || tx.address.empty()) continue;
if (tx.address.find(partial) != std::string::npos) {
bool dup = false;
for (const auto& s : suggestions) { if (s == tx.address) { dup = true; break; } }
if (!dup) suggestions.push_back(tx.address);
if (suggestions.size() >= (size_t)schema::UI().drawElement("tabs.send", "max-suggestions").size) break;
}
}
if (suggestions.empty()) return;
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(schema::UI().resolveColor(schema::UI().drawElement("tabs.send", "suggestion-bg-color").color)));
float sugH = std::min((float)suggestions.size() * schema::UI().drawElement("tabs.send", "suggestion-row-height").size + schema::UI().drawElement("tabs.send", "suggestion-list-padding").size, schema::UI().drawElement("tabs.send", "suggestion-max-height").size);
ImGui::BeginChild(childId, ImVec2(width, sugH), true);
for (size_t si = 0; si < suggestions.size(); si++) {
int sugTrunc = (int)schema::UI().drawElement("tabs.send", "suggestion-trunc-len").size;
std::string dispSug = TruncateAddress(suggestions[si], sugTrunc > 0 ? sugTrunc : 50);
ImGui::PushID((int)si);
if (ImGui::Selectable(dispSug.c_str())) {
snprintf(s_to_address, sizeof(s_to_address), "%s", suggestions[si].c_str());
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", suggestions[si].c_str());
}
ImGui::PopID();
}
ImGui::EndChild();
ImGui::PopStyleColor();
}
// ============================================================================
// Fee tier selector
// ============================================================================
static void RenderFeeTierSelector(const char* suffix = "") {
auto& S = schema::UI();
const char* feeLabels[] = { TR("send_fee_low"), TR("send_fee_normal"), TR("send_fee_high") };
const double feeValues[] = { FEE_LOW, FEE_NORMAL, FEE_HIGH };
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.send", "fee-rounding").size);
for (int fi = 0; fi < 3; fi++) {
if (fi > 0) ImGui::SameLine(0, S.drawElement("tabs.send", "fee-tier-gap").size);
bool active = (s_fee_tier == fi);
if (active) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "fee-tier-active-bg-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Primary()));
}
char feeId[32];
snprintf(feeId, sizeof(feeId), "%s%s", feeLabels[fi], suffix);
if (TactileSmallButton(feeId, S.resolveFont("button"))) {
s_fee_tier = fi;
s_fee = feeValues[fi];
}
if (active) ImGui::PopStyleColor(2);
}
ImGui::PopStyleVar();
ImGui::PopStyleColor();
}
// ============================================================================
// Combined amount slider + usage bar
// ============================================================================
static void RenderAmountBar(ImDrawList* dl, double available, float innerW,
ImFont* capFont, double usd_price = 0.0,
const char* suffix = "") {
double maxAmount = available - s_fee;
if (maxAmount < 0) maxAmount = 0;
float progress = (available > 0)
? std::clamp((float)((s_amount + s_fee) / available), 0.0f, 1.0f)
: 0.0f;
float maxBtnW = schema::UI().drawElement("tabs.send", "amount-bar-max-btn-width").size;
float gap = Layout::spacingMd();
float barW = innerW - maxBtnW - gap;
if (barW < schema::UI().drawElement("tabs.send", "progress-bar-min-width").size) barW = schema::UI().drawElement("tabs.send", "progress-bar-min-width").size;
float barH = schema::UI().drawElement("tabs.send", "amount-bar-height").size;
float barRound = barH * 0.5f;
ImVec2 barMin = ImGui::GetCursorScreenPos();
ImVec2 barMax(barMin.x + barW, barMin.y + barH);
// Track background
dl->AddRectFilled(barMin, barMax, IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.send", "amount-bar-track-alpha").size), barRound);
// Color-coded fill
int fillAlpha = (int)schema::UI().drawElement("tabs.send", "progress-fill-alpha").size;
ImU32 fillCol;
if (progress < schema::UI().drawElement("tabs.send", "progress-threshold-ok").size) fillCol = WithAlpha(Success(), fillAlpha);
else if (progress < schema::UI().drawElement("tabs.send", "progress-threshold-warn").size) fillCol = WithAlpha(Warning(), fillAlpha);
else fillCol = WithAlpha(Error(), fillAlpha);
// Map fill so its right-side rounding matches the thumb circle exactly
float thumbR = barH * 0.5f;
float usableW = barW - thumbR * 2.0f;
float thumbCenterX = barMin.x + thumbR + usableW * progress;
// Extend fill to the right edge of the thumb circle so the rounded
// corner (radius = barRound = thumbR) produces the same semicircle
float fillRight = thumbCenterX + thumbR;
if (fillRight > barMin.x + barRound * 2.0f) {
dl->AddRectFilled(barMin, ImVec2(fillRight, barMax.y),
fillCol, barRound);
} else if (progress > 0.0f) {
// Very small fill — just draw a circle at the thumb position
dl->AddCircleFilled(ImVec2(thumbCenterX, barMin.y + barH * 0.5f),
thumbR, fillCol, 24);
}
// Percentage label centered on bar
char pctBuf[32];
snprintf(pctBuf, sizeof(pctBuf), "%.0f%%", progress * 100.0f);
ImVec2 textSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, pctBuf);
float textX = barMin.x + (barW - textSz.x) * 0.5f;
float textY = barMin.y + (barH - textSz.y) * 0.5f;
dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, textY),
IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.send", "progress-pct-text-alpha").size), pctBuf);
// Invisible button for click-to-set interaction
char barId[32];
snprintf(barId, sizeof(barId), "##AmtBar%s", suffix);
ImGui::InvisibleButton(barId, ImVec2(barW, barH));
bool barActive = ImGui::IsItemActive();
bool barHovered = ImGui::IsItemHovered();
if (barActive) {
float mouseX = ImGui::GetIO().MousePos.x;
float clickPct = std::clamp((mouseX - barMin.x) / barW, 0.0f, 1.0f);
s_amount = maxAmount * clickPct;
if (s_amount < 0) s_amount = 0;
s_send_max = (clickPct >= 1.0f);
// Sync USD amount so the USD input field stays in sync
if (usd_price > 0.0)
s_usd_amount = s_amount * usd_price;
// Recalculate fill position for the thumb while dragging
progress = (available > 0)
? std::clamp((float)((s_amount + s_fee) / available), 0.0f, 1.0f)
: 0.0f;
}
if (barHovered) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
char tipBuf[128];
snprintf(tipBuf, sizeof(tipBuf), "%.8f / %.8f %s (%.1f%%)",
s_amount, maxAmount > 0 ? maxAmount : 0.0, DRAGONX_TICKER,
progress * 100.0f);
ImGui::SetTooltip("%s", tipBuf);
}
// Glass thumb circle at the fill edge
{
// thumbCenterX already computed above
float thumbY = barMin.y + barH * 0.5f;
// Glass circle body — translucent fill with border
dl->AddCircleFilled(ImVec2(thumbCenterX, thumbY), thumbR,
IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.send", "thumb-fill-alpha").size), 24);
dl->AddCircle(ImVec2(thumbCenterX, thumbY), thumbR,
IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.send", "thumb-border-alpha").size), 24, schema::UI().drawElement("tabs.send", "thumb-border-thickness").size);
}
// Max button — use caption font to fit bar height
ImGui::SameLine(0, gap);
char maxId[32];
snprintf(maxId, sizeof(maxId), "Max%s", suffix);
if (TactileButton(maxId, ImVec2(maxBtnW, barH), capFont)) {
s_amount = maxAmount;
s_send_max = true;
if (usd_price > 0.0)
s_usd_amount = s_amount * usd_price;
}
}
// ============================================================================
// Transaction progress indicator
// ============================================================================
static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
ImFont* body2, ImFont* capFont,
float cardBottomY = -1.0f) {
// Auto-clear old success status
if (!s_tx_status.empty() && s_status_success && s_status_timestamp > 0.0 &&
(ImGui::GetTime() - s_status_timestamp) > schema::UI().drawElement("tabs.send", "tx-success-timeout").size) {
s_tx_status.clear();
s_result_txid.clear();
s_status_timestamp = 0.0;
}
if (s_tx_status.empty() && !s_sending) return;
bool is_error = !s_sending && (s_tx_status.find("Error") != std::string::npos ||
s_tx_status.find("Failed") != std::string::npos ||
s_tx_status.find("error") != std::string::npos);
// ---- ERROR: absolute-positioned overlay, does not displace layout ----
if (is_error) {
float pad = Layout::spacingLg();
float btnH = std::max(schema::UI().drawElement("tabs.send", "error-btn-min-height").size, schema::UI().drawElement("tabs.send", "error-btn-height").size);
float textWrapW = w - pad * 2 - schema::UI().drawElement("tabs.send", "error-icon-inset").size; // icon space
ImVec2 textSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, textWrapW, s_tx_status.c_str());
float contentH = textSz.y + Layout::spacingMd() + btnH + pad * 2;
// Anchor to bottom of compose card, overlapping upward (position: absolute)
float anchorY = (cardBottomY > 0) ? cardBottomY : y;
float overlayY = anchorY - contentH - Layout::spacingMd();
ImVec2 pMin(x, overlayY);
ImVec2 pMax(x + w, overlayY + contentH);
// Save cursor — this overlay must not displace layout
ImVec2 savedCursor = ImGui::GetCursorScreenPos();
// Glass card background — matches compose form card style
GlassPanelSpec errGlass;
errGlass.rounding = Layout::glassRounding();
// Use foreground draw list so overlay renders on top of everything
ImDrawList* fgDl = ImGui::GetForegroundDrawList();
// Glass panel background
DrawGlassPanel(fgDl, pMin, pMax, errGlass);
// Red accent bar on left
fgDl->AddRectFilled(pMin, ImVec2(pMin.x + schema::UI().drawElement("tabs.send", "error-accent-bar-width").size, pMax.y), Error(), errGlass.rounding);
float ix = pMin.x + pad;
float iy = pMin.y + pad;
// Error icon (Material Design)
{
ImFont* iconFont = material::Type().iconMed();
const char* errIcon = ICON_MD_ERROR;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, errIcon);
fgDl->AddText(iconFont, iconFont->LegacySize,
ImVec2(ix + schema::UI().drawElement("tabs.send", "error-icon-x-offset").size, iy + body2->LegacySize * 0.5f - iSz.y * 0.5f),
Error(), errIcon);
}
// Error text (wrapped)
fgDl->AddText(body2, body2->LegacySize, ImVec2(ix + schema::UI().drawElement("tabs.send", "error-text-x-offset").size, iy), Error(),
s_tx_status.c_str(), s_tx_status.c_str() + s_tx_status.size(), textWrapW);
// Buttons row — use invisible window for interactive widgets on top of overlay
float btnY = iy + textSz.y + Layout::spacingMd();
ImGui::SetNextWindowPos(ImVec2(ix, btnY));
ImGui::SetNextWindowSize(ImVec2(w - pad * 2, btnH + schema::UI().drawElement("tabs.send", "error-btn-area-padding").size));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0, 0, 0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::Begin("##ErrOverlayBtns", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoBringToFrontOnFocus);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.send", "error-btn-bg-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.send", "error-btn-hover-alpha").size)));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.send", "error-btn-rounding").size);
if (TactileSmallButton(TR("send_copy_error"), schema::UI().resolveFont("button"))) {
ImGui::SetClipboardText(s_tx_status.c_str());
Notifications::instance().info(TR("send_error_copied"));
}
ImGui::SameLine();
if (TactileSmallButton(TR("send_dismiss"), schema::UI().resolveFont("button"))) {
s_tx_status.clear();
s_result_txid.clear();
s_status_success = false;
}
ImGui::PopStyleVar();
ImGui::PopStyleColor(2);
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor(2);
// Restore cursor — no layout displacement
ImGui::SetCursorScreenPos(savedCursor);
return;
}
// ---- SENDING / SUCCESS: inline progress card ----
float progCardH = schema::UI().drawElement("tabs.send", "progress-card-height").size;
float progCardHTxid = schema::UI().drawElement("tabs.send", "progress-card-height-txid").size;
float progH = s_result_txid.empty() ? progCardH : progCardHTxid;
ImVec2 pMin(x, y);
ImVec2 pMax(x + w, y + progH);
GlassPanelSpec progGlass;
progGlass.rounding = Layout::glassRounding() * schema::UI().drawElement("tabs.send", "progress-glass-rounding-ratio").size;
DrawGlassPanel(dl, pMin, pMax, progGlass);
float progPadX = schema::UI().drawElement("tabs.send", "progress-card-pad-x").size;
float progPadY = schema::UI().drawElement("tabs.send", "progress-card-pad-y").size;
float ix = pMin.x + progPadX;
float iy = pMin.y + progPadY;
char buf[128];
if (s_sending) {
// Spinning refresh icon
ImFont* iconFont = material::Type().iconMed();
const char* spinIcon = ICON_MD_REFRESH;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, spinIcon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(ix, iy), Primary(), spinIcon);
double elapsed = ImGui::GetTime() - s_send_start_time;
snprintf(buf, sizeof(buf), TR("send_submitting"), elapsed);
dl->AddText(body2, body2->LegacySize, ImVec2(ix + iSz.x + schema::UI().drawElement("tabs.send", "progress-icon-text-gap").size, iy), OnSurface(), buf);
} else {
// Success checkmark
ImFont* iconFont = material::Type().iconMed();
const char* checkIcon = ICON_MD_CHECK;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, checkIcon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(ix, iy), Success(), checkIcon);
dl->AddText(body2, body2->LegacySize, ImVec2(ix + iSz.x + schema::UI().drawElement("tabs.send", "progress-icon-text-gap").size, iy), Success(), TR("send_tx_sent"));
if (!s_result_txid.empty()) {
float txY = iy + body2->LegacySize + schema::UI().drawElement("tabs.send", "txid-y-offset").size;
int txidThreshold = (int)schema::UI().drawElement("tabs.send", "txid-display-threshold").size;
int txidTruncLen = (int)schema::UI().drawElement("tabs.send", "txid-trunc-len").size;
std::string dispTxid = (int)s_result_txid.length() > txidThreshold
? s_result_txid.substr(0, txidTruncLen) + "..." + s_result_txid.substr(s_result_txid.length() - txidTruncLen)
: s_result_txid;
snprintf(buf, sizeof(buf), TR("send_txid_label"), dispTxid.c_str());
dl->AddText(capFont, capFont->LegacySize, ImVec2(ix + schema::UI().drawElement("tabs.send", "txid-label-x-offset").size, txY),
OnSurfaceDisabled(), buf);
ImGui::SetCursorScreenPos(ImVec2(pMax.x - schema::UI().drawElement("tabs.send", "txid-copy-btn-right-offset").size, txY - schema::UI().drawElement("tabs.send", "txid-copy-btn-y-offset").size));
if (TactileSmallButton(TR("copy"), schema::UI().resolveFont("button"))) {
ImGui::SetClipboardText(s_result_txid.c_str());
Notifications::instance().info(TR("send_txid_copied"));
}
}
}
ImGui::SetCursorScreenPos(ImVec2(pMin.x, pMax.y));
ImGui::Dummy(ImVec2(w, 0));
}
// ============================================================================
// Confirmation dialog
// ============================================================================
void RenderSendConfirmPopup(App* app) {
if (!s_show_confirm) return;
// Called every frame while the popup should be visible.
// OpenPopup is idempotent when the popup is already open.
ImGui::OpenPopup(TR("confirm_send"));
bool is_valid_z = (s_to_address[0] == 'z' && s_to_address[1] == 's' && strlen(s_to_address) > 60);
auto& S = schema::UI();
const auto& state = app->getWalletState();
const auto& market = state.market;
ImFont* capFont = Type().caption();
ImFont* sub1 = Type().subtitle1();
ImFont* body2 = Type().body2();
char buf[128];
double total = s_amount + s_fee;
float popupAvailW = ImGui::GetMainViewport()->Size.x * S.drawElement("tabs.send", "confirm-popup-width-ratio").size;
float popupW = std::min(schema::UI().drawElement("tabs.send", "confirm-popup-max-width").size, popupAvailW);
float popVs = Layout::vScale();
if (material::BeginOverlayDialog(TR("confirm_send"), nullptr, popupW, 0.94f)) {
if (ImGui::IsKeyPressed(ImGuiKey_Escape) && !s_sending) {
s_show_confirm = false;
}
float popW = ImGui::GetContentRegionAvail().x;
ImDrawList* popDl = ImGui::GetWindowDrawList();
Type().text(TypeStyle::H6, TR("confirm_transaction"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
float addrCardH = std::max(schema::UI().drawElement("tabs.send", "confirm-addr-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-addr-card-height").size * popVs);
float popGlassRound = Layout::glassRounding() * S.drawElement("tabs.send", "confirm-glass-rounding-ratio").size;
// FROM card
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("from_upper"));
ImVec2 cMin = ImGui::GetCursorScreenPos();
ImVec2 cMax(cMin.x + popW, cMin.y + addrCardH);
GlassPanelSpec gs; gs.rounding = popGlassRound;
DrawGlassPanel(popDl, cMin, cMax, gs);
popDl->AddText(body2, body2->LegacySize,
ImVec2(cMin.x + Layout::spacingMd(), cMin.y + Layout::spacingSm()),
Success(), TruncateAddress(s_from_address, (size_t)S.drawElement("tabs.send", "confirm-addr-trunc-len").size).c_str());
ImGui::Dummy(ImVec2(popW, addrCardH));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
// TO card
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("to_upper"));
ImVec2 cMin = ImGui::GetCursorScreenPos();
ImVec2 cMax(cMin.x + popW, cMin.y + addrCardH);
GlassPanelSpec gs; gs.rounding = popGlassRound;
DrawGlassPanel(popDl, cMin, cMax, gs);
popDl->AddText(body2, body2->LegacySize,
ImVec2(cMin.x + Layout::spacingMd(), cMin.y + Layout::spacingSm()),
Success(), TruncateAddress(s_to_address, (size_t)S.drawElement("tabs.send", "confirm-addr-trunc-len").size).c_str());
ImGui::Dummy(ImVec2(popW, addrCardH));
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
// Fee tier selector
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_network_fee"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
RenderFeeTierSelector("##confirm");
// Recalculate total after potential fee change
total = s_amount + s_fee;
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
// Amount / Fee / Total summary
{
float valX = std::max(schema::UI().drawElement("tabs.send", "confirm-val-col-min-x").size, schema::UI().drawElement("tabs.send", "confirm-val-col-x").size * popVs);
float usdX = popW - std::max(schema::UI().drawElement("tabs.send", "confirm-usd-col-min-x").size, schema::UI().drawElement("tabs.send", "confirm-usd-col-x").size * popVs);
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_amount_details"));
ImVec2 cMin = ImGui::GetCursorScreenPos();
float rowStep = std::max(schema::UI().drawElement("tabs.send", "confirm-row-step-min").size, schema::UI().drawElement("tabs.send", "confirm-row-step").size * popVs);
float configuredH = std::max(schema::UI().drawElement("tabs.send", "confirm-amount-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-amount-card-height").size * popVs);
float contentH = Layout::spacingMd() * 2.0f + capFont->LegacySize * 2.0f + sub1->LegacySize + rowStep * 2.0f;
float cH = std::max(configuredH, contentH);
ImVec2 cMax(cMin.x + popW, cMin.y + cH);
GlassPanelSpec gs; gs.rounding = popGlassRound;
DrawGlassPanel(popDl, cMin, cMax, gs);
float cx = cMin.x + Layout::spacingLg();
float cy = cMin.y + Layout::spacingMd();
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), TR("send_amount"));
snprintf(buf, sizeof(buf), "%.8f %s", s_amount, DRAGONX_TICKER);
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx + valX, cy), OnSurface(), buf);
if (market.price_usd > 0) {
snprintf(buf, sizeof(buf), "$%.4f", s_amount * market.price_usd);
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx + usdX, cy), OnSurfaceDisabled(), buf);
}
cy += rowStep;
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), TR("send_fee"));
snprintf(buf, sizeof(buf), "%.8f %s", s_fee, DRAGONX_TICKER);
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx + valX, cy), OnSurface(), buf);
if (market.price_usd > 0) {
snprintf(buf, sizeof(buf), "$%.6f", s_fee * market.price_usd);
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx + usdX, cy), OnSurfaceDisabled(), buf);
}
cy += rowStep * 0.5f;
popDl->AddLine(ImVec2(cx, cy),
ImVec2(cMax.x - Layout::spacingLg(), cy),
ImGui::GetColorU32(Divider()), S.drawElement("tabs.send", "confirm-divider-thickness").size);
cy += rowStep * 0.5f;
popDl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), TR("send_total"));
snprintf(buf, sizeof(buf), "%.8f %s", total, DRAGONX_TICKER);
DrawTextShadow(popDl, sub1, sub1->LegacySize, ImVec2(cx + valX, cy), Primary(), buf);
if (market.price_usd > 0) {
snprintf(buf, sizeof(buf), "$%.4f", total * market.price_usd);
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx + usdX, cy + S.drawElement("tabs.send", "confirm-usd-y-offset").size), OnSurfaceDisabled(), buf);
}
ImGui::Dummy(ImVec2(popW, cH));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
if (s_memo[0] != '\0' && is_valid_z) {
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("memo_upper"));
Type().textColored(TypeStyle::Caption, OnSurface(), s_memo);
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (s_sending) {
Type().text(TypeStyle::Body2, TR("sending"));
} else {
if (TactileButton(TR("confirm_and_send"), ImVec2(S.button("tabs.send", "confirm-button").width, std::max(schema::UI().drawElement("tabs.send", "confirm-btn-min-height").size, schema::UI().drawElement("tabs.send", "confirm-btn-base-height").size * popVs)), S.resolveFont(S.button("tabs.send", "confirm-button").font))) {
s_sending = true;
s_send_start_time = ImGui::GetTime();
s_tx_status = std::string(TR("sending")) + "...";
std::string memo_str = (is_valid_z && s_memo[0]) ? s_memo : "";
app->sendTransaction(
s_from_address,
s_to_address,
s_amount,
s_fee,
memo_str,
[](bool success, const std::string& result) {
s_sending = false;
s_status_timestamp = ImGui::GetTime();
if (success) {
s_tx_status = TR("send_tx_sent");
s_result_txid = result;
s_status_success = true;
Notifications::instance().success(TR("send_tx_success"), 5.0f);
s_to_address[0] = '\0';
s_amount = 0.0;
s_memo[0] = '\0';
s_send_max = false;
} else {
s_tx_status = std::string(TR("send_error_prefix")) + result;
s_result_txid.clear();
s_status_success = false;
Notifications::instance().error(std::string(TR("send_tx_failed")) + result);
}
}
);
s_show_confirm = false;
}
ImGui::SameLine();
if (TactileButton(TR("cancel"), ImVec2(S.button("tabs.send", "cancel-button").width, std::max(schema::UI().drawElement("tabs.send", "confirm-btn-min-height").size, schema::UI().drawElement("tabs.send", "confirm-btn-base-height").size * popVs)), S.resolveFont(S.button("tabs.send", "cancel-button").font))) {
s_show_confirm = false;
}
}
material::EndOverlayDialog();
}
}
// ============================================================================
// Zero balance CTA
// ============================================================================
static bool RenderZeroBalanceCTA(App* app, ImDrawList* dl, float width) {
const auto& state = app->getWalletState();
double totalBal = 0.0;
for (const auto& a : state.addresses) totalBal += a.balance;
if (!app->isConnected() || state.addresses.empty() || totalBal > 0.0)
return false;
ImFont* sub1 = Type().subtitle1();
ImFont* capFont = Type().caption();
ImVec2 ctaMin = ImGui::GetCursorScreenPos();
float ctaH = schema::UI().drawElement("tabs.send", "cta-height").size;
ImVec2 ctaMax(ctaMin.x + width, ctaMin.y + ctaH);
GlassPanelSpec ctaGlass;
ctaGlass.rounding = Layout::glassRounding();
DrawGlassPanel(dl, ctaMin, ctaMax, ctaGlass);
float cx = ctaMin.x + Layout::spacingXl();
float cy = ctaMin.y + Layout::spacingLg();
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurface(), TR("send_wallet_empty"));
cy += sub1->LegacySize + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(),
TR("send_switch_to_receive"));
cy += capFont->LegacySize + Layout::spacingMd();
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
if (TactileButton(TR("send_go_to_receive"), ImVec2(schema::UI().drawElement("tabs.send", "cta-button-width").size, schema::UI().drawElement("tabs.send", "cta-button-height").size), schema::UI().resolveFont("button"))) {
app->setCurrentPage(NavPage::Receive);
}
ImGui::SetCursorScreenPos(ImVec2(ctaMin.x, ctaMax.y + Layout::spacingLg()));
ImGui::Dummy(ImVec2(width, 0));
return true;
}
// ============================================================================
// Action buttons (Send + Clear + Undo)
// ============================================================================
static void RenderActionButtons(App* app, float width, float vScale,
bool is_valid_address, double available,
const char* suffix = "") {
auto& S = schema::UI();
const auto& state = app->getWalletState();
double total = s_amount + s_fee;
bool can_send = app->isConnected() &&
!state.sync.syncing &&
is_valid_address &&
s_amount > 0 &&
s_from_address[0] != '\0' &&
total <= available &&
!s_sending;
float btnGap = Layout::spacingMd();
float cancelBtnW = std::max(schema::UI().drawElement("tabs.send", "cancel-btn-min-width").size, width * S.drawElement("tabs.send", "cancel-btn-width-ratio").size);
float sendBtnW = width - cancelBtnW - btnGap;
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
// ---- Send button ----
if (!can_send) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "disabled-btn-bg-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "disabled-btn-bg-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
}
ImGui::BeginDisabled(!can_send);
char sendId[64];
snprintf(sendId, sizeof(sendId), "Review Send%s", suffix);
if (TactileButton(sendId, ImVec2(sendBtnW, btnH), S.resolveFont(S.button("tabs.send", "send-button").font))) {
s_show_confirm = true;
}
ImGui::EndDisabled();
if (!can_send && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
if (!app->isConnected())
ImGui::SetTooltip("%s", TR("send_tooltip_not_connected"));
else if (state.sync.syncing)
ImGui::SetTooltip("%s", TR("send_tooltip_syncing"));
else if (s_from_address[0] == '\0')
ImGui::SetTooltip("%s", TR("send_tooltip_select_source"));
else if (!is_valid_address)
ImGui::SetTooltip("%s", TR("send_tooltip_invalid_address"));
else if (s_amount <= 0)
ImGui::SetTooltip("%s", TR("send_tooltip_enter_amount"));
else if (total > available)
ImGui::SetTooltip("%s", TR("send_tooltip_exceeds_balance"));
else if (s_sending)
ImGui::SetTooltip("%s", TR("send_tooltip_in_progress"));
}
if (!can_send) ImGui::PopStyleColor(3);
// ---- Cancel button (same line) ----
ImGui::SameLine(0, btnGap);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "cancel-btn-hover-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.send", "cancel-btn-border-size").size);
char clearId[64];
snprintf(clearId, sizeof(clearId), "Cancel%s", suffix);
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
if (FormHasData()) {
s_clear_confirm_pending = true;
} else {
ClearFormWithUndo();
}
}
ImGui::PopStyleVar(); // FrameBorderSize
ImGui::PopStyleColor(3);
// Clear confirmation popup
char confirmClearId[32];
snprintf(confirmClearId, sizeof(confirmClearId), "##ConfirmClear%s", suffix);
if (s_clear_confirm_pending) {
ImGui::OpenPopup(confirmClearId);
s_clear_confirm_pending = false;
}
if (ImGui::BeginPopup(confirmClearId)) {
ImGui::Text("%s", TR("send_clear_fields"));
ImGui::Spacing();
if (TactileButton(TR("send_yes_clear"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-yes-width").size, 0), S.resolveFont("button"))) {
ClearFormWithUndo();
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (TactileButton(TR("send_keep"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-keep-width").size, 0), S.resolveFont("button"))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Undo button (shown briefly after clear)
if (s_undo_timestamp > 0.0) {
double undoElapsed = ImGui::GetTime() - s_undo_timestamp;
if (undoElapsed < UNDO_TIMEOUT) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
float undoAlpha = (float)std::max(0.0, 1.0 - undoElapsed / UNDO_TIMEOUT);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, undoAlpha);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Warning(), (int)S.drawElement("tabs.send", "undo-btn-bg-alpha").size)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(WithAlpha(Warning(), (int)S.drawElement("tabs.send", "undo-btn-hover-alpha").size)));
char undoId[32];
snprintf(undoId, sizeof(undoId), "Undo Clear%s", suffix);
if (TactileButton(undoId, ImVec2(width, btnH), S.resolveFont("button"))) {
RestoreFormSnapshot();
Notifications::instance().info(TR("send_form_restored"));
}
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
} else {
s_undo_timestamp = 0.0;
}
}
}
// ============================================================================
// Recent Sends section — styled to match transactions list
// ============================================================================
static void RenderRecentSends(ImDrawList* dl, const WalletState& state,
float width, ImFont* capFont, App* app) {
auto& S = schema::UI();
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_recent_sends"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImVec2 avail = ImGui::GetContentRegionAvail();
float hs = Layout::hScale(avail.x);
float glassRound = Layout::glassRounding();
ImFont* body2 = Type().body2();
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
float iconSz = std::max(schema::UI().drawElement("tabs.send", "recent-icon-min-size").size, schema::UI().drawElement("tabs.send", "recent-icon-size").size * hs);
ImU32 sendCol = Error();
ImU32 greenCol = WithAlpha(Success(), (int)S.drawElement("tabs.send", "recent-green-alpha").size);
float rowPadLeft = Layout::spacingLg();
// Collect matching transactions
std::vector<const TransactionInfo*> sends;
for (const auto& tx : state.transactions) {
if (tx.type != "send") continue;
sends.push_back(&tx);
if (sends.size() >= (size_t)S.drawElement("tabs.send", "max-recent-sends").size) break;
}
if (sends.empty()) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("send_no_recent"));
return;
}
// Outer glass panel wrapping all rows
float itemSpacingY = ImGui::GetStyle().ItemSpacing.y;
float listH = rowH * (float)sends.size() + itemSpacingY * (float)(sends.size() - 1);
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
float listW = width;
ImVec2 listPanelMax(listPanelMin.x + listW, listPanelMin.y + listH);
GlassPanelSpec glassSpec;
glassSpec.rounding = glassRound;
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
// Clip draw commands to panel bounds to prevent overflow
dl->PushClipRect(listPanelMin, listPanelMax, true);
char buf[64];
for (size_t si = 0; si < sends.size(); si++) {
const auto& tx = *sends[si];
ImVec2 rowPos = ImGui::GetCursorScreenPos();
float innerW = listW;
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
// Hover glow
bool hovered = material::IsRectHovered(rowPos, rowEnd);
if (hovered) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "row-hover-alpha").size), schema::UI().drawElement("tabs.send", "row-hover-rounding").size);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) {
app->setCurrentPage(ui::NavPage::History);
}
}
float cx = rowPos.x + rowPadLeft;
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawTxIcon(dl, tx.type, cx + iconSz, cy + body2->LegacySize * 0.5f, iconSz, sendCol);
// Type label (first line)
float labelX = cx + iconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), sendCol, TR("sent_upper"));
// Time (next to type)
std::string ago = timeAgo(tx.timestamp);
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, TR("sent_upper")).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
OnSurfaceDisabled(), ago.c_str());
// Address (second line)
std::string addr_display = TruncateAddress(tx.address, (int)S.drawElement("tabs.send", "recent-addr-trunc-len").size);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), addr_display.c_str());
// Amount (right-aligned, first line)
snprintf(buf, sizeof(buf), "-%.8f", std::abs(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), sendCol, buf,
S.drawElement("tabs.send", "text-shadow-offset-x").size, S.drawElement("tabs.send", "text-shadow-offset-y").size, IM_COL32(0, 0, 0, (int)S.drawElement("tabs.send", "text-shadow-alpha").size));
// 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
{
const char* statusStr;
ImU32 statusCol;
if (tx.confirmations == 0) {
statusStr = TR("pending"); statusCol = Warning();
} else if (tx.confirmations < (int)S.drawElement("tabs.send", "confirmed-threshold").size) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
statusStr = buf; statusCol = Warning();
} else {
statusStr = TR("confirmed"); statusCol = greenCol;
}
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
float statusX = amtX - sSz.x - Layout::spacingXxl();
float minStatusX = cx + innerW * S.drawElement("tabs.send", "status-min-x-ratio").size;
if (statusX < minStatusX) statusX = minStatusX;
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>((int)S.drawElement("tabs.send", "status-pill-bg-alpha").size) << 24);
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + (int)S.drawElement("tabs.send", "status-pill-y-offset").size);
ImVec2 pillMax(statusX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(pillMin, pillMax, pillBg, schema::UI().drawElement("tabs.send", "status-pill-rounding").size);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(statusX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
}
ImGui::Dummy(ImVec2(0, rowH));
// Subtle divider between rows
if (si < sends.size() - 1) {
ImVec2 divStart = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(divStart.x + rowPadLeft + iconSz * 2.0f, divStart.y),
ImVec2(divStart.x + innerW - Layout::spacingLg(), divStart.y),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "row-divider-alpha").size));
}
}
dl->PopClipRect();
}
// ============================================================================
// MAIN: RenderSendTab — Layout E: Top Bar Source + Bottom Form
// ============================================================================
void RenderSendTab(App* app)
{
const auto& state = app->getWalletState();
const auto& market = state.market;
auto& S = schema::UI();
// Handle pending payment from URI
if (app->hasPendingPayment()) {
strncpy(s_to_address, app->getPendingToAddress().c_str(), sizeof(s_to_address) - 1);
s_amount = app->getPendingAmount();
strncpy(s_memo, app->getPendingMemo().c_str(), sizeof(s_memo) - 1);
app->clearPendingPayment();
}
RenderSyncBanner(state);
bool sendSyncing = state.sync.syncing && !state.sync.isSynced();
ImGui::BeginDisabled(sendSyncing);
ImVec2 sendAvail = ImGui::GetContentRegionAvail();
float hs = Layout::hScale(sendAvail.x);
float vScale = Layout::vScale(sendAvail.y);
float glassRound = Layout::glassRounding();
float availWidth = sendAvail.x;
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* capFont = Type().caption();
ImFont* sub1 = Type().subtitle1();
ImFont* body2 = Type().body2();
GlassPanelSpec glassSpec;
glassSpec.rounding = glassRound;
double available = GetAvailableBalance(app);
bool is_valid_z = (s_to_address[0] == 'z' && s_to_address[1] == 's' && strlen(s_to_address) > 60);
bool is_valid_t = (s_to_address[0] == 'R' && strlen(s_to_address) >= 34);
bool is_valid_address = is_valid_z || is_valid_t;
float sectionGap = Layout::spacingXl() * vScale;
char buf[128];
// ================================================================
// SCROLLABLE CONTENT
// ================================================================
ImVec2 formAvail = ImGui::GetContentRegionAvail();
ImGui::BeginChild("##SendFormScroll", formAvail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
dl = ImGui::GetWindowDrawList();
// Top-aligned content — consistent vertical position across all tabs
static float s_sendContentH = 0;
float scrollAvailH = ImGui::GetContentRegionAvail().y;
float groupStartY = ImGui::GetCursorPosY();
float contentStartY = ImGui::GetCursorPosY();
float formAvailW = ImGui::GetContentRegionAvail().x;
float formW = formAvailW;
ImGui::BeginGroup();
// Zero balance CTA
if (RenderZeroBalanceCTA(app, dl, formW)) {
ImGui::EndGroup();
ImGui::EndDisabled(); // sendSyncing guard
ImGui::EndChild();
return;
}
// ================================================================
// COMPOSE FORM — single container for all fields
// ================================================================
{
ImVec2 containerMin = ImGui::GetCursorScreenPos();
float pad = Layout::spacingLg();
float innerW = formW - pad * 2;
float innerGap = Layout::spacingMd() * vScale;
// Single-column layout
float colW = innerW;
// Channel split: content on ch1, glass background on ch0
dl->ChannelsSplit(2);
dl->ChannelsSetCurrent(1);
// Indent content by pad so every line is inset from the card edges
ImGui::Indent(pad);
ImGui::Dummy(ImVec2(0, pad * vScale)); // top padding
// ---- SOURCE ADDRESS ----
RenderSourceDropdown(app, colW);
// Divider between source and recipient
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(containerMin.x + pad, divPos.y),
ImVec2(containerMin.x + pad + colW, divPos.y),
ImGui::GetColorU32(Divider()), S.drawElement("tabs.send", "divider-thickness").size);
}
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
// ---- RECIPIENT ----
{
// Static preview state (declared early for title indicator)
static bool s_paste_previewing = false;
static std::string s_preview_text;
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_recipient"));
// Validation indicator — inline next to title (no height change)
// Check the preview text during hover, otherwise check actual address
const char* validAddr = s_to_address;
bool checkPreview = false;
if (s_paste_previewing && !s_preview_text.empty()) {
validAddr = s_preview_text.c_str();
checkPreview = true;
}
if (validAddr[0] != '\0') {
bool vz = (validAddr[0] == 'z' && validAddr[1] == 's' && strlen(validAddr) > 60);
bool vt = (validAddr[0] == 'R' && strlen(validAddr) >= 34);
if (vz || vt) {
ImGui::SameLine();
if (vz)
Type().textColored(TypeStyle::Caption, Success(), TR("send_valid_shielded"));
else
Type().textColored(TypeStyle::Caption, Warning(), TR("send_valid_transparent"));
} else if (!checkPreview) {
ImGui::SameLine();
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
float pasteW = std::max(schema::UI().drawElement("tabs.send", "paste-btn-min-width").size, colW * schema::UI().drawElement("tabs.send", "paste-btn-width-ratio").size);
ImGui::PushItemWidth(colW - pasteW - Layout::spacingSm());
// Show clipboard preview as transparent overlay when paste button is hovered
bool paste_hovered = false;
ImGui::InputText("##ToAddr", s_to_address, sizeof(s_to_address));
// Capture input rect for preview overlay
ImVec2 inputMin = ImGui::GetItemRectMin();
ImGui::PopItemWidth();
ImGui::SameLine();
// Detect hover BEFORE the button click
ImVec2 pasteBtnPos = ImGui::GetCursorScreenPos();
ImVec2 pasteBtnSize(pasteW, ImGui::GetFrameHeight());
paste_hovered = material::IsRectHovered(pasteBtnPos,
ImVec2(pasteBtnPos.x + pasteBtnSize.x, pasteBtnPos.y + pasteBtnSize.y));
// Handle preview state — don't modify s_to_address, just store preview text
if (paste_hovered && !s_paste_previewing) {
const char* clip = ImGui::GetClipboardText();
if (clip && clip[0] != '\0') {
std::string trimmed(clip);
while (!trimmed.empty() && (trimmed.front() == ' ' || trimmed.front() == '\n' || trimmed.front() == '\r' || trimmed.front() == '\t'))
trimmed.erase(trimmed.begin());
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\n' || trimmed.back() == '\r' || trimmed.back() == '\t'))
trimmed.pop_back();
bool looksValid = (trimmed.size() > 30 &&
((trimmed[0] == 'z' && trimmed[1] == 's') || trimmed[0] == 'R'));
if (looksValid && s_to_address[0] == '\0') {
s_preview_text = trimmed;
s_paste_previewing = true;
}
}
} else if (!paste_hovered && s_paste_previewing) {
s_paste_previewing = false;
s_preview_text.clear();
}
// Draw transparent preview text overlay on the input field
if (s_paste_previewing && !s_preview_text.empty()) {
ImVec2 textPos(inputMin.x + ImGui::GetStyle().FramePadding.x,
inputMin.y + ImGui::GetStyle().FramePadding.y);
ImVec4 previewCol = ImGui::GetStyleColorVec4(ImGuiCol_Text);
previewCol.w = S.drawElement("tabs.send", "paste-preview-alpha").size;
ImGui::GetWindowDrawList()->AddText(textPos, ImGui::ColorConvertFloat4ToU32(previewCol),
s_preview_text.c_str(), s_preview_text.c_str() + std::min(s_preview_text.size(), (size_t)S.drawElement("tabs.send", "paste-preview-max-chars").size));
}
if (TactileButton("Paste##to", ImVec2(pasteW, 0), S.resolveFont(S.button("tabs.send", "paste-button").font))) {
if (s_paste_previewing) {
// Commit the preview
snprintf(s_to_address, sizeof(s_to_address), "%s", s_preview_text.c_str());
s_paste_previewing = false;
s_preview_text.clear();
} else {
PasteClipboardToAddress();
}
}
// Recently sent-to suggestions
RenderAddressSuggestions(state, colW, "##AddrSugForm");
}
// Divider
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(containerMin.x + pad, divPos.y),
ImVec2(containerMin.x + pad + colW, divPos.y),
ImGui::GetColorU32(Divider()), S.drawElement("tabs.send", "divider-thickness").size);
}
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
// ---- AMOUNT ----
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_amount_upper"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Toggle between DRGX and USD input
float toggleW = schema::UI().drawElement("tabs.send", "toggle-currency-width").size;
float amtInputW = colW - toggleW - Layout::spacingMd();
if (amtInputW < schema::UI().drawElement("tabs.send", "amount-input-min-width").size) amtInputW = schema::UI().drawElement("tabs.send", "amount-input-min-width").size;
if (s_input_usd && market.price_usd > 0) {
// USD input mode — no step buttons (step=0)
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
s_amount = s_usd_amount / market.price_usd;
}
// Draw DRGX equivalent inside the input field (right-aligned overlay)
{
ImVec2 iMin = ImGui::GetItemRectMin();
ImVec2 iMax = ImGui::GetItemRectMax();
snprintf(buf, sizeof(buf), "\xe2\x89\x88 %.4f %s", s_amount, DRAGONX_TICKER);
ImFont* font = capFont;
ImVec2 sz = font->CalcTextSizeA(font->LegacySize, 10000, 0, buf);
float tx = iMax.x - sz.x - ImGui::GetStyle().FramePadding.x;
float ty = iMin.y + (iMax.y - iMin.y - sz.y) * 0.5f;
dl->AddText(font, font->LegacySize, ImVec2(tx, ty),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "input-overlay-text-alpha").size), buf);
}
ImGui::PopItemWidth();
} else {
// DRGX input mode — no step buttons (step=0)
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##Amount", &s_amount, 0, 0, "%.8f")) {
if (market.price_usd > 0)
s_usd_amount = s_amount * market.price_usd;
}
// Draw USD equivalent inside the input field (right-aligned overlay)
if (market.price_usd > 0 && s_amount > 0) {
ImVec2 iMin = ImGui::GetItemRectMin();
ImVec2 iMax = ImGui::GetItemRectMax();
double usd_value = s_amount * market.price_usd;
if (usd_value >= 0.01)
snprintf(buf, sizeof(buf), "\xe2\x89\x88 $%.2f", usd_value);
else
snprintf(buf, sizeof(buf), "\xe2\x89\x88 $%.6f", usd_value);
ImFont* font = capFont;
ImVec2 sz = font->CalcTextSizeA(font->LegacySize, 10000, 0, buf);
float tx = iMax.x - sz.x - ImGui::GetStyle().FramePadding.x;
float ty = iMin.y + (iMax.y - iMin.y - sz.y) * 0.5f;
dl->AddText(font, font->LegacySize, ImVec2(tx, ty),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "input-overlay-text-alpha").size), buf);
}
ImGui::PopItemWidth();
}
// Toggle button — shows current mode with swap icon
ImGui::SameLine(0, Layout::spacingMd());
{
const char* currLabel = s_input_usd ? "DRGX" : "USD";
bool canToggle = (market.price_usd > 0);
ImGui::BeginDisabled(!canToggle);
if (TactileButton("##ToggleCurrency", ImVec2(toggleW, 0), S.resolveFont("button"))) {
s_input_usd = !s_input_usd;
if (s_input_usd && market.price_usd > 0)
s_usd_amount = s_amount * market.price_usd;
}
// Draw swap arrows icon + label centered on button
{
ImVec2 bMin = ImGui::GetItemRectMin();
ImVec2 bMax = ImGui::GetItemRectMax();
float bH = bMax.y - bMin.y;
ImFont* font = ImGui::GetFont();
ImVec2 textSz = font->CalcTextSizeA(font->LegacySize, 10000, 0, currLabel);
float iconW = schema::UI().drawElement("tabs.send", "swap-icon-width").size;
float iconGap = schema::UI().drawElement("tabs.send", "swap-icon-gap").size;
float totalW = iconW + iconGap + textSz.x;
float startX = bMin.x + ((bMax.x - bMin.x) - totalW) * 0.5f;
float cy = bMin.y + bH * 0.5f;
ImU32 iconCol = ImGui::GetColorU32(ImGuiCol_Text);
float s = iconW * 0.5f;
float cx = startX + s;
// Swap icon (Material Design)
ImFont* swapFont = Type().iconSmall();
const char* swapIcon = ICON_MD_SWAP_HORIZ;
ImVec2 swapSz = swapFont->CalcTextSizeA(swapFont->LegacySize, 1000.0f, 0.0f, swapIcon);
dl->AddText(swapFont, swapFont->LegacySize,
ImVec2(cx - swapSz.x * 0.5f, cy - swapSz.y * 0.5f), iconCol, swapIcon);
// Text label
float tx = startX + iconW + iconGap;
float ty = cy - textSz.y * 0.5f;
dl->AddText(font, font->LegacySize, ImVec2(tx, ty), iconCol, currLabel);
}
ImGui::EndDisabled();
}
// Combined amount bar (slider + usage indicator)
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
// Amount error
if (s_amount > 0 && s_amount + s_fee > available && available > 0) {
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
Type().textColored(TypeStyle::Caption, Error(), buf);
}
}
// ---- MEMO (shielded only) ----
if (is_valid_z || s_to_address[0] == '\0') {
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(containerMin.x + pad, divPos.y),
ImVec2(containerMin.x + pad + colW, divPos.y),
ImGui::GetColorU32(Divider()), S.drawElement("tabs.send", "divider-thickness").size);
}
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("memo_optional"));
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.send", "memo-label-gap").size));
float memoInputH = std::max(S.drawElement("tabs.send", "memo-min-height").size, S.drawElement("tabs.send", "memo-base-height").size * vScale);
ImGui::PushItemWidth(colW);
ImGui::InputTextMultiline("##Memo", s_memo, sizeof(s_memo),
ImVec2(colW, memoInputH));
ImGui::PopItemWidth();
size_t memo_len = strlen(s_memo);
snprintf(buf, sizeof(buf), "%zu / %d", memo_len, (int)S.drawElement("business", "memo-max-length").size);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
// Divider before action buttons
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
{
ImVec2 divPos = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(containerMin.x + pad, divPos.y),
ImVec2(containerMin.x + formW - pad, divPos.y),
ImGui::GetColorU32(Divider()), S.drawElement("tabs.send", "divider-thickness").size);
}
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
// ---- ACTION BUTTONS (full width) ----
RenderActionButtons(app, innerW, vScale, is_valid_address, available, "##main");
// Add bottom padding
ImGui::Dummy(ImVec2(0, pad * vScale));
ImGui::Unindent(pad);
// Enforce shared card height (matches receive tab)
{
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
float targetCardH = Layout::mainCardTargetH(formW, vScale);
if (currentCardH < targetCardH)
ImGui::Dummy(ImVec2(0, targetCardH - currentCardH));
}
// Draw glass panel background on channel 0
ImVec2 containerMax(containerMin.x + formW, ImGui::GetCursorScreenPos().y);
dl->ChannelsSetCurrent(0);
DrawGlassPanel(dl, containerMin, containerMax, glassSpec);
dl->ChannelsMerge();
ImGui::SetCursorScreenPos(ImVec2(containerMin.x, containerMax.y));
ImGui::Dummy(ImVec2(formW, 0));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Pass card bottom Y so error overlay can anchor to it
float cardBottom = containerMax.y;
// ---- TRANSACTION PROGRESS ----
{
ImVec2 progPos = ImGui::GetCursorScreenPos();
RenderTxProgress(dl, progPos.x, progPos.y, formW, body2, capFont, cardBottom);
if ((!s_tx_status.empty() || s_sending) &&
(s_sending || (s_tx_status.find("Error") == std::string::npos &&
s_tx_status.find("Failed") == std::string::npos &&
s_tx_status.find("error") == std::string::npos))) {
ImGui::Dummy(ImVec2(0, sectionGap));
}
}
}
// ---- RECENT SENDS ----
RenderRecentSends(dl, state, formW, capFont, app);
ImGui::EndGroup();
ImGui::EndDisabled(); // sendSyncing guard
// Round to nearest pixel to prevent sub-pixel oscillation (vibration)
// when content like "recent sends" elapsed-time text changes width each frame
float measuredH = ImGui::GetCursorPosY() - contentStartY;
s_sendContentH = std::round(measuredH);
ImGui::EndChild(); // ##SendFormScroll
}
void SetSendFromAddress(const std::string& address)
{
strncpy(s_from_address, address.c_str(), sizeof(s_from_address) - 1);
s_from_address[sizeof(s_from_address) - 1] = '\0';
s_selected_from_idx = -1;
}
} // namespace ui
} // namespace dragonx