// 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 #include #include #include #include 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(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(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 sortedIdx = sortedSpendableAddressIndices(state.addresses); if (sortedIdx.empty()) { ImGui::TextDisabled("%s", TR("send_no_balance")); } else { size_t addrTruncLen = static_cast(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(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(i)); if (ImGui::Selectable(buf, isCurrent)) { s_selected_from_idx = static_cast(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 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 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((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