Implements the layout-improvement suggestions from the layout audit (visual arrangement only; no functionality added or removed): - Send/Receive: the recent-activity list now grows to fill the space below the fixed compose/receive card (more history visible), with a centered empty-state when there is none — instead of leaving dead canvas. - Shield/Merge: pair the Fee and UTXO-Limit fields on one row to tighten vertical rhythm. - Market: extend + frame the portfolio group-list as one contained panel (with a bottom edge) and center its empty-state, closing the previously un-anchored gap. - Overlay dialogs: raise the card glass fill/border alpha (35/50 -> 60/90 of 255) so the dialog card reads as a distinct surface over busy backdrops (global, all overlays). - Wallets: size the list height to the actual wallet count instead of always reserving 7 rows, removing the large gap before the scan/create prompts (still scrolls when many). - Contacts: width-aware address truncation shows more of the address on wide rows. - Transfer Funds: give the "sends the full balance" warning a warning icon + color so the stakes stand out from the neutral result-preview lines. - First-run wizard: collapse a completed Step 1 (Appearance) to the compact pill like Step 2, so a finished step is no longer taller than the active one. - Explorer: distribute the Chain card's two stats to match the density of the sibling metrics grid. - Validate Address: a "Results will appear here" caption fills the pre-interaction blank. - Change Passphrase: add the warning banner its sibling security dialogs have. - Migration ShowSeed: box the 24-word mnemonic grid (a GlassSectionScope behind the existing RenderSeedWordGrid) so the critical secret reads as a distinct artifact — purely visual, no seed/logic/state change. Verified at 1280 across full-node + Lite + Windows (ctest green) and an adversarial diff review (clean). New i18n key backfilled into all 8 languages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
334 lines
13 KiB
C++
334 lines
13 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#pragma once
|
|
|
|
#include <string>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <cmath>
|
|
#include "../../app.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "../notifications.h"
|
|
#include "../theme.h"
|
|
#include "imgui.h"
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
/**
|
|
* @brief Modal dialog for transferring funds between two addresses.
|
|
*
|
|
* Shown when a user drags one address row onto another in the address list.
|
|
* Displays from/to addresses, balances, amount input, fee, and a preview of
|
|
* the resulting balances after transfer.
|
|
*/
|
|
struct AddressTransferInfo {
|
|
std::string fromAddr;
|
|
std::string toAddr;
|
|
double fromBalance = 0.0;
|
|
double toBalance = 0.0;
|
|
bool fromIsZ = false;
|
|
bool toIsZ = false;
|
|
};
|
|
|
|
class AddressTransferDialog {
|
|
public:
|
|
using TransferInfo = AddressTransferInfo;
|
|
|
|
static void show(App* app, const TransferInfo& info) {
|
|
s_open = true;
|
|
s_app = app;
|
|
s_info = info;
|
|
s_sending = false;
|
|
s_resultMsg.clear();
|
|
s_success = false;
|
|
|
|
// Default fee
|
|
s_fee = 0.0001;
|
|
|
|
// Pre-fill with the maximum sendable amount so the dialog is valid
|
|
// immediately without requiring the user to press Max.
|
|
snprintf(s_amount, sizeof(s_amount), "%.8f", maxSendableAmount(info.fromBalance, s_fee));
|
|
}
|
|
|
|
static void render() {
|
|
if (!s_open || !s_app) return;
|
|
|
|
using namespace material;
|
|
|
|
OverlayDialogSpec ov;
|
|
ov.title = TR("transfer_funds"); ov.p_open = &s_open;
|
|
ov.style = OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 620.0f; ov.idSuffix = "addrtransfer";
|
|
if (BeginOverlayDialog(ov)) {
|
|
float dp = Layout::dpiScale();
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
|
|
// If we got a result, show it
|
|
if (!s_resultMsg.empty()) {
|
|
renderResult();
|
|
EndOverlayDialog();
|
|
return;
|
|
}
|
|
|
|
// Shielding / deshielding warning
|
|
if (s_info.fromIsZ && !s_info.toIsZ) {
|
|
DialogWarningHeader(TR("deshielding_warning"));
|
|
ImGui::Spacing();
|
|
} else if (!s_info.fromIsZ && s_info.toIsZ) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Success()));
|
|
ImGui::TextWrapped("%s", TR("shielding_notice"));
|
|
ImGui::PopStyleColor();
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// From address
|
|
Type().text(TypeStyle::Overline, TR("from"));
|
|
renderAddressRow(s_info.fromAddr, s_info.fromBalance, s_info.fromIsZ, dp);
|
|
ImGui::Spacing();
|
|
|
|
// Arrow
|
|
{
|
|
float arrowCX = ImGui::GetContentRegionAvail().x * 0.5f;
|
|
ImGui::SetCursorPosX(arrowCX - 8.0f);
|
|
ImFont* iconFont = Type().iconMed();
|
|
float fsz = ScaledFontSize(iconFont);
|
|
ImVec2 pos = ImGui::GetCursorScreenPos();
|
|
dl->AddText(iconFont, fsz, pos, OnSurfaceMedium(), ICON_MD_ARROW_DOWNWARD);
|
|
ImGui::Dummy(ImVec2(fsz, fsz));
|
|
}
|
|
ImGui::Spacing();
|
|
|
|
// To address
|
|
Type().text(TypeStyle::Overline, TR("to"));
|
|
renderAddressRow(s_info.toAddr, s_info.toBalance, s_info.toIsZ, dp);
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
|
|
// Amount input + Max button on same row without overflow
|
|
Type().text(TypeStyle::Subtitle2, TR("amount"));
|
|
{
|
|
float spacing = ImGui::GetStyle().ItemSpacing.x;
|
|
float maxBtnW = ImGui::CalcTextSize(TR("max")).x
|
|
+ ImGui::GetStyle().FramePadding.x * 2.0f;
|
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - maxBtnW - spacing);
|
|
}
|
|
ImGui::InputText("##TransferAmt", s_amount, sizeof(s_amount),
|
|
ImGuiInputTextFlags_CharsDecimal);
|
|
|
|
// Max button
|
|
ImGui::SameLine();
|
|
if (material::TactileSmallButton(TR("max"))) {
|
|
snprintf(s_amount, sizeof(s_amount), "%.8f",
|
|
maxSendableAmount(s_info.fromBalance, s_fee));
|
|
}
|
|
ImGui::Spacing();
|
|
|
|
// Fee display
|
|
{
|
|
char feeBuf[64];
|
|
snprintf(feeBuf, sizeof(feeBuf), "%s: %.4f DRGX", TR("fee"), s_fee);
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), feeBuf);
|
|
}
|
|
ImGui::Spacing();
|
|
|
|
// Preview after transfer
|
|
double amount = atof(s_amount);
|
|
double totalDeduct = amount + s_fee;
|
|
double newFromBal = s_info.fromBalance - totalDeduct;
|
|
double newToBal = s_info.toBalance + amount;
|
|
bool amountValid = amount > 0 && totalDeduct <= s_info.fromBalance;
|
|
|
|
ImGui::Separator();
|
|
ImGui::Spacing();
|
|
Type().text(TypeStyle::Subtitle2, TR("result_preview"));
|
|
ImGui::Spacing();
|
|
{
|
|
char buf[128];
|
|
snprintf(buf, sizeof(buf), TR("sender_balance"),
|
|
s_info.fromBalance, amountValid ? newFromBal : s_info.fromBalance);
|
|
Type().textColored(TypeStyle::Caption,
|
|
(amountValid && newFromBal < 1e-9) ? Warning() : OnSurfaceMedium(), buf);
|
|
}
|
|
{
|
|
char buf[128];
|
|
snprintf(buf, sizeof(buf), TR("recipient_balance"),
|
|
s_info.toBalance, amountValid ? newToBal : s_info.toBalance);
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf);
|
|
}
|
|
|
|
// Warnings
|
|
if (!amountValid && amount > 0) {
|
|
ImGui::Spacing();
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error()));
|
|
ImGui::TextWrapped("%s", TR("insufficient_funds"));
|
|
ImGui::PopStyleColor();
|
|
}
|
|
if (amountValid && newFromBal < 1e-9) {
|
|
ImGui::Spacing();
|
|
// Full-balance send: same warning-icon treatment as the de-shielding header above,
|
|
// so this stakes-bearing line reads as distinct from the neutral preview text.
|
|
DialogWarningHeader(TR("sends_full_balance_warning"));
|
|
}
|
|
|
|
// Buttons
|
|
const char* cancelLabel = TR("cancel");
|
|
const char* confirmLabel = TR("confirm_transfer");
|
|
const char* sendingLabel = TR("sending");
|
|
ImFont* buttonFont = Type().button();
|
|
float buttonFontSize = ScaledFontSize(buttonFont);
|
|
float confirmMinW = 160.0f * dp;
|
|
float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
|
|
// Both footer buttons share one width (equal-width primary/Close pair), sized to fit the
|
|
// widest label — the "Sending…" swap label included — so nothing clips.
|
|
float confirmTextW = std::max(
|
|
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x,
|
|
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x);
|
|
float btnW = std::max(confirmMinW, confirmTextW + buttonPadW);
|
|
float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f; // footer divider removed
|
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
|
float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f;
|
|
float footerTopY = cardBottomY - 24.0f * dp - footerH;
|
|
float currentY = ImGui::GetCursorScreenPos().y;
|
|
if (currentY < footerTopY) {
|
|
ImGui::Dummy(ImVec2(0, footerTopY - currentY));
|
|
} else {
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
// Standardized primary + Close footer (centered, no divider). The primary is the
|
|
// Confirm/"Sending…" action; the Close button dismisses the dialog.
|
|
bool outConfirm = false;
|
|
bool outClose = false;
|
|
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
|
|
amountValid && !s_sending,
|
|
cancelLabel, outConfirm, outClose, btnW);
|
|
|
|
if (outClose) {
|
|
s_open = false;
|
|
}
|
|
if (outConfirm) {
|
|
s_sending = true;
|
|
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
|
|
amount, s_fee, "",
|
|
[](bool ok, const std::string& result) {
|
|
s_sending = false;
|
|
s_success = ok;
|
|
s_resultMsg = result;
|
|
if (ok) {
|
|
Notifications::instance().success(TR("transfer_sent_desc"));
|
|
} else {
|
|
Notifications::instance().error(result.empty() ? TR("transfer_failed") : result);
|
|
}
|
|
});
|
|
// Keep the dialog OPEN on submit: s_sending drives the button to a disabled "Sending…"
|
|
// state, and when the async callback sets s_resultMsg the in-dialog result screen shows
|
|
// (with its own Close button). Previously closing here made that result screen dead code.
|
|
}
|
|
|
|
EndOverlayDialog();
|
|
}
|
|
}
|
|
|
|
static bool isOpen() { return s_open; }
|
|
static void close() { s_open = false; }
|
|
|
|
private:
|
|
static double maxSendableAmount(double balance, double fee) {
|
|
double maxAmt = balance - fee;
|
|
return maxAmt > 0.0 ? maxAmt : 0.0;
|
|
}
|
|
|
|
static void renderAddressRow(const std::string& addr, double balance, bool isZ, float dp) {
|
|
using namespace material;
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImVec2 pos = ImGui::GetCursorScreenPos();
|
|
float w = ImGui::GetContentRegionAvail().x;
|
|
float h = 40.0f * dp;
|
|
ImVec2 mn = pos, mx(pos.x + w, pos.y + h);
|
|
|
|
GlassPanelSpec g;
|
|
g.rounding = 8.0f * dp;
|
|
g.fillAlpha = 18;
|
|
DrawGlassPanel(dl, mn, mx, g);
|
|
|
|
float pad = 12.0f * dp;
|
|
// Type icon
|
|
ImFont* iconFont = Type().iconSmall();
|
|
const char* icon = isZ ? ICON_MD_SHIELD : ICON_MD_CIRCLE;
|
|
ImU32 col = isZ ? Success() : Warning();
|
|
float iconFsz = ScaledFontSize(iconFont);
|
|
ImVec2 iSz = iconFont->CalcTextSizeA(iconFsz, 1000.0f, 0.0f, icon);
|
|
dl->AddText(iconFont, iconFsz, ImVec2(mn.x + pad, mn.y + (h - iSz.y) * 0.5f), col, icon);
|
|
|
|
// Address (truncated)
|
|
// Balance (right-aligned) — computed first so we know how much space address gets
|
|
char balBuf[32];
|
|
snprintf(balBuf, sizeof(balBuf), "%.8f DRGX", balance);
|
|
ImFont* body = Type().body2();
|
|
float bodyFsz = ScaledFontSize(body);
|
|
ImVec2 balSz = body->CalcTextSizeA(bodyFsz, 1000.0f, 0.0f, balBuf);
|
|
float balX = mx.x - pad - balSz.x;
|
|
dl->AddText(body, bodyFsz, ImVec2(balX, mn.y + (h - balSz.y) * 0.5f),
|
|
balance > 0 ? OnSurface() : OnSurfaceDisabled(), balBuf);
|
|
|
|
float textX = mn.x + pad + iSz.x + 8.0f * dp;
|
|
ImFont* capFont = Type().caption();
|
|
float capFsz = ScaledFontSize(capFont);
|
|
// Clip address text so it never overlaps the balance
|
|
float addrMaxW = balX - textX - 8.0f * dp;
|
|
dl->PushClipRect(ImVec2(textX, mn.y), ImVec2(textX + addrMaxW, mn.y + h), true);
|
|
dl->AddText(capFont, capFsz, ImVec2(textX, mn.y + (h * 0.5f - capFsz)), OnSurfaceMedium(), addr.c_str());
|
|
dl->PopClipRect();
|
|
|
|
ImGui::Dummy(ImVec2(w, h));
|
|
}
|
|
|
|
static void renderResult() {
|
|
using namespace material;
|
|
if (s_success) {
|
|
ImGui::Spacing();
|
|
Type().text(TypeStyle::H6, TR("transfer_sent"));
|
|
ImGui::Spacing();
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("transfer_sent_desc"));
|
|
ImGui::Spacing();
|
|
{
|
|
char buf[128];
|
|
snprintf(buf, sizeof(buf), TR("shield_operation_id"),
|
|
s_resultMsg.size() > 40
|
|
? (s_resultMsg.substr(0, 20) + "..." + s_resultMsg.substr(s_resultMsg.size() - 10)).c_str()
|
|
: s_resultMsg.c_str());
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
|
}
|
|
} else {
|
|
ImGui::Spacing();
|
|
Type().textColored(TypeStyle::H6, Error(), TR("transfer_failed"));
|
|
ImGui::Spacing();
|
|
ImGui::TextWrapped("%s", s_resultMsg.c_str());
|
|
}
|
|
|
|
ImGui::Spacing();
|
|
ImGui::Spacing();
|
|
float btnW = 120.0f;
|
|
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - btnW) * 0.5f);
|
|
if (TactileButton(TR("close"), ImVec2(btnW, 0))) {
|
|
s_open = false;
|
|
}
|
|
}
|
|
|
|
static inline bool s_open = false;
|
|
static inline App* s_app = nullptr;
|
|
static inline TransferInfo s_info;
|
|
static inline char s_amount[64] = {};
|
|
static inline double s_fee = 0.0001;
|
|
static inline bool s_sending = false;
|
|
static inline bool s_success = false;
|
|
static inline std::string s_resultMsg;
|
|
};
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|