New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).
- The four ops run one at a time on a background lane and their results
are applied on the main thread; Settings shows "Working…" meanwhile.
Key and passphrase copies are wiped after the call, or when a queued
op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
first scan starts, so the save takes milliseconds and the scan runs on
the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
disabled while a scan runs ("Wait for blockchain to sync"); Redownload
blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
dropped, pending ones reported failed, its client shut down) and
destroys it on a background thread. It no longer saves on the way out:
the new client re-scans the blocks since the last save, as after a
restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
finish and save on the old client while the new one owns the wallet
file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
server reads once one fails, and then retries after 4, 8, then 15 s
instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.
Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1147 lines
61 KiB
C++
1147 lines
61 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
//
|
|
// Receive Tab — redesigned to match Send tab layout
|
|
// - Address dropdown at top (like Send's source selector)
|
|
// - Single glass card containing QR, address, payment request
|
|
// - Action buttons below the card
|
|
// - Recent received at bottom
|
|
|
|
#include "receive_tab.h"
|
|
#include "../../app.h"
|
|
#include "../../config/settings.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../../util/payment_uri.h"
|
|
#include "../../util/platform.h"
|
|
#include "../../util/text_format.h"
|
|
#include "../../config/version.h"
|
|
#include "../../data/wallet_state.h"
|
|
#include "../../wallet/lite_wallet_controller.h"
|
|
#include "../../ui/widgets/qr_code.h"
|
|
#include "../sidebar.h"
|
|
#include "../layout.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../material/type.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "../material/colors.h"
|
|
#include "../notifications.h"
|
|
#include "imgui.h"
|
|
|
|
#include <string>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <ctime>
|
|
#include <map>
|
|
#include <vector>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
using namespace material;
|
|
|
|
static std::string TrId(const char* key, const char* id) {
|
|
return std::string(TR(key)) + "##" + id;
|
|
}
|
|
|
|
// ============================================================================
|
|
// State
|
|
// ============================================================================
|
|
static int s_selected_address_idx = -1;
|
|
static double s_request_amount = 0.0;
|
|
static char s_request_memo[512] = "";
|
|
static double s_request_usd_amount = 0.0;
|
|
static bool s_request_usd_mode = false;
|
|
static std::string s_cached_qr_data;
|
|
static uintptr_t s_qr_texture = 0;
|
|
static bool s_auto_selected = false;
|
|
|
|
// Address type filter
|
|
static int s_addr_type_filter = 0; // 0=All, 1=Z, 2=T
|
|
|
|
// Source dropdown preview string
|
|
static std::string s_source_preview;
|
|
|
|
// Track newly created addresses for NEW badge
|
|
static std::map<std::string, double> s_new_address_timestamps;
|
|
static size_t s_prev_address_count = 0;
|
|
|
|
// Address labels (in-memory until persistent config)
|
|
static std::map<std::string, std::string> s_address_labels;
|
|
static std::string s_pending_select_address;
|
|
static bool s_generating_address = false;
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
static void OpenExplorerURL(App* app, const std::string& address) {
|
|
std::string url = app->settings()->getAddressExplorerUrl() + address;
|
|
dragonx::util::Platform::openUrl(url);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Track new addresses (detect creations)
|
|
// ============================================================================
|
|
static void TrackNewAddresses(const WalletState& state) {
|
|
if (state.addresses.size() > s_prev_address_count && s_prev_address_count > 0) {
|
|
for (const auto& a : state.addresses) {
|
|
if (s_new_address_timestamps.find(a.address) == s_new_address_timestamps.end()) {
|
|
s_new_address_timestamps[a.address] = ImGui::GetTime();
|
|
}
|
|
}
|
|
} else if (s_prev_address_count == 0) {
|
|
for (const auto& a : state.addresses) {
|
|
s_new_address_timestamps[a.address] = 0.0;
|
|
}
|
|
}
|
|
s_prev_address_count = state.addresses.size();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Sync banner
|
|
// ============================================================================
|
|
static void RenderSyncBanner(const WalletState& state) {
|
|
if (!state.sync.syncing || state.sync.isSynced()) return;
|
|
|
|
// Use the work-weighted verification_progress like every other surface (not the naive
|
|
// blocks/headers ratio, which over-reports during early sync).
|
|
double vp = state.sync.verification_progress;
|
|
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
|
|
float syncPct = (float)(vp * 100.0);
|
|
char syncBuf[128];
|
|
snprintf(syncBuf, sizeof(syncBuf),
|
|
TR("blockchain_syncing"), syncPct);
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(schema::UI().resolveColor(schema::UI().drawElement("tabs.receive", "sync-banner-bg-color").color)));
|
|
float syncH = std::max(schema::UI().drawElement("tabs.receive", "sync-banner-min-height").size, schema::UI().drawElement("tabs.receive", "sync-banner-height").size * Layout::vScale());
|
|
ImGui::BeginChild("##SyncBannerRecv", 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();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Address Dropdown — matches Send tab's source selector style
|
|
// ============================================================================
|
|
static void RenderAddressDropdown(App* app, float width) {
|
|
const auto& state = app->getWalletState();
|
|
char buf[256];
|
|
|
|
// Header row: label + address type toggle buttons
|
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("address_upper"));
|
|
|
|
// All / Shielded / Mining: each segment as wide as the widest label (they are words, not Z/T).
|
|
const char* filterLabels[] = { TR("all_filter"), TR("shielded"), TR("addr_type_mining") };
|
|
float toggleBtnW = std::max(schema::UI().drawElement("tabs.receive", "toggle-btn-min-width").size, schema::UI().drawElement("tabs.receive", "toggle-btn-width").size * Layout::hScale(width));
|
|
for (const char* lbl : filterLabels)
|
|
toggleBtnW = std::max(toggleBtnW, Type().caption()->CalcTextSizeA(Type().caption()->LegacySize, FLT_MAX, 0, lbl).x +
|
|
2.0f * Layout::spacingMd());
|
|
float toggleGap = schema::UI().drawElement("tabs.receive", "toggle-gap").size;
|
|
float toggleTotalW = toggleBtnW * 3 + toggleGap * 2;
|
|
float segH = ImGui::GetFrameHeight();
|
|
ImGui::SameLine(width - toggleTotalW);
|
|
ImVec2 segOrigin = ImGui::GetCursorScreenPos();
|
|
int segClk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, toggleTotalW, segH,
|
|
filterLabels, 3, s_addr_type_filter, Type().caption(),
|
|
"##addrFilter", Layout::dpiScale());
|
|
if (segClk >= 0) s_addr_type_filter = segClk;
|
|
ImGui::Dummy(ImVec2(toggleTotalW, segH));
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
TrackNewAddresses(state);
|
|
|
|
// Auto-select the shielded address with the largest balance on first load. A mining (transparent)
|
|
// address can't be paid, so it is picked only when the wallet has no shielded address yet.
|
|
if (!s_auto_selected && app->isConnected() && !state.addresses.empty()) {
|
|
int bestIdx = -1;
|
|
double bestBal = -1.0;
|
|
bool bestIsZ = false;
|
|
for (size_t i = 0; i < state.addresses.size(); i++) {
|
|
const bool z = state.addresses[i].type == "shielded";
|
|
if ((z && !bestIsZ) || (z == bestIsZ && state.addresses[i].balance > bestBal)) {
|
|
bestBal = state.addresses[i].balance;
|
|
bestIdx = static_cast<int>(i);
|
|
bestIsZ = z;
|
|
}
|
|
}
|
|
if (bestIdx >= 0) {
|
|
s_selected_address_idx = bestIdx;
|
|
}
|
|
s_auto_selected = true;
|
|
}
|
|
|
|
// Auto-select pending new address
|
|
if (!s_pending_select_address.empty()) {
|
|
for (size_t i = 0; i < state.addresses.size(); i++) {
|
|
if (state.addresses[i].address == s_pending_select_address) {
|
|
s_selected_address_idx = static_cast<int>(i);
|
|
s_cached_qr_data.clear();
|
|
s_pending_select_address.clear();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Combo/button widths first — the preview truncates to the combo's real pixel width below.
|
|
float copyBtnW = std::max(schema::UI().drawElement("tabs.receive", "copy-btn-min-width").size, schema::UI().drawElement("tabs.receive", "copy-btn-width").size * Layout::hScale(width));
|
|
float newBtnW = std::max(schema::UI().drawElement("tabs.receive", "new-btn-min-width").size, schema::UI().drawElement("tabs.receive", "new-btn-width").size * Layout::hScale(width));
|
|
float dropdownW = width - copyBtnW - newBtnW - Layout::spacingSm() * 2;
|
|
|
|
// Build preview string
|
|
if (!app->isConnected()) {
|
|
s_source_preview = TR(app->isLiteBuild() ? "lite_no_wallet_short" : "not_connected");
|
|
} else if (s_selected_address_idx >= 0 &&
|
|
s_selected_address_idx < (int)state.addresses.size()) {
|
|
const auto& addr = state.addresses[s_selected_address_idx];
|
|
bool isZ = addr.type == "shielded";
|
|
const char* tag = isZ ? "[Z]" : "[T]";
|
|
// Reserve pixel room for the tag prefix and the trailing balance, then middle-truncate the
|
|
// address to whatever remains — measured with the combo's own Body2 font. Char-count
|
|
// truncation kept MORE chars as the column widened, so at 150% the scaled font overflowed
|
|
// and the combo hard-clipped "— 12.00000000 DRGX" to "— 1"; measuring in pixels keeps the
|
|
// balance visible at any scale.
|
|
ImFont* comboFont = Type().getFont(TypeStyle::Body2);
|
|
float comboFontSz = comboFont->LegacySize;
|
|
char prefix[16]; snprintf(prefix, sizeof(prefix), "%s ", tag);
|
|
char suffix[64]; snprintf(suffix, sizeof(suffix), " \xe2\x80\x94 %.8f %s", addr.balance, DRAGONX_TICKER);
|
|
float fixedW = comboFont->CalcTextSizeA(comboFontSz, FLT_MAX, 0.0f, prefix).x
|
|
+ comboFont->CalcTextSizeA(comboFontSz, FLT_MAX, 0.0f, suffix).x;
|
|
// Combo interior = dropdownW minus its dropdown-arrow button (~frame height) and both frame paddings.
|
|
float addrBudget = dropdownW - ImGui::GetFrameHeight() - ImGui::GetStyle().FramePadding.x * 2.0f - fixedW;
|
|
if (addrBudget < 24.0f) addrBudget = 24.0f; // floor: truncate to a stub rather than overflow
|
|
std::string trunc = material::TruncateToWidth(addr.address, comboFont, comboFontSz, addrBudget);
|
|
snprintf(buf, sizeof(buf), "%s%s%s", prefix, trunc.c_str(), suffix);
|
|
s_source_preview = buf;
|
|
} else {
|
|
s_source_preview = TR("select_receiving_address");
|
|
}
|
|
|
|
ImGui::SetNextItemWidth(dropdownW);
|
|
ImGui::PushFont(Type().getFont(TypeStyle::Body2));
|
|
if (ImGui::BeginCombo("##RecvAddr", s_source_preview.c_str())) {
|
|
if (!app->isConnected() || state.addresses.empty()) {
|
|
ImGui::TextDisabled("%s", TR("no_addresses_available"));
|
|
} else {
|
|
// Build filtered and sorted list
|
|
std::vector<size_t> sortedIdx;
|
|
sortedIdx.reserve(state.addresses.size());
|
|
for (size_t i = 0; i < state.addresses.size(); i++) {
|
|
bool isZ = state.addresses[i].type == "shielded";
|
|
if (s_addr_type_filter == 1 && !isZ) continue;
|
|
if (s_addr_type_filter == 2 && isZ) continue;
|
|
// "All" lists a mining address only when it matters: it holds rewards, it is marked as a
|
|
// mining address, we are solo mining, or it is the one selected. The Mining filter lists all.
|
|
if (s_addr_type_filter == 0 && !isZ && state.addresses[i].balance < 1e-9 &&
|
|
!app->isMiningAddress(state.addresses[i].address) && !state.mining.generate &&
|
|
s_selected_address_idx != static_cast<int>(i))
|
|
continue;
|
|
sortedIdx.push_back(i);
|
|
}
|
|
std::sort(sortedIdx.begin(), sortedIdx.end(),
|
|
[&](size_t a, size_t b) {
|
|
return state.addresses[a].balance > state.addresses[b].balance;
|
|
});
|
|
|
|
if (sortedIdx.empty()) {
|
|
ImGui::TextDisabled("%s", TR("no_addresses_match"));
|
|
} else {
|
|
size_t addrTruncLen = static_cast<size_t>(std::max(schema::UI().drawElement("tabs.receive", "addr-dropdown-trunc-min").size, width / schema::UI().drawElement("tabs.receive", "addr-dropdown-trunc-divisor").size));
|
|
double now = ImGui::GetTime();
|
|
|
|
for (size_t si = 0; si < sortedIdx.size(); si++) {
|
|
size_t i = sortedIdx[si];
|
|
const auto& addr = state.addresses[i];
|
|
bool isCurrent = (s_selected_address_idx == static_cast<int>(i));
|
|
bool isZ = addr.type == "shielded";
|
|
const char* tag = isZ ? "[Z]" : "[T]";
|
|
|
|
// Check for NEW badge
|
|
bool isNew = false;
|
|
auto newIt = s_new_address_timestamps.find(addr.address);
|
|
if (newIt != s_new_address_timestamps.end() && newIt->second > 0.0) {
|
|
double age = now - newIt->second;
|
|
if (age < schema::UI().drawElement("tabs.receive", "new-badge-timeout").size) isNew = true;
|
|
}
|
|
|
|
// Check for label
|
|
auto lblIt = s_address_labels.find(addr.address);
|
|
bool hasLabel = (lblIt != s_address_labels.end() && !lblIt->second.empty());
|
|
|
|
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
|
|
if (hasLabel) {
|
|
snprintf(buf, sizeof(buf), "%s %s (%s) \xe2\x80\x94 %.8f %s%s",
|
|
tag, lblIt->second.c_str(), trunc.c_str(),
|
|
addr.balance, DRAGONX_TICKER,
|
|
isNew ? TR("grpb_new_badge_suffix") : "");
|
|
} else {
|
|
snprintf(buf, sizeof(buf), "%s %s \xe2\x80\x94 %.8f %s%s",
|
|
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER,
|
|
isNew ? TR("grpb_new_badge_suffix") : "");
|
|
}
|
|
|
|
ImGui::PushID(static_cast<int>(i));
|
|
if (ImGui::Selectable(buf, isCurrent)) {
|
|
s_selected_address_idx = static_cast<int>(i);
|
|
s_cached_qr_data.clear(); // Force QR regeneration
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
char tip[512];
|
|
snprintf(tip, sizeof(tip), TR("grpb_tooltip_address_balance"),
|
|
addr.address.c_str(), addr.balance, DRAGONX_TICKER,
|
|
isCurrent ? TR("grpb_selected_suffix") : "");
|
|
if (isZ) material::Tooltip("%s", tip);
|
|
else material::Tooltip("%s\n\n%s", tip, TR("mining_addr_explainer"));
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
}
|
|
}
|
|
ImGui::EndCombo();
|
|
}
|
|
ImGui::PopFont();
|
|
|
|
// Copy address button
|
|
ImGui::SameLine(0, Layout::spacingSm());
|
|
ImGui::BeginDisabled(!app->isConnected() || s_selected_address_idx < 0 ||
|
|
s_selected_address_idx >= (int)state.addresses.size());
|
|
if (TactileButton(TrId("copy", "recvAddr").c_str(), ImVec2(copyBtnW, 0), schema::UI().resolveFont("button"))) {
|
|
if (s_selected_address_idx >= 0 && s_selected_address_idx < (int)state.addresses.size()) {
|
|
ImGui::SetClipboardText(state.addresses[s_selected_address_idx].address.c_str());
|
|
Notifications::instance().info(TR("address_copied"));
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
|
|
// New address button on same line. Lite: the backend's new-address save waits out a running scan, so the button
|
|
// is unavailable until the scan ends (it used to freeze the window that long).
|
|
ImGui::SameLine(0, Layout::spacingSm());
|
|
const bool liteScanning = app->liteWallet() && app->liteWallet()->scanInProgress();
|
|
ImGui::BeginDisabled(!app->isConnected() || s_generating_address || liteScanning);
|
|
if (s_generating_address) {
|
|
char genLabel[64];
|
|
snprintf(genLabel, sizeof(genLabel), "%s%s##recv", TR("generating"), material::LoadingDots());
|
|
TactileButton(genLabel, ImVec2(newBtnW, 0), schema::UI().resolveFont("button"));
|
|
} else if (TactileButton(TrId("new", "recv").c_str(), ImVec2(newBtnW, 0), schema::UI().resolveFont("button"))) {
|
|
// createNewAddress can early-return without ever invoking the callback (e.g. connection
|
|
// dropped between this button being enabled and the click landing), which would leave
|
|
// s_generating_address stuck. Only enter the "generating…" state when a dispatch is possible.
|
|
if (!app->isConnected()) {
|
|
s_generating_address = false;
|
|
} else {
|
|
s_generating_address = true;
|
|
if (s_addr_type_filter != 2) {
|
|
app->createNewZAddress([](const std::string& addr) {
|
|
s_generating_address = false;
|
|
if (addr.empty())
|
|
Notifications::instance().error(TR("failed_create_shielded"));
|
|
else {
|
|
s_pending_select_address = addr;
|
|
Notifications::instance().success(TR("new_shielded_created"));
|
|
}
|
|
});
|
|
} else {
|
|
app->createNewTAddress([](const std::string& addr) {
|
|
s_generating_address = false;
|
|
if (addr.empty())
|
|
Notifications::instance().error(TR("failed_create_mining_address"));
|
|
else {
|
|
s_pending_select_address = addr;
|
|
Notifications::instance().success(TR("new_mining_address_created"));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
if (liteScanning && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
|
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
|
}
|
|
|
|
void SweepSelectReceiveAddress(App* app, const char* address) {
|
|
(void)app;
|
|
if (address) {
|
|
s_pending_select_address = address;
|
|
s_addr_type_filter = 0;
|
|
} else {
|
|
s_auto_selected = false; // re-run the default (shielded-first) choice
|
|
}
|
|
s_cached_qr_data.clear();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mining address notice (instead of a payment request)
|
|
// ============================================================================
|
|
// A mining (transparent) address only ever receives mining rewards: the network rejects payments to it.
|
|
// So it gets no payment request and no QR code — just what it is for, and a way to a shielded address.
|
|
static void RenderMiningAddressNotice(App* app, const WalletState& state, float width) {
|
|
Type().textColored(TypeStyle::Overline, Warning(), TR("mining_address"));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + width);
|
|
Type().textWrapped(TypeStyle::Body2, TR("mining_addr_explainer"));
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
|
|
|
int bestZ = -1;
|
|
double bestBal = -1.0;
|
|
for (size_t i = 0; i < state.addresses.size(); i++) {
|
|
if (state.addresses[i].type == "shielded" && state.addresses[i].balance > bestBal) {
|
|
bestBal = state.addresses[i].balance;
|
|
bestZ = static_cast<int>(i);
|
|
}
|
|
}
|
|
const bool liteScanningZ = app->liteWallet() && app->liteWallet()->scanInProgress();
|
|
ImGui::BeginDisabled(bestZ < 0 && (!app->isConnected() || s_generating_address || liteScanningZ));
|
|
if (TactileButton(TrId("receive_use_shielded", "mining").c_str(), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
|
|
if (bestZ >= 0) {
|
|
s_selected_address_idx = bestZ;
|
|
s_cached_qr_data.clear();
|
|
} else {
|
|
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
|
app->createNewZAddress([](const std::string& addr) {
|
|
s_generating_address = false;
|
|
if (addr.empty())
|
|
Notifications::instance().error(TR("failed_create_shielded"));
|
|
else {
|
|
s_pending_select_address = addr;
|
|
Notifications::instance().success(TR("new_shielded_created"));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::Dummy(ImVec2(width, Layout::spacingSm()));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers: DrawRecvIcon (local copy — original is static in send_tab).
|
|
// Relative time uses the shared util::formatTimeAgoShort ("14d ago"), matching Overview/Send.
|
|
// ============================================================================
|
|
static void DrawRecvIcon(ImDrawList* dl, float cx, float cy, float s, ImU32 col) {
|
|
dl->AddTriangleFilled(
|
|
ImVec2(cx, cy + s),
|
|
ImVec2(cx - s * 0.65f, cy - s * 0.3f),
|
|
ImVec2(cx + s * 0.65f, cy - s * 0.3f), col);
|
|
dl->AddRectFilled(
|
|
ImVec2(cx - s * 0.2f, cy - s * 0.8f),
|
|
ImVec2(cx + s * 0.2f, cy - s * 0.3f), col);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Recent received transactions — styled to match transactions list
|
|
// ============================================================================
|
|
static void RenderRecentReceived(const AddressInfo& /* addr */,
|
|
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("recent_received"));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
|
|
float hs = Layout::hScale(width);
|
|
float vs = Layout::vScale(std::max(1.0f, ImGui::GetContentRegionAvail().y));
|
|
float dp = Layout::dpiScale();
|
|
float rowH = std::max(18.0f * dp, S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f) * vs);
|
|
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
|
|
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
|
ImU32 recvCol = Success();
|
|
|
|
// Grow the list to fill the dead space beneath the (fixed-height) receive card:
|
|
// fit as many newest-first rows as the remaining region can show, instead of a
|
|
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
|
// lost. A floor of 4 keeps the section substantial when the region is short.
|
|
// Compute maxRows BEFORE the collect loop so the scan can stop early (below).
|
|
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
|
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
|
|
|
// Collect matching transactions. state.transactions is newest-first, so the first
|
|
// maxRows matches ARE exactly the rows we render — stop scanning once we have them
|
|
// instead of filtering the entire history every frame (mirrors RenderSharedRecentTx
|
|
// in balance_components.cpp).
|
|
std::vector<const TransactionInfo*> recvs;
|
|
recvs.reserve(maxRows);
|
|
for (const auto& tx : state.transactions) {
|
|
if (tx.type != "receive" && tx.type != "mined") continue;
|
|
recvs.push_back(&tx);
|
|
if (recvs.size() >= maxRows) break; // newest-first: prefix is all we show
|
|
}
|
|
|
|
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
|
|
ImGuiWindowFlags_NoBackground);
|
|
ImDrawList* rowDL = ImGui::GetWindowDrawList();
|
|
|
|
char buf[64];
|
|
if (recvs.empty()) {
|
|
// Fill the empty canvas with a centered material empty-state (icon + title)
|
|
// rather than a lone left-aligned caption stranded at the top of dead space.
|
|
material::DrawEmptyState(ICON_MD_CALL_RECEIVED, TR("no_recent_receives"));
|
|
ImGui::EndChild();
|
|
return;
|
|
}
|
|
|
|
for (size_t ri = 0; ri < recvs.size(); ri++) {
|
|
const auto& tx = *recvs[ri];
|
|
|
|
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
|
float rowY = rowPos.y + rowH * 0.5f;
|
|
|
|
// Icon
|
|
DrawRecvIcon(rowDL, rowPos.x + Layout::spacingMd(), rowY, iconSz * 0.5f, recvCol);
|
|
|
|
// Type label (first line)
|
|
float txX = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
|
|
const char* typeText = tx.type == "mined" ? TR("mined_type") : TR("received_label");
|
|
rowDL->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(txX, rowPos.y + 2.0f * dp), OnSurfaceMedium(), typeText);
|
|
|
|
// Address — start it AFTER the measured type-label width (not a fixed offset that shrinks below
|
|
// the label at narrow widths), mirroring Overview's recent-tx list. The schema offset is a floor.
|
|
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, typeText).x;
|
|
float addrX = txX + std::max(S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f) * hs,
|
|
typeW + Layout::spacingSm());
|
|
std::string addrDisplay = util::truncateMiddle(tx.address,
|
|
(int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
|
rowDL->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(addrX, rowPos.y + 2.0f * dp), OnSurfaceDisabled(), addrDisplay.c_str());
|
|
|
|
// Time ago — short "14d ago" form (shared helper, matches Overview/Send). Measure it
|
|
// first so the amount can chain its right edge off this width and never overlap.
|
|
std::string ago = util::formatTimeAgoShort(tx.timestamp);
|
|
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
|
|
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
|
float agoMargin = S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f) * hs;
|
|
rowDL->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(rightEdge - agoSz.x - agoMargin, rowPos.y + 2.0f * dp),
|
|
OnSurfaceDisabled(), ago.c_str());
|
|
|
|
// Amount (right-aligned, first line) — anchored to the LEFT of the time-ago text
|
|
// (measured width + a gap) so the two columns can never collide, whatever the strings.
|
|
snprintf(buf, sizeof(buf), "+%.4f %s", std::abs(tx.amount), DRAGONX_TICKER);
|
|
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, buf);
|
|
float amtGap = std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size,
|
|
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
|
float amtRightEdge = rightEdge - agoSz.x - agoMargin - amtGap;
|
|
float amtX = amtRightEdge - amtSz.x;
|
|
rowDL->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(amtX, rowPos.y + 2.0f * dp), recvCol, buf);
|
|
|
|
// Clickable row — hover highlight + navigate to History
|
|
float rowW = ImGui::GetContentRegionAvail().x;
|
|
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
|
if (material::IsRectHovered(rowPos, rowEnd)) {
|
|
rowDL->AddRectFilled(rowPos, rowEnd,
|
|
IM_COL32(255, 255, 255, 15),
|
|
S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
if (ImGui::IsMouseClicked(0)) {
|
|
app->setCurrentPage(ui::NavPage::History);
|
|
}
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, rowH));
|
|
}
|
|
|
|
ImGui::EndChild();
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN: RenderReceiveTab
|
|
// ============================================================================
|
|
void RenderReceiveTab(App* app)
|
|
{
|
|
const auto& state = app->getWalletState();
|
|
auto& S = schema::UI();
|
|
|
|
RenderSyncBanner(state);
|
|
|
|
ImVec2 recvAvail = ImGui::GetContentRegionAvail();
|
|
float hs = Layout::hScale(recvAvail.x);
|
|
float vScale = Layout::vScale(recvAvail.y);
|
|
float glassRound = Layout::glassRounding();
|
|
|
|
float availWidth = recvAvail.x;
|
|
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImFont* capFont = Type().caption();
|
|
ImFont* sub1 = Type().subtitle1();
|
|
ImFont* body2 = Type().body2();
|
|
GlassPanelSpec glassSpec;
|
|
glassSpec.rounding = glassRound;
|
|
|
|
float sectionGap = Layout::spacingXl() * vScale;
|
|
char buf[128];
|
|
|
|
// ================================================================
|
|
// NON-SCROLLING CONTENT — resizes to fit available height
|
|
// ================================================================
|
|
ImVec2 formAvail = ImGui::GetContentRegionAvail();
|
|
ImGui::BeginChild("##ReceiveScroll", formAvail, false,
|
|
ImGuiWindowFlags_NoBackground);
|
|
dl = ImGui::GetWindowDrawList();
|
|
|
|
// Top-aligned content — consistent vertical position across all tabs
|
|
static float s_recvContentH = 0;
|
|
float scrollAvailH = ImGui::GetContentRegionAvail().y;
|
|
float groupStartY = ImGui::GetCursorPosY();
|
|
float contentStartY = ImGui::GetCursorPosY();
|
|
|
|
// Reserve a slice of the available height for RECENT RECEIVED (ratio — mirrors
|
|
// balance_tab's recent-tx-reserve). The main card's target height is capped so it can
|
|
// never grow past (available - reserve): a no-op at 1.0x (mainCardTargetH already fits
|
|
// well within scrollAvailH there), but at HiDPI it prevents the card — whose QR (280*dp)
|
|
// and pads scale with dp while scrollAvailH is physical px — from eating the whole child
|
|
// and evicting the list.
|
|
float recvReserveRatio = S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
|
|
float recvRecentReserve = std::max(0.0f, scrollAvailH) * recvReserveRatio;
|
|
float recvCardCapH = std::max(0.0f, scrollAvailH - recvRecentReserve);
|
|
|
|
float formAvailW = ImGui::GetContentRegionAvail().x;
|
|
float formW = formAvailW;
|
|
ImGui::BeginGroup();
|
|
|
|
// ================================================================
|
|
// Not connected / empty state
|
|
// ================================================================
|
|
if (!app->isConnected()) {
|
|
ImVec2 emptyMin = ImGui::GetCursorScreenPos();
|
|
float emptyH = std::max(schema::UI().drawElement("tabs.receive", "empty-state-min-height").size, schema::UI().drawElement("tabs.receive", "empty-state-height").size * Layout::vScale());
|
|
ImVec2 emptyMax(emptyMin.x + formW, emptyMin.y + emptyH);
|
|
DrawGlassPanel(dl, emptyMin, emptyMax, glassSpec);
|
|
dl->AddText(sub1, sub1->LegacySize,
|
|
ImVec2(emptyMin.x + Layout::spacingXl(), emptyMin.y + Layout::spacingXl()),
|
|
OnSurfaceDisabled(), TR(app->isLiteBuild() ? "lite_no_wallet" : "waiting_for_daemon"));
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(emptyMin.x + Layout::spacingXl(), emptyMin.y + Layout::spacingXl() + sub1->LegacySize + S.drawElement("tabs.receive", "empty-state-subtitle-gap").size),
|
|
OnSurfaceDisabled(), TR("addresses_appear_here"));
|
|
ImGui::Dummy(ImVec2(formW, emptyH));
|
|
ImGui::EndGroup();
|
|
ImGui::EndChild();
|
|
return;
|
|
}
|
|
|
|
if (state.addresses.empty()) {
|
|
ImVec2 emptyMin = ImGui::GetCursorScreenPos();
|
|
// Hand-drawn skeleton geometry is absolute px — scale heights/offsets/rounding by
|
|
// dpiScale() so it doesn't render native-thin at >100% (width ratios multiply formW, leave as-is).
|
|
float dp = Layout::dpiScale();
|
|
float emptyH = S.drawElement("tabs.receive", "skeleton-height").size * dp;
|
|
ImVec2 emptyMax(emptyMin.x + formW, emptyMin.y + emptyH);
|
|
DrawGlassPanel(dl, emptyMin, emptyMax, glassSpec);
|
|
float alpha = (float)(schema::UI().drawElement("animations", "skeleton-base").size + schema::UI().drawElement("animations", "skeleton-amp").size * std::sin(ImGui::GetTime() * schema::UI().drawElement("animations", "pulse-speed-slow").size));
|
|
ImU32 skelCol = IM_COL32(255, 255, 255, (int)(alpha * 255));
|
|
float skelRound = schema::UI().drawElement("tabs.receive", "skeleton-rounding").size * dp;
|
|
dl->AddRectFilled(
|
|
ImVec2(emptyMin.x + Layout::spacingLg(), emptyMin.y + Layout::spacingLg()),
|
|
ImVec2(emptyMin.x + formW * S.drawElement("tabs.receive", "skeleton-bar1-width-ratio").size, emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar1-height").size * dp),
|
|
skelCol, skelRound);
|
|
dl->AddRectFilled(
|
|
ImVec2(emptyMin.x + Layout::spacingLg(), emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar2-top").size * dp),
|
|
ImVec2(emptyMin.x + formW * S.drawElement("tabs.receive", "skeleton-bar2-width-ratio").size, emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar2-bottom").size * dp),
|
|
skelCol, skelRound);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(emptyMin.x + Layout::spacingLg(), emptyMin.y + emptyH - S.drawElement("tabs.receive", "skeleton-text-bottom-offset").size * dp),
|
|
OnSurfaceDisabled(), TR("loading_addresses"));
|
|
ImGui::Dummy(ImVec2(formW, emptyH));
|
|
ImGui::EndGroup();
|
|
ImGui::EndChild();
|
|
return;
|
|
}
|
|
|
|
// Ensure valid selection
|
|
if (s_selected_address_idx < 0 ||
|
|
s_selected_address_idx >= (int)state.addresses.size()) {
|
|
s_selected_address_idx = 0;
|
|
}
|
|
|
|
const AddressInfo& selected = state.addresses[s_selected_address_idx];
|
|
bool isZ = selected.type == "shielded";
|
|
|
|
// Generate QR data — shielded only: a mining (transparent) address can't be paid, so it gets no QR.
|
|
std::string qr_data = isZ ? selected.address : std::string();
|
|
if (isZ && s_request_amount > 0) {
|
|
// Canonical, URL-encoded "drgx:" URI via the shared builder. The old inline code emitted an
|
|
// unparseable "dragonx:" scheme and appended the memo raw (spaces/&/= corrupted the QR).
|
|
qr_data = util::buildPaymentUri(selected.address, s_request_amount, "",
|
|
(s_request_memo[0] && isZ) ? std::string(s_request_memo) : std::string());
|
|
}
|
|
if (qr_data != s_cached_qr_data) {
|
|
if (s_qr_texture) {
|
|
FreeQRTexture(s_qr_texture);
|
|
s_qr_texture = 0;
|
|
}
|
|
if (!qr_data.empty()) {
|
|
int w, h;
|
|
s_qr_texture = GenerateQRTexture(qr_data.c_str(), &w, &h);
|
|
}
|
|
s_cached_qr_data = qr_data;
|
|
}
|
|
|
|
// ================================================================
|
|
// MAIN CARD — single glass panel (channel split like Send tab)
|
|
// ================================================================
|
|
{
|
|
// Cap + center the card so the address/amount column stops stretching while the
|
|
// QR plateaus. Shared with the Send tab via mainComposeCardBox() so the two card
|
|
// envelopes are identical (they must never drift in width/position again).
|
|
// The RECENT RECEIVED list below stays on the uncapped formW (handled separately).
|
|
float cardDp = Layout::dpiScale();
|
|
Layout::CardBox cardBox = Layout::mainComposeCardBox(formW);
|
|
float cardW = cardBox.width;
|
|
float cardLeftX = ImGui::GetCursorScreenPos().x;
|
|
float cardOffsetX = cardBox.offsetX;
|
|
if (cardOffsetX > 0.0f)
|
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + cardOffsetX);
|
|
|
|
ImVec2 containerMin = ImGui::GetCursorScreenPos();
|
|
float pad = Layout::spacingLg();
|
|
float innerW = cardW - pad * 2;
|
|
float innerGap = Layout::spacingLg();
|
|
|
|
// Channel split: content on ch1, glass background on ch0
|
|
dl->ChannelsSplit(2);
|
|
dl->ChannelsSetCurrent(1);
|
|
|
|
// Indent carries the centering offset too, so ImGui auto-layout content (the address
|
|
// dropdown/inputs/chips) lands at containerMin.x + pad — matching the hand-drawn geometry
|
|
// (glass panel / QR column) that keys off the offset containerMin.x.
|
|
ImGui::Indent(pad + cardOffsetX);
|
|
ImGui::Dummy(ImVec2(0, pad)); // top padding
|
|
|
|
// ---- ADDRESS DROPDOWN + QR CODE — side by side ----
|
|
{
|
|
float qrColW = std::min(innerW * schema::UI().drawElement("tabs.receive", "qr-col-width-ratio").size, 340.0f * cardDp);
|
|
float colGap = Layout::spacingLg();
|
|
float addrColW = innerW - qrColW - colGap;
|
|
float qrColX = containerMin.x + pad + addrColW + colGap;
|
|
ImVec2 sectionTop = ImGui::GetCursorScreenPos();
|
|
|
|
// LEFT: ADDRESS DROPDOWN + PAYMENT REQUEST
|
|
{
|
|
// Address dropdown replaces the old address display panel
|
|
RenderAddressDropdown(app, addrColW);
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
|
|
|
if (isZ) {
|
|
// ---- PAYMENT REQUEST ----
|
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("payment_request"));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
// Amount input with currency toggle
|
|
float toggleW = S.drawElement("tabs.receive", "currency-toggle-width").size * Layout::dpiScale();
|
|
float amtInputW = addrColW - toggleW - Layout::spacingMd();
|
|
if (amtInputW < S.drawElement("tabs.receive", "amount-input-min-width").size) amtInputW = S.drawElement("tabs.receive", "amount-input-min-width").size;
|
|
double usd_price = state.market.price_usd;
|
|
|
|
ImGui::PushFont(body2);
|
|
if (s_request_usd_mode && usd_price > 0) {
|
|
ImGui::PushItemWidth(amtInputW);
|
|
if (ImGui::InputDouble("##RequestAmountUSD", &s_request_usd_amount, 0, 0, "$%.2f")) {
|
|
if (s_request_usd_amount < 0) s_request_usd_amount = 0;
|
|
s_request_amount = s_request_usd_amount / usd_price;
|
|
}
|
|
{
|
|
ImVec2 iMin = ImGui::GetItemRectMin();
|
|
ImVec2 iMax = ImGui::GetItemRectMax();
|
|
snprintf(buf, sizeof(buf), "\xe2\x89\x88 %.4f %s", s_request_amount, DRAGONX_TICKER);
|
|
ImVec2 sz = capFont->CalcTextSizeA(capFont->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(capFont, capFont->LegacySize, ImVec2(tx, ty),
|
|
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "input-overlay-text-alpha").size), buf);
|
|
}
|
|
ImGui::PopItemWidth();
|
|
} else {
|
|
ImGui::PushItemWidth(amtInputW);
|
|
if (ImGui::InputDouble("##RequestAmount", &s_request_amount, 0, 0, "%.8f")) {
|
|
if (s_request_amount < 0) s_request_amount = 0;
|
|
if (usd_price > 0)
|
|
s_request_usd_amount = s_request_amount * usd_price;
|
|
}
|
|
if (usd_price > 0 && s_request_amount > 0) {
|
|
ImVec2 iMin = ImGui::GetItemRectMin();
|
|
ImVec2 iMax = ImGui::GetItemRectMax();
|
|
double usd_value = s_request_amount * usd_price;
|
|
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);
|
|
ImVec2 sz = capFont->CalcTextSizeA(capFont->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(capFont, capFont->LegacySize, ImVec2(tx, ty),
|
|
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "input-overlay-text-alpha").size), buf);
|
|
}
|
|
ImGui::PopItemWidth();
|
|
}
|
|
|
|
// Toggle button
|
|
ImGui::SameLine(0, Layout::spacingMd());
|
|
{
|
|
const char* currLabel = s_request_usd_mode ? "DRGX" : "USD";
|
|
bool canToggle = (usd_price > 0);
|
|
ImGui::BeginDisabled(!canToggle);
|
|
if (TactileButton("##ToggleCurrencyRecv", ImVec2(toggleW, 0), S.resolveFont("button"))) {
|
|
s_request_usd_mode = !s_request_usd_mode;
|
|
if (s_request_usd_mode && usd_price > 0)
|
|
s_request_usd_amount = s_request_amount * usd_price;
|
|
}
|
|
{
|
|
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.receive", "currency-icon-width").size * Layout::dpiScale();
|
|
float iconGap2 = schema::UI().drawElement("tabs.receive", "currency-icon-gap").size * Layout::dpiScale();
|
|
float totalW2 = iconW + iconGap2 + textSz.x;
|
|
float startX = bMin.x + ((bMax.x - bMin.x) - totalW2) * 0.5f;
|
|
float cy = bMin.y + bH * 0.5f;
|
|
ImU32 iconCol = ImGui::GetColorU32(ImGuiCol_Text);
|
|
float ss = iconW * 0.5f;
|
|
float cx = startX + ss;
|
|
float ag = S.drawElement("tabs.receive", "swap-icon-arrow-gap").size * cardDp;
|
|
float al = ss * S.drawElement("tabs.receive", "swap-icon-arrow-length-ratio").size;
|
|
float hss = S.drawElement("tabs.receive", "swap-icon-arrowhead-size").size * cardDp;
|
|
float ay1 = cy - ag;
|
|
dl->AddLine(ImVec2(cx - al, ay1), ImVec2(cx + al, ay1), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size * cardDp);
|
|
dl->AddTriangleFilled(
|
|
ImVec2(cx + al, ay1),
|
|
ImVec2(cx + al - hss, ay1 - hss),
|
|
ImVec2(cx + al - hss, ay1 + hss), iconCol);
|
|
float ay2 = cy + ag;
|
|
dl->AddLine(ImVec2(cx + al, ay2), ImVec2(cx - al, ay2), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size * cardDp);
|
|
dl->AddTriangleFilled(
|
|
ImVec2(cx - al, ay2),
|
|
ImVec2(cx - al + hss, ay2 - hss),
|
|
ImVec2(cx - al + hss, ay2 + hss), iconCol);
|
|
float tx = startX + iconW + iconGap2;
|
|
float ty = cy - textSz.y * 0.5f;
|
|
dl->AddText(font, font->LegacySize, ImVec2(tx, ty), iconCol, currLabel);
|
|
}
|
|
ImGui::EndDisabled();
|
|
}
|
|
ImGui::PopFont();
|
|
|
|
// Preset amount chips
|
|
{
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
|
|
float chipRound = schema::UI().drawElement("tabs.receive", "chip-rounding").size;
|
|
float chipGap = schema::UI().drawElement("tabs.receive", "chip-gap").size * cardDp;
|
|
float chipH = schema::UI().drawElement("tabs.receive", "chip-height").size * cardDp;
|
|
|
|
struct Preset { const char* label; double amount; };
|
|
Preset presets[] = {
|
|
{ "0.1", 0.1 },
|
|
{ "1", 1.0 },
|
|
{ "10", 10.0 },
|
|
{ "100", 100.0 },
|
|
{ "1K", 1000.0 },
|
|
{ "10K", 10000.0 },
|
|
{ "50K", 50000.0 },
|
|
{ "100K", 100000.0 },
|
|
};
|
|
constexpr int presetCount = 8;
|
|
|
|
// Calculate equal chip widths to span full addrColW
|
|
float totalGap = chipGap * (presetCount - 1);
|
|
float chipW = (addrColW - totalGap) / presetCount;
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, chipRound);
|
|
|
|
ImFont* chipFont = S.resolveFont("button");
|
|
|
|
for (int ci = 0; ci < presetCount; ci++) {
|
|
if (ci > 0) ImGui::SameLine(0, chipGap);
|
|
|
|
bool active = false;
|
|
if (s_request_usd_mode) {
|
|
active = (usd_price > 0 && std::abs(s_request_usd_amount - presets[ci].amount) < 0.001);
|
|
} else {
|
|
active = (std::abs(s_request_amount - presets[ci].amount) < 1e-8);
|
|
}
|
|
|
|
if (active) {
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "chip-active-bg-alpha").size)));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Primary()));
|
|
}
|
|
|
|
char chipId[32];
|
|
snprintf(chipId, sizeof(chipId), "%s##presetAmt%d", presets[ci].label, ci);
|
|
if (TactileButton(chipId, ImVec2(chipW, chipH), chipFont)) {
|
|
if (s_request_usd_mode) {
|
|
s_request_usd_amount = presets[ci].amount;
|
|
if (usd_price > 0)
|
|
s_request_amount = s_request_usd_amount / usd_price;
|
|
} else {
|
|
s_request_amount = presets[ci].amount;
|
|
if (usd_price > 0)
|
|
s_request_usd_amount = s_request_amount * usd_price;
|
|
}
|
|
}
|
|
|
|
if (active) ImGui::PopStyleColor(2);
|
|
}
|
|
|
|
ImGui::PopStyleVar();
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
// Memo (z-addresses only)
|
|
if (isZ) {
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("memo_optional"));
|
|
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.receive", "memo-label-gap").size));
|
|
|
|
float memoInputH = std::max(schema::UI().drawElement("tabs.receive", "memo-input-min-height").size, schema::UI().drawElement("tabs.receive", "memo-input-height").size * vScale);
|
|
ImGui::PushItemWidth(addrColW);
|
|
ImGui::InputTextMultiline("##RequestMemo", s_request_memo, sizeof(s_request_memo),
|
|
ImVec2(addrColW, memoInputH));
|
|
ImGui::PopItemWidth();
|
|
|
|
// The Sapling memo limit is in BYTES and matches the enforced buffer size
|
|
// (InputTextMultiline caps at sizeof(s_request_memo)); count against that, not the
|
|
// old display-only 256 value, and warn in colour once the field is at its cap.
|
|
size_t memo_len = strlen(s_request_memo);
|
|
size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
|
|
bool memoAtCap = memo_len + 1 >= memoMax;
|
|
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
|
|
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
|
|
}
|
|
|
|
// URI preview
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
if (s_request_amount > 0 && !s_cached_qr_data.empty()) {
|
|
float uriWrapW = addrColW;
|
|
dl->AddText(capFont, capFont->LegacySize, ImGui::GetCursorScreenPos(),
|
|
OnSurfaceDisabled(), s_cached_qr_data.c_str(), nullptr, uriWrapW);
|
|
ImVec2 uriSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX,
|
|
uriWrapW, s_cached_qr_data.c_str());
|
|
ImGui::Dummy(ImVec2(addrColW, uriSz.y + Layout::spacingSm()));
|
|
} else {
|
|
ImGui::Dummy(ImVec2(addrColW, capFont->LegacySize + Layout::spacingSm()));
|
|
}
|
|
} else {
|
|
// A mining (transparent) address can't be paid: no payment request and no QR code.
|
|
RenderMiningAddressNotice(app, state, addrColW);
|
|
}
|
|
|
|
}
|
|
float leftBottom = ImGui::GetCursorScreenPos().y;
|
|
|
|
// RIGHT: QR CODE
|
|
{
|
|
float qrAvailW = qrColW;
|
|
float rx = qrColX;
|
|
float ry = sectionTop.y;
|
|
|
|
float qrPadding = Layout::spacingMd();
|
|
float qrWidthBound = std::max(0.0f, qrAvailW - qrPadding * 2.0f);
|
|
float qrHeightBound = std::max(0.0f, leftBottom - sectionTop.y - qrPadding * 2.0f);
|
|
float qrMaxSize = qrHeightBound > 0.0f
|
|
? std::min(qrWidthBound, qrHeightBound)
|
|
: qrWidthBound;
|
|
float qrMinSize = std::min(S.drawElement("tabs.receive", "qr-min-size").size, qrWidthBound);
|
|
float qrSize = std::max(qrMinSize, qrMaxSize);
|
|
float totalQrSize = qrSize + qrPadding * 2;
|
|
float qrOffsetX = std::max(0.0f, (qrAvailW - totalQrSize) * 0.5f);
|
|
ImVec2 qrPanelMin(rx + qrOffsetX, ry);
|
|
ImVec2 qrPanelMax(qrPanelMin.x + totalQrSize, qrPanelMin.y + totalQrSize);
|
|
|
|
GlassPanelSpec qrGlass;
|
|
qrGlass.rounding = glassRound * S.drawElement("tabs.receive", "qr-glass-rounding-ratio").size;
|
|
qrGlass.fillAlpha = (int)S.drawElement("tabs.receive", "qr-glass-fill-alpha").size;
|
|
qrGlass.borderAlpha = (int)S.drawElement("tabs.receive", "qr-glass-border-alpha").size;
|
|
DrawGlassPanel(dl, qrPanelMin, qrPanelMax, qrGlass);
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(qrPanelMin.x + qrPadding, qrPanelMin.y + qrPadding));
|
|
if (!isZ) {
|
|
// A mining address: nothing to scan. Mark the panel so the empty square isn't a puzzle.
|
|
ImGui::Dummy(ImVec2(qrSize, qrSize));
|
|
ImFont* iconF = Type().iconXL();
|
|
const char* noPay = TR("receive_not_for_payments");
|
|
ImVec2 iSz = iconF->CalcTextSizeA(iconF->LegacySize, FLT_MAX, 0, ICON_MD_CONSTRUCTION);
|
|
ImVec2 tSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, qrSize, noPay);
|
|
const float blockH = iSz.y + Layout::spacingSm() + tSz.y;
|
|
const float cx = qrPanelMin.x + totalQrSize * 0.5f;
|
|
const float top = qrPanelMin.y + (totalQrSize - blockH) * 0.5f;
|
|
dl->AddText(iconF, iconF->LegacySize, ImVec2(cx - iSz.x * 0.5f, top), WithAlpha(Warning(), 200),
|
|
ICON_MD_CONSTRUCTION);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx - tSz.x * 0.5f, top + iSz.y + Layout::spacingSm()),
|
|
OnSurfaceDisabled(), noPay, nullptr, qrSize);
|
|
} else if (s_qr_texture) {
|
|
RenderQRCode(s_qr_texture, qrSize);
|
|
} else {
|
|
ImGui::Dummy(ImVec2(qrSize, qrSize));
|
|
ImVec2 textPos(qrPanelMin.x + totalQrSize * 0.5f - S.drawElement("tabs.receive", "qr-unavailable-text-offset").size * cardDp,
|
|
qrPanelMin.y + totalQrSize * 0.5f);
|
|
dl->AddText(capFont, capFont->LegacySize, textPos,
|
|
OnSurfaceDisabled(), TR("qr_unavailable"));
|
|
}
|
|
|
|
ImGui::SetCursorScreenPos(qrPanelMin);
|
|
ImGui::InvisibleButton("##QRClickCopy", ImVec2(totalQrSize, totalQrSize));
|
|
if (isZ && ImGui::IsItemHovered()) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
material::Tooltip("%s",
|
|
s_request_amount > 0 ? TR("click_copy_uri") : TR("click_copy_address"));
|
|
}
|
|
if (isZ && ImGui::IsItemClicked()) {
|
|
ImGui::SetClipboardText(qr_data.c_str());
|
|
Notifications::instance().info(s_request_amount > 0
|
|
? TR("payment_uri_copied") : TR("address_copied"));
|
|
}
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(rx, qrPanelMax.y));
|
|
ImGui::Dummy(ImVec2(qrAvailW, 0));
|
|
}
|
|
float rightBottom = ImGui::GetCursorScreenPos().y;
|
|
float sectionBottom = std::max(leftBottom, rightBottom);
|
|
ImGui::SetCursorScreenPos(ImVec2(containerMin.x + pad, sectionBottom));
|
|
ImGui::Dummy(ImVec2(innerW, 0));
|
|
}
|
|
|
|
// Divider before action buttons
|
|
{
|
|
float actionBtnH = std::max(S.drawElement("tabs.receive", "action-btn-min-height").size,
|
|
S.drawElement("tabs.receive", "action-btn-height").size * vScale);
|
|
float footerH = innerGap + actionBtnH + pad;
|
|
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
|
|
float targetCardH = std::min(Layout::mainCardTargetH(cardW, vScale), recvCardCapH);
|
|
float footerTopH = targetCardH - footerH;
|
|
if (currentCardH < footerTopH) {
|
|
ImGui::Dummy(ImVec2(0, footerTopH - currentCardH));
|
|
}
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
|
{
|
|
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
|
dl->AddLine(ImVec2(containerMin.x + pad, divPos.y),
|
|
ImVec2(containerMin.x + cardW - pad, divPos.y),
|
|
ImGui::GetColorU32(Divider()), S.drawElement("tabs.receive", "divider-thickness").size);
|
|
}
|
|
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
|
|
|
// ---- ACTION BUTTONS (inside card) ----
|
|
{
|
|
float btnGap = Layout::spacingMd();
|
|
float btnH = std::max(schema::UI().drawElement("tabs.receive", "action-btn-min-height").size, schema::UI().drawElement("tabs.receive", "action-btn-height").size * vScale);
|
|
// Justify the footer edge-to-edge like Send's [Review Send][Cancel] row instead of packing
|
|
// fixed-width buttons from the left (which left a large dead gap on the right). Split innerW
|
|
// into equal shares over the live button count: 2 by default (Clear Request + Explorer),
|
|
// 4 when an amount is requested (+ Copy URI + Share).
|
|
int nBtns = (s_request_amount > 0 ? 2 : 0) + 2;
|
|
float otherBtnW = std::max(S.drawElement("tabs.receive", "action-btn-min-width").size,
|
|
(innerW - (nBtns - 1) * btnGap) / (float)nBtns);
|
|
|
|
bool firstBtn = true;
|
|
|
|
if (s_request_amount > 0) {
|
|
if (!firstBtn) ImGui::SameLine(0, btnGap);
|
|
firstBtn = false;
|
|
if (TactileButton(TrId("copy_uri", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
|
|
ImGui::SetClipboardText(s_cached_qr_data.c_str());
|
|
Notifications::instance().info(TR("payment_uri_copied"));
|
|
}
|
|
}
|
|
|
|
if (s_request_amount > 0) {
|
|
if (!firstBtn) ImGui::SameLine(0, btnGap);
|
|
firstBtn = false;
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
|
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "btn-hover-alpha").size)));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(PrimaryLight()));
|
|
if (TactileButton(TrId("share", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
|
|
char shareBuf[1024];
|
|
snprintf(shareBuf, sizeof(shareBuf),
|
|
"%s\n%s: %.8f %s\n%s: %s\nURI: %s",
|
|
TR("payment_request"), TR("amount"),
|
|
s_request_amount, DRAGONX_TICKER,
|
|
TR("address"), selected.address.c_str(),
|
|
s_cached_qr_data.c_str());
|
|
ImGui::SetClipboardText(shareBuf);
|
|
Notifications::instance().info(TR("payment_request_copied"));
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
}
|
|
|
|
{
|
|
bool hasData = (s_request_amount > 0 || s_request_memo[0]);
|
|
if (!firstBtn) ImGui::SameLine(0, btnGap);
|
|
firstBtn = false;
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
|
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "clear-btn-hover-alpha").size)));
|
|
ImGui::PushStyleColor(ImGuiCol_Text,
|
|
ImGui::ColorConvertU32ToFloat4(hasData ? OnSurfaceMedium() : OnSurfaceDisabled()));
|
|
ImGui::BeginDisabled(!hasData);
|
|
if (TactileButton(TrId("clear_request", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
|
|
s_request_amount = 0.0;
|
|
s_request_usd_amount = 0.0;
|
|
s_request_memo[0] = '\0';
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::PopStyleColor(3);
|
|
}
|
|
|
|
if (!firstBtn) ImGui::SameLine(0, btnGap);
|
|
firstBtn = false;
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
|
ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.receive", "btn-hover-alpha").size)));
|
|
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.receive", "explorer-btn-border-size").size);
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(PrimaryLight()));
|
|
if (TactileButton(TrId("explorer", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
|
|
OpenExplorerURL(app, selected.address);
|
|
}
|
|
ImGui::PopStyleVar(); // FrameBorderSize
|
|
ImGui::PopStyleColor(4);
|
|
|
|
}
|
|
|
|
// Bottom padding
|
|
ImGui::Dummy(ImVec2(0, pad));
|
|
ImGui::Unindent(pad + cardOffsetX);
|
|
|
|
// Enforce shared card height (matches QR-driven target), capped so the reserved
|
|
// RECENT RECEIVED slice below stays on-screen at HiDPI.
|
|
{
|
|
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
|
|
float targetCardH = std::min(Layout::mainCardTargetH(cardW, vScale), recvCardCapH);
|
|
if (currentCardH < targetCardH)
|
|
ImGui::Dummy(ImVec2(0, targetCardH - currentCardH));
|
|
}
|
|
|
|
// Draw glass panel background on channel 0
|
|
ImVec2 containerMax(containerMin.x + cardW, ImGui::GetCursorScreenPos().y);
|
|
dl->ChannelsSetCurrent(0);
|
|
DrawGlassPanel(dl, containerMin, containerMax, glassSpec);
|
|
dl->ChannelsMerge();
|
|
|
|
// Restore the original (uncapped) left edge so RECENT RECEIVED below renders
|
|
// full-width as-is, unaffected by the card's centering offset.
|
|
ImGui::SetCursorScreenPos(ImVec2(cardLeftX, containerMax.y));
|
|
ImGui::Dummy(ImVec2(formW, 0));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
}
|
|
|
|
// ================================================================
|
|
// RECENT RECEIVED
|
|
// ================================================================
|
|
RenderRecentReceived(selected, state, formW, capFont, app);
|
|
|
|
ImGui::EndGroup();
|
|
float measuredH = ImGui::GetCursorPosY() - contentStartY;
|
|
s_recvContentH = std::round(measuredH);
|
|
ImGui::EndChild(); // ##ReceiveScroll
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|