feat(send): usable-while-syncing form, calmer validation, honest clear/memo/source
Send-tab UX pass (presentation/validation only — the z_sendmany submit path, its amounts, fee, recipients and memo bytes are unchanged, and the Review button plus the confirm-time live re-check still block any send until the wallet is synced): - The form is now editable while syncing (so the recipient/amount/memo can be pre-filled); only Review/Send stays gated, with an "available once synced" note. - The recipient's red "Invalid address" no longer flashes from the first keystroke — it waits until the field loses focus. - The memo field stays visible (text preserved) with a "requires a shielded recipient" explainer for transparent recipients, instead of vanishing + silently dropping what was typed. - "Cancel" (which only cleared the form) is relabeled "Clear form", disabled when empty, and drops its redundant confirm (the timed Undo already covers it). - The source picker shows address labels, and warns when the chosen source is transparent (non-private on this chain). Also adds the string keys used here and by the next commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+66
-45
@@ -77,7 +77,6 @@ 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;
|
||||
@@ -268,9 +267,13 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
const char* tag = isZ ? "[Z]" : "[T]";
|
||||
std::string trunc = util::truncateMiddle(addr.address,
|
||||
static_cast<int>(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 — %s",
|
||||
tag, trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
std::string lbl = app->getAddressLabel(addr.address);
|
||||
if (!lbl.empty())
|
||||
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
s_source_preview = buf;
|
||||
} else if (s_from_address[0] != '\0') {
|
||||
// A pre-filled source that the dropdown list doesn't carry (zero-balance / view-only / list still
|
||||
@@ -313,9 +316,13 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
const char* tag = isZ ? "[Z]" : "[T]";
|
||||
|
||||
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
|
||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
||||
tag, trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
std::string lbl = app->getAddressLabel(addr.address);
|
||||
if (!lbl.empty())
|
||||
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
if (ImGui::Selectable(buf, isCurrent)) {
|
||||
@@ -324,9 +331,12 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
addr.address.c_str());
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
material::Tooltip("%s\n%s",
|
||||
addr.address.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
if (!lbl.empty())
|
||||
material::Tooltip("%s\n%s\n%s", lbl.c_str(), addr.address.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
else
|
||||
material::Tooltip("%s\n%s", addr.address.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
@@ -335,6 +345,20 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
ImGui::PopFont();
|
||||
|
||||
// Warn when the chosen source is a transparent address: spending from it is not private on this
|
||||
// ac_private chain (sender, amount and recipient become visible on-chain).
|
||||
if (app->isConnected() && s_from_address[0] != '\0') {
|
||||
bool srcT = (s_selected_from_idx >= 0 && s_selected_from_idx < (int)state.addresses.size())
|
||||
? (state.addresses[s_selected_from_idx].type != "shielded")
|
||||
: IsValidTransparentAddr(s_from_address);
|
||||
if (srcT) {
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||
Type().textWrapped(TypeStyle::Caption, TR("send_transparent_source_warn"));
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1033,6 +1057,12 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
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);
|
||||
|
||||
// Explain why Review/Send is greyed while syncing (the fields above are usable now).
|
||||
if (state.sync.syncing) {
|
||||
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
}
|
||||
|
||||
// ---- Send button ----
|
||||
if (!can_send) {
|
||||
// The disabled fill must contrast the surface: a white overlay reads on dark skins but vanished
|
||||
@@ -1081,38 +1111,20 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
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), "%s##Cancel%s", TR("cancel"), suffix);
|
||||
// This button never cancels an in-progress send (there is none here) — it clears the form. Relabel it
|
||||
// honestly, disable it when the form is empty, and drop the redundant confirm (ClearFormWithUndo
|
||||
// already offers a timed Undo). The "##Cancel" id suffix is kept so the widget id stays stable.
|
||||
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("send_clear_form"), suffix);
|
||||
ImGui::BeginDisabled(!FormHasData());
|
||||
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
|
||||
if (FormHasData()) {
|
||||
s_clear_confirm_pending = true;
|
||||
} else {
|
||||
ClearFormWithUndo();
|
||||
}
|
||||
ClearFormWithUndo();
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||
material::Tooltip("%s", TR("send_clear_form_tip"));
|
||||
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 * dp, 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 * dp, 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;
|
||||
@@ -1270,9 +1282,9 @@ void RenderSendTab(App* app)
|
||||
|
||||
RenderSyncBanner(state);
|
||||
|
||||
bool sendSyncing = state.sync.syncing && !state.sync.isSynced();
|
||||
ImGui::BeginDisabled(sendSyncing);
|
||||
|
||||
// The form stays EDITABLE while syncing so the user can pre-fill it; only the Review/Send button is
|
||||
// gated (can_send includes !syncing, and the confirm step re-checks live state), so nothing can be
|
||||
// sent until the wallet is synced.
|
||||
ImVec2 sendAvail = ImGui::GetContentRegionAvail();
|
||||
float hs = Layout::hScale(sendAvail.x);
|
||||
float vScale = Layout::vScale(sendAvail.y);
|
||||
@@ -1329,7 +1341,6 @@ void RenderSendTab(App* app)
|
||||
// Zero balance CTA
|
||||
if (RenderZeroBalanceCTA(app, dl, formW)) {
|
||||
ImGui::EndGroup();
|
||||
ImGui::EndDisabled(); // sendSyncing guard
|
||||
ImGui::EndChild();
|
||||
return;
|
||||
}
|
||||
@@ -1404,7 +1415,9 @@ void RenderSendTab(App* app)
|
||||
Type().textColored(TypeStyle::Caption, Error(), TR("send_mining_addr_short"));
|
||||
if (vt && ImGui::IsItemHovered())
|
||||
material::Tooltip("%s", TR("send_mining_addr_blocked"));
|
||||
} else if (!checkPreview) {
|
||||
} else if (!checkPreview && ImGui::GetActiveID() != ImGui::GetID("##ToAddr")) {
|
||||
// Defer the "invalid" error until the field loses focus, so it doesn't flash red
|
||||
// from the first keystroke while the user is still typing the address.
|
||||
ImGui::SameLine();
|
||||
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
|
||||
}
|
||||
@@ -1612,8 +1625,11 @@ void RenderSendTab(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MEMO (shielded only) ----
|
||||
if (is_valid_z || s_to_address[0] == '\0') {
|
||||
// ---- MEMO (shielded only; shown disabled + explained for non-shielded recipients) ----
|
||||
{
|
||||
// Keep the memo visible (and the typed text intact) even for a transparent recipient, with an
|
||||
// explainer, instead of silently hiding the field and dropping what the user wrote.
|
||||
const bool memoDisabled = (s_to_address[0] != '\0' && !is_valid_z);
|
||||
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
||||
{
|
||||
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
||||
@@ -1628,8 +1644,10 @@ void RenderSendTab(App* app)
|
||||
|
||||
float memoInputH = std::max(S.drawElement("tabs.send", "memo-min-height").size, S.drawElement("tabs.send", "memo-base-height").size * vScale);
|
||||
ImGui::PushItemWidth(colW);
|
||||
if (memoDisabled) ImGui::BeginDisabled();
|
||||
ImGui::InputTextMultiline("##Memo", s_memo, sizeof(s_memo),
|
||||
ImVec2(colW, memoInputH));
|
||||
if (memoDisabled) ImGui::EndDisabled();
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
// The Sapling memo limit is in BYTES (multi-byte UTF-8 fills it faster than visible chars);
|
||||
@@ -1639,6 +1657,10 @@ void RenderSendTab(App* app)
|
||||
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);
|
||||
if (memoDisabled) {
|
||||
const char* mk = s_memo[0] ? "send_memo_will_not_be_sent" : "send_memo_requires_shielded";
|
||||
Type().textColored(TypeStyle::Caption, Warning(), TR(mk));
|
||||
}
|
||||
}
|
||||
|
||||
// Divider before action buttons
|
||||
@@ -1706,7 +1728,6 @@ void RenderSendTab(App* app)
|
||||
RenderRecentSends(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;
|
||||
|
||||
@@ -1151,6 +1151,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["recent_sends"] = "RECENT SENDS";
|
||||
strings_["recipient"] = "RECIPIENT";
|
||||
strings_["memo_optional"] = "MEMO (OPTIONAL)";
|
||||
strings_["send_memo_requires_shielded"] = "Memos require a shielded (z) recipient address.";
|
||||
strings_["send_memo_will_not_be_sent"] = "This memo will not be sent: memos only work with shielded (z) recipients.";
|
||||
strings_["no_recent_sends"] = "No recent sends";
|
||||
strings_["network_fee"] = "NETWORK FEE";
|
||||
strings_["amount_details"] = "AMOUNT DETAILS";
|
||||
@@ -2575,6 +2577,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["request_payment_uri"] = "Payment URI:";
|
||||
strings_["request_receive_address"] = "Receive Address:";
|
||||
strings_["request_select_address"] = "Select address...";
|
||||
strings_["request_no_shielded"] = "You don't have a shielded address yet. Create one to receive payments.";
|
||||
strings_["request_create_shielded"] = "Create a shielded address";
|
||||
strings_["request_shielded_addrs"] = "-- Shielded Addresses --";
|
||||
strings_["request_title"] = "Request Payment";
|
||||
strings_["request_uri_copied"] = "Payment URI copied to clipboard";
|
||||
@@ -2584,6 +2588,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["send_amount_details"] = "AMOUNT DETAILS";
|
||||
strings_["send_amount_upper"] = "AMOUNT";
|
||||
strings_["send_clear_fields"] = "Clear all form fields?";
|
||||
strings_["send_clear_form"] = "Clear form";
|
||||
strings_["send_clear_form_tip"] = "Clear the recipient, amount and memo. You can undo for a few seconds.";
|
||||
strings_["send_copy_error"] = "Copy Error";
|
||||
strings_["send_dismiss"] = "Dismiss";
|
||||
strings_["send_error_copied"] = "Error copied to clipboard";
|
||||
@@ -2600,6 +2606,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["send_network_fee_label"] = "Network fee:";
|
||||
strings_["send_total_label"] = "Total:";
|
||||
strings_["send_no_balance"] = "No balance";
|
||||
strings_["send_transparent_source_warn"] = "Sending from a transparent address is not private: the sender, amount and recipient are visible on-chain. Use a shielded (Z) address for privacy.";
|
||||
strings_["send_no_recent"] = "No recent sends";
|
||||
strings_["send_recent_sends"] = "RECENT SENDS";
|
||||
strings_["send_recipient"] = "RECIPIENT";
|
||||
@@ -2615,6 +2622,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
||||
strings_["send_tooltip_select_source"] = "Select a source address first";
|
||||
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
||||
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
|
||||
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
||||
strings_["send_total"] = "Total";
|
||||
strings_["send_tx_failed"] = "Transaction failed";
|
||||
|
||||
Reference in New Issue
Block a user