feat(wallet): live progress modal for wallet switching

The "Stop the running node?" confirm modal now stays open through the entire
switch — stop → wait-for-exit → start → reconnect — showing a live phase, and
auto-closes the moment the new node connects. This turns the up-to-a-minute
graceful-shutdown wait from an apparent freeze into visible progress.

- WalletSwitchPhase (Stopping/Starting/Reconnecting/Failed) + atomic phase and
  dialog-open flags; the worker advances the phase, onConnected closes the modal,
  and a failed switch shows the accurate reason with a Close button.
- Owned switches (no confirm) also show the progress modal directly.
- "Continue in background" escape hatch so a long rescan / a hung startup never
  traps the user (the switch keeps running; a toast reports the result).
- isWalletSwitchInProgress() keeps the frame loop redrawing so the phase text and
  spinner animate while otherwise idle.
- i18n (EN + 8 languages, additive) + a modal-switch-progress sweep surface.

State machine adversarially verified 6/6 (thread-safety, no stuck modal,
confirm→progress transition, owned/unowned, no phase leak, redraw scoping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:56:53 -05:00
parent 02839dc415
commit 88091bd77c
14 changed files with 161 additions and 29 deletions

View File

@@ -3863,35 +3863,70 @@ void App::renderAntivirusHelpDialog()
void App::renderSwitchStopDaemonDialog()
{
if (!show_switch_stop_daemon_confirm_) return;
const bool confirm = show_switch_stop_daemon_confirm_;
const bool progress = wallet_switch_dialog_open_.load();
if (!confirm && !progress) return;
const auto phase = static_cast<WalletSwitchPhase>(wallet_switch_phase_.load());
const bool failed = progress && phase == WalletSwitchPhase::Failed;
ui::material::OverlayDialogSpec ov;
ov.title = TR("switch_stopnode_title");
ov.p_open = &show_switch_stop_daemon_confirm_; // the X / backdrop click acts as Cancel
ov.title = confirm ? TR("switch_stopnode_title")
: failed ? TR("switch_progress_failed_title")
: TR("switch_progress_title");
// A confirm can be dismissed via X/backdrop (= cancel); a running switch cannot (stay until it's up).
ov.p_open = confirm ? &show_switch_stop_daemon_confirm_ : nullptr;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "switchstopnode";
if (!ui::material::BeginOverlayDialog(ov)) {
// Closed via X/backdrop — treat as cancel: drop the pending target.
if (!show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear();
if (confirm && !show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear(); // X = cancel
return;
}
const float dp = ui::Layout::dpiScale();
ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
const std::string w = pending_switch_wallet_file_;
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
switchToWallet(w, /*stopDaemonConfirmed=*/true); // now proceeds to stop + wait + restart
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
if (confirm) {
ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
const std::string w = pending_switch_wallet_file_;
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
switchToWallet(w, /*stopDaemonConfirmed=*/true); // opens the live-progress phase below
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
}
} else if (failed) {
ui::material::DialogWarningHeader(TR("switch_progress_failed_title"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", wallet_switch_error_.c_str());
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) {
wallet_switch_dialog_open_.store(false);
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
wallet_switch_error_.clear();
}
} else {
// Live progress: an animated phase line, kept redrawing by isWalletSwitchInProgress().
const char* status =
phase == WalletSwitchPhase::Starting ? TR("switch_progress_starting")
: phase == WalletSwitchPhase::Reconnecting ? TR("switch_progress_reconnecting")
: TR("switch_progress_stopping");
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ui::material::Type().text(ui::material::TypeStyle::Subtitle1,
(std::string(status) + ui::material::LoadingDots()).c_str());
if (phase == WalletSwitchPhase::Stopping) {
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("switch_progress_hint"));
}
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Escape hatch — the switch keeps running in the background (a toast reports the result).
if (ui::material::TactileButton(TR("switch_progress_background"), ImVec2(220.0f * dp, 0)))
wallet_switch_dialog_open_.store(false);
}
ui::material::EndOverlayDialog();