- 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.
1530 lines
73 KiB
C++
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
|