feat: modernize address list with drag-transfer, labels, and UX polish
- Rewrite RenderSharedAddressList with two-pass layout architecture - Add drag-to-transfer: drag address onto another to open transfer dialog - Add AddressLabelDialog with custom label text and 20-icon picker - Add AddressTransferDialog with amount input, fee, and balance preview - Add AddressMeta persistence (label, icon, sortOrder) in settings.json - Gold favorite border inset 2dp from container edge - Show hide button on all addresses, not just zero-balance - Smaller star/hide buttons to clear favorite border - Semi-transparent dragged row with context-aware tooltip - Copy-to-clipboard deferred to mouse-up (no copy on drag) - Themed colors via resolveColor() with CSS variable fallbacks - Keyboard nav (Up/Down/J/K, Enter to copy, F2 to edit label) - Add i18n keys for all new UI strings
This commit is contained in:
293
src/ui/windows/address_transfer_dialog.h
Normal file
293
src/ui/windows/address_transfer_dialog.h
Normal file
@@ -0,0 +1,293 @@
|
||||
// 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 "../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;
|
||||
|
||||
// Pre-fill amount with full source balance
|
||||
snprintf(s_amount, sizeof(s_amount), "%.8f", info.fromBalance);
|
||||
|
||||
// Default fee
|
||||
s_fee = 0.0001;
|
||||
}
|
||||
|
||||
static void render() {
|
||||
if (!s_open || !s_app) return;
|
||||
|
||||
using namespace material;
|
||||
|
||||
if (BeginOverlayDialog(TR("transfer_funds"), &s_open, 480.0f, 0.94f)) {
|
||||
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) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||
ImGui::TextWrapped("%s", TR("deshielding_warning"));
|
||||
ImGui::PopStyleColor();
|
||||
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
|
||||
Type().text(TypeStyle::Subtitle2, TR("amount"));
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
ImGui::InputText("##TransferAmt", s_amount, sizeof(s_amount),
|
||||
ImGuiInputTextFlags_CharsDecimal);
|
||||
|
||||
// Max button
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton(TR("max"))) {
|
||||
double maxAmt = s_info.fromBalance - s_fee;
|
||||
if (maxAmt < 0) maxAmt = 0;
|
||||
snprintf(s_amount, sizeof(s_amount), "%.8f", maxAmt);
|
||||
}
|
||||
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), "%s: %.8f DRGX → %.8f DRGX",
|
||||
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), "%s: %.8f DRGX → %.8f DRGX",
|
||||
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();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||
ImGui::TextWrapped("%s", TR("sends_full_balance_warning"));
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Buttons
|
||||
float btnW = 140.0f;
|
||||
float totalW = btnW * 2 + Layout::spacingMd();
|
||||
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - totalW) * 0.5f);
|
||||
|
||||
if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
|
||||
s_open = false;
|
||||
}
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
|
||||
ImGui::BeginDisabled(!amountValid || s_sending);
|
||||
if (TactileButton(s_sending ? TR("sending") : TR("confirm_transfer"), ImVec2(btnW, 0))) {
|
||||
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;
|
||||
if (ok)
|
||||
s_resultMsg = result; // opid
|
||||
else
|
||||
s_resultMsg = result; // error message
|
||||
});
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
EndOverlayDialog();
|
||||
}
|
||||
}
|
||||
|
||||
static bool isOpen() { return s_open; }
|
||||
static void close() { s_open = false; }
|
||||
|
||||
private:
|
||||
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)
|
||||
float textX = mn.x + pad + iSz.x + 8.0f * dp;
|
||||
std::string display = addr;
|
||||
if (display.size() > 42) display = display.substr(0, 18) + "..." + display.substr(display.size() - 14);
|
||||
ImFont* capFont = Type().caption();
|
||||
float capFsz = ScaledFontSize(capFont);
|
||||
dl->AddText(capFont, capFsz, ImVec2(textX, mn.y + (h * 0.5f - capFsz)), OnSurfaceMedium(), display.c_str());
|
||||
|
||||
// Balance (right-aligned)
|
||||
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);
|
||||
dl->AddText(body, bodyFsz, ImVec2(mx.x - pad - balSz.x, mn.y + (h - balSz.y) * 0.5f),
|
||||
balance > 0 ? OnSurface() : OnSurfaceDisabled(), balBuf);
|
||||
|
||||
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), "Operation ID: %s",
|
||||
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
|
||||
Reference in New Issue
Block a user